Go語言單元測試

簡介

Go 語言在設計之初就考慮到了代碼的可測試性。一方面 Go 自己提供了 testing 庫,使用方法很簡單;
另外一方面 go 的 package 提供了不少編譯選項,代碼和業務邏輯代碼很容易解耦,可讀性比較強(不妨對比一下C++測試框架)。 本文中,咱們討論的重點是 Go 語言中
的單元測試,並且只討論一些基本的測試方法,包括下面幾個方面:html

  1. 寫一個簡單的測試用例git

  2. Table driven testgithub

  3. 使用輔助測試函數(test helper)golang

  4. 臨時文件數據庫

這裏咱們只涉及到一些通用的測試方法。關於 HTTP server/client 測試,這裏不作深刻討論。json

閱讀建議

Testing shows the presence, not the absence of bugs -- Edsger W. Dijkstrawindows

在閱讀本文以前,建議您對 Go 語言的 package 有必定的瞭解,並在實際項目中使用過,下面是一些基本的要求:api

  1. 瞭解如何在項目中 import 一個外部的package微信

  2. 瞭解如何將本身的項目按照功能模塊劃分 package框架

  3. 瞭解 struct、struct字段、函數、變量名字首字母大小寫的含義(非必需)

  4. 瞭解一些 Go語言的編譯選項,好比 +build !windows(非必需)

若是你對 一、2都不太瞭解,建議閱讀一下這篇文章How to Write Go Code,動手實踐一下。

寫一個簡單的測試用例

爲了便於理解,咱們首先給出一個代碼片斷(若是你已經使用過go 的單元測試,能夠跳過這個環節):

// demo/equal.go
package demo

// a function to check if two numbers equals to each other.
func equal(a, b int) bool {
  return a == b
}

// demo/equal_test.go
package demo
import (
  "testing"
)

func TestEqual(t *testing.T) {
  a := 1
  b := 1
  shouldBe := true
  if real := equal(a, b); real == shouldBe {
    t.Errorf("equal(%d, %d) should be %v, but is:%v\n", a, b, shouldBe, real)
  }
}

上面這個例子中,若是你歷來沒有使用過單元測試,建議在本地開發環境中運行一次。這裏有幾點須要注意一下:

  1. 這兩個文件的父目錄必須與包名一致(這裏是 demo),且包名必須是在 $GOPATH 下

  2. 測試用例的函數命名必須符合 TestXXX 格式,而且參數是 t *testing.T

  3. 瞭解一下 t.Errorf 與 t.Fatalf 的行爲差別

Table Driven Test

上面的測試用例中,咱們一次只能測試一種狀況,若是咱們但願在一個 TestXXX 函數中進行不少項測試,Table Driven Test 就派上了用場。
舉個例子,假設咱們實現了本身的 Sqrt 函數 mymath.Sqrt,咱們須要對其進行測試:

首先,咱們須要考慮一些特殊狀況:

  1. Sqrt(+Inf) = +Inf

  2. Sqrt(±0) = ±0

  3. Sqrt(x < 0) = NaN

  4. Sqrt(NaN) = NaN

而後,咱們須要考慮通常狀況:

  1. Sqrt(1.0) = 1.0

  2. Sqrt(4.0) = 2.0

  3. ...

注意:在通常狀況中,咱們對結果進行驗證時,須要考慮小數點精確位數的問題。因爲文章篇幅限制,這裏不作額外的處理。

有了思路之後,咱們能夠基於 Table Driven Test 實現測試用例:

func TestSqrt(t *testing.T) {
  var shouldSuccess = []struct {
    input    float64 // input
    expected float64 // expected result
  }{
    {math.Inf(1), math.Inf(1)}, // positive infinity
    {math.Inf(-1), math.NaN()}, // negative infinity
    {-1.0, math.NaN()},
    {0.0, 0.0},
    {-0.0, -0.0},
    {1.0, 1.0},
    {4.0, 2.0},
  }
  for _, ts := range shouldSuccess {
    if actual := Sqrt(t.input); actual != ts.expected {
      t.Fatalf("Sqrt(%f) should be %v, but is:%v\n", ts.input, ts.expected, actual)
    }
  }
}

輔助函數 (test helper)

在寫測試的過程當中,咱們可能遇到下面幾個場景:

  1. 待測試的功能須要一些前提條件,好比初始化數據庫鏈接、打開文件、建立資源

  2. 核心功能測試結束後,須要一些清理工做,好比關閉文件、銷燬資源

  3. 待測試的功能錯誤分類比較多,考慮到table driven test,寫到一個測試函數裏可讀性比較差

這時候,咱們須要定義一些輔助函數,以協助核心功能的測試。下面咱們以用戶登陸校驗爲例,來看如何使用輔助函數。
咱們要測試的函數是 login,爲了保證本次單元測試不會污染數據庫,咱們採起的流程是:

  1. 初始化數據庫鏈接(相似於 Junit 中的 @Before)

  2. 建立一個用戶 (相似於 Junit 中的 @Before)

  3. 測試 login

  4. 刪除該用戶(相似於 Junit 中的 @After)

肯定了測試的邏輯之後,咱們看下代碼:

// file name: user_test.go
// source code: https://github.com/oscarzhao/blogger-server/blob/master/controllers/user_test.go

// package level initialization of database connections
func init() {
  // init database connections
}

// testCreateUser 建立一個臨時用戶(test helper)
// 具體流程:
// 1. mocks a http server
// 2. send create user request to the server
func testCreateUser(t *testing, userSpec map[string]string) (int, []byte) {
  // mock a http server
  router := denco.New()
  router.Build([]denco.Record{
    {"/api/v1/users/:user_id", &route{}},
  })

  testURL := "/api/v1/users/" + userID
  _, params, found := router.Lookup(testURL)
  if !found {
    t.Fatalf("fails to look up the route, url:%s\n", testURL)
  }

  handler := func(w http.ResponseWriter, r *http.Request) {
    CreateUser(w, r, params)
  }

  marshaled, _ := json.Marshal(userSpec)
  // create request
  req, err := http.NewRequest("POST", "http://anything.com", bytes.NewBuffer(marshaled))
  if err != nil {
    t.Fatalf("should create user success, but fails to send request, error:%s\n", err)
  }

  // mock ResponseWriter
  w := httptest.NewRecorder()
  // call create operation
  handler(w, req)
  return w.Code, w.Body.Bytes()
}

// testDeleteUser 根據 userID 刪除一個用戶(test helper)
func testDeleteUser(t *testing.T, userID string) (int, []byte) {
  ...
}

// TestVerifyLogin 建立用戶、測試登陸,而後刪除該用戶
// 該函數由 go 語言的 test 框架調用
func TestVerifyLogin(t *testing.T) {
  userID := uuid.NewV4().String()
  data := map[string]string{
    "username": "simple_yyxxzz",
    "password": "simple_password",
    "email":    "not@changed.com",
    "phone":    "1234567890",
  }
  statusCode, msg := testCreateUser(t, userID, data)
  if statusCode >= http.StatusBadRequest {
    t.Fatalf("should succeeed, create user (%s), but fails, error:%s\n", userID, msg)
  }
  // 測試結束時,清理數據
  defer func(userID string) {
    statusCode, msg := testDeleteUser(t, userID)
    if statusCode >= http.StatusBadRequest {
      t.Errorf("should delete user(%s) successfully, but fails, status code:%d, error:%s\n", userID, statusCode, msg)
    }
  }(userID)

  // 測試登陸功能
  shouldSuccess := xxx
  for _, ts := range shouldSuccess {
    statusCode, msg = testVerifyPassword(t, ts)
    if statusCode != http.StatusOK {
      // if use fatal, user will not be cleaned up
      t.Errorf("should verify with %v successfully, but failed, status code:%d, error:%s\n", ts, statusCode, msg)
      return
    }
  }
}

在測試代碼中,咱們推薦使用 t.Fatalf , 而不是 t.Errorf,一方面測試代碼不須要作太多容錯,另外一方面增長了測試代碼的可讀性。

臨時文件

若是待測試的功能模塊涉及到文件操做,臨時文件是一個不錯的解決方案。go語言的 ioutil 包提供了 TempDir 和
TempFile 方法,供咱們使用。

咱們以 etcd 建立 wal 文件爲例,來看一下 TempDir 的用法:

// github.com/coreos/etcd/wal/wal_test.go

func TestNew(t *testing.T) {
  p, err := ioutil.TempDir(os.TempDir(), "waltest")
  if err != nil {
    t.Fatal(err)
  }
  defer os.RemoveAll(p)  // 千萬不要忘記刪除目錄

  w, err := Create(p, []byte("somedata"))
  if err != nil {
    t.Fatalf("err = %v, want nil", err)
  }
  if g := path.Base(w.tail().Name()); g != walName(0, 0) {
    t.Errorf("name = %+v, want %+v", g, walName(0, 0))
  }
  defer w.Close()

  // 將文件 waltest 中的數據讀取到變量 gb []byte 中 
  // ...

  // 根據 "somedata" 生成數據,存儲在變量 wb byte.Buffer 中
  // ...

  // 臨時文件中的數據(gb)與 生成的數據(wb)進行對比
  if !bytes.Equal(gd, wb.Bytes()) {
    t.Errorf("data = %v, want %v", gd, wb.Bytes())
  }
}

上面這段代碼是從 etcd 中摘取出來的,源碼查看 coreos/etcd - Github
須要注意的是,使用 TempDirTempFile 建立文件之後,須要本身去刪除。

關於 package

在寫單元測試時,通常狀況下,咱們將功能代碼和測試代碼放到同一個目錄下,僅之後綴 _test 進行區分。

對於複雜的大型項目,功能依賴比較多時,一般在跟目錄下再增長一個 test 文件夾,不一樣的測試
放到不一樣的子目錄下面,以下圖所示:

kubernetes/kubernetes

針對本身的項目進行測試時,能夠結合這兩種方式實現測試用例,提升代碼的可讀性和可維護性。

相關連接:

  1. golang.org/pkg/testing

  2. Testing Techniques

  3. Table Driven Test

  4. Learn Testing

掃碼關注微信公衆號「深刻Go語言」

在這裏

相關文章
相關標籤/搜索