首頁 > 軟體

Go語言自帶測試庫testing使用教學

2022-07-22 14:02:02

簡介

testing是 Go 語言標準庫自帶的測試庫。在 Go 語言中編寫測試很簡單,只需要遵循 Go 測試的幾個約定,與編寫正常的 Go 程式碼沒有什麼區別。Go 語言中有 3 種型別的測試:單元測試,效能測試,範例測試。下面依次來介紹。

單元測試

單元測試又稱為功能性測試,是為了測試函數、模組等程式碼的邏輯是否正確。接下來我們編寫一個庫,用於將表示羅馬數位的字串和整數互轉。羅馬數位是由M/D/C/L/X/V/I這幾個字元根據一定的規則組合起來表示一個正整數:

  • M=1000,D=500,C=100,L=50,X=10,V=5,I=1;
  • 只能表示 1-3999 範圍內的整數,不能表示 0 和負數,不能表示 4000 及以上的整數,不能表示分數和小數(當然有其他複雜的規則來表示這些數位,這裡暫不考慮);
  • 每個整數只有一種表示方式,一般情況下,連寫的字元表示對應整數相加,例如I=1II=2III=3。但是,十位字元(I/X/C/M)最多出現 3 次,所以不能用IIII表示 4,需要在V左邊新增一個I(即IV)來表示,不能用VIIII表示 9,需要使用IX代替。另外五位字元(V/L/D)不能連續出現 2 次,所以不能出現VV,需要用X代替。
// roman.go
package roman
import (
  "bytes"
  "errors"
  "regexp"
)
type romanNumPair struct {
  Roman string
  Num   int
}
var (
  romanNumParis []romanNumPair
  romanRegex    *regexp.Regexp
)
var (
  ErrOutOfRange   = errors.New("out of range")
  ErrInvalidRoman = errors.New("invalid roman")
)
func init() {
  romanNumParis = []romanNumPair{
    {"M", 1000},
    {"CM", 900},
    {"D", 500},
    {"CD", 400},
    {"C", 100},
    {"XC", 90},
    {"L", 50},
    {"XL", 40},
    {"X", 10},
    {"IX", 9},
    {"V", 5},
    {"IV", 4},
    {"I", 1},
  }
  romanRegex = regexp.MustCompile(`^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$`)
}
func ToRoman(n int) (string, error) {
  if n <= 0 || n >= 4000 {
    return "", ErrOutOfRange
  }
  var buf bytes.Buffer
  for _, pair := range romanNumParis {
    for n > pair.Num {
      buf.WriteString(pair.Roman)
      n -= pair.Num
    }
  }
  return buf.String(), nil
}
func FromRoman(roman string) (int, error) {
  if !romanRegex.MatchString(roman) {
    return 0, ErrInvalidRoman
  }
  var result int
  var index int
  for _, pair := range romanNumParis {
    for roman[index:index+len(pair.Roman)] == pair.Roman {
      result += pair.Num
      index += len(pair.Roman)
    }
  }
  return result, nil
}

在 Go 中編寫測試很簡單,只需要在待測試功能所在檔案的同級目錄中建立一個以_test.go結尾的檔案。在該檔案中,我們可以編寫一個個測試函數。測試函數名必須是TestXxxx這個形式,而且Xxxx必須以大寫字母開頭,另外函數帶有一個*testing.T型別的引數:

// roman_test.go
package roman
import (
  "testing"
)
func TestToRoman(t *testing.T) {
  _, err1 := ToRoman(0)
  if err1 != ErrOutOfRange {
    t.Errorf("ToRoman(0) expect error:%v got:%v", ErrOutOfRange, err1)
  }
  roman2, err2 := ToRoman(1)
  if err2 != nil {
    t.Errorf("ToRoman(1) expect nil error, got:%v", err2)
  }
  if roman2 != "I" {
    t.Errorf("ToRoman(1) expect:%s got:%s", "I", roman2)
  }
}

在測試函數中編寫的程式碼與正常的程式碼沒有什麼不同,呼叫相應的函數,返回結果,判斷結果與預期是否一致,如果不一致則呼叫testing.TErrorf()輸出錯誤資訊。執行測試時,這些錯誤資訊會被收集起來,執行結束後統一輸出。

測試編寫完成之後,使用go test命令執行測試,輸出結果:

$ go test

--- FAIL: TestToRoman (0.00s)
    roman_test.go:18: ToRoman(1) expect:I got:
FAIL
exit status 1
FAIL    github.com/darjun/go-daily-lib/testing  0.172s

我故意將ToRoman()函數中寫錯了一行程式碼,n > pair.Num>應該為>=,單元測試成功找出了錯誤。修改之後重新執行測試:

$ go test
PASS
ok      github.com/darjun/go-daily-lib/testing  0.178s

這次測試都通過了!

我們還可以給go test命令傳入-v選項,輸出詳細的測試資訊:

$ go test -v

=== RUN   TestToRoman
--- PASS: TestToRoman (0.00s)
PASS
ok      github.com/darjun/go-daily-lib/testing  0.174s

在執行每個測試函數前,都輸出一行=== RUN,執行結束之後輸出--- PASS--- FAIL資訊。

表格驅動測試

在上面的例子中,我們實際上只測試了兩種情況,0 和 1。按照這種方式將每種情況都寫出來就太繁瑣了,Go 中流行使用表格的方式將各個測試資料和結果列舉出來:

func TestToRoman(t *testing.T) {
  testCases := []struct {
    num    int
    expect string
    err    error
  }{
    {0, "", ErrOutOfRange},
    {1, "I", nil},
    {2, "II", nil},
    {3, "III", nil},
    {4, "IV", nil},
    {5, "V", nil},
    {6, "VI", nil},
    {7, "VII", nil},
    {8, "VIII", nil},
    {9, "IX", nil},
    {10, "X", nil},
    {50, "L", nil},
    {100, "C", nil},
    {500, "D", nil},
    {1000, "M", nil},
    {31, "XXXI", nil},
    {148, "CXLVIII", nil},
    {294, "CCXCIV", nil},
    {312, "CCCXII", nil},
    {421, "CDXXI", nil},
    {528, "DXXVIII", nil},
    {621, "DCXXI", nil},
    {782, "DCCLXXXII", nil},
    {870, "DCCCLXX", nil},
    {941, "CMXLI", nil},
    {1043, "MXLIII", nil},
    {1110, "MCX", nil},
    {1226, "MCCXXVI", nil},
    {1301, "MCCCI", nil},
    {1485, "MCDLXXXV", nil},
    {1509, "MDIX", nil},
    {1607, "MDCVII", nil},
    {1754, "MDCCLIV", nil},
    {1832, "MDCCCXXXII", nil},
    {1993, "MCMXCIII", nil},
    {2074, "MMLXXIV", nil},
    {2152, "MMCLII", nil},
    {2212, "MMCCXII", nil},
    {2343, "MMCCCXLIII", nil},
    {2499, "MMCDXCIX", nil},
    {2574, "MMDLXXIV", nil},
    {2646, "MMDCXLVI", nil},
    {2723, "MMDCCXXIII", nil},
    {2892, "MMDCCCXCII", nil},
    {2975, "MMCMLXXV", nil},
    {3051, "MMMLI", nil},
    {3185, "MMMCLXXXV", nil},
    {3250, "MMMCCL", nil},
    {3313, "MMMCCCXIII", nil},
    {3408, "MMMCDVIII", nil},
    {3501, "MMMDI", nil},
    {3610, "MMMDCX", nil},
    {3743, "MMMDCCXLIII", nil},
    {3844, "MMMDCCCXLIV", nil},
    {3888, "MMMDCCCLXXXVIII", nil},
    {3940, "MMMCMXL", nil},
    {3999, "MMMCMXCIX", nil},
    {4000, "", ErrOutOfRange},
  }
  for _, testCase := range testCases {
    got, err := ToRoman(testCase.num)
    if got != testCase.expect {
      t.Errorf("ToRoman(%d) expect:%s got:%s", testCase.num, testCase.expect, got)
    }
    if err != testCase.err {
      t.Errorf("ToRoman(%d) expect error:%v got:%v", testCase.num, testCase.err, err)
    }
  }
}

上面將要測試的每種情況列舉出來,然後針對每個整數呼叫ToRoman()函數,比較返回的羅馬數位字串和錯誤值是否與預期的相符。後續要新增新的測試用例也很方便。

分組和並行

有時候對同一個函數有不同維度的測試,將這些組合在一起有利於維護。例如上面對ToRoman()函數的測試可以分為非法值,單個羅馬字元和普通 3 種情況。

為了分組,我對程式碼做了一定程度的重構,首先抽象一個toRomanCase結構:

type toRomanCase struct {
  num    int
  expect string
  err    error
}

將所有的測試資料劃分到 3 個組中:

var (
  toRomanInvalidCases []toRomanCase
  toRomanSingleCases  []toRomanCase
  toRomanNormalCases  []toRomanCase
)
func init() {
  toRomanInvalidCases = []toRomanCase{
    {0, "", roman.ErrOutOfRange},
    {4000, "", roman.ErrOutOfRange},
  }
  toRomanSingleCases = []toRomanCase{
    {1, "I", nil},
    {5, "V", nil},
    // ...
  }
  toRomanNormalCases = []toRomanCase{
    {2, "II", nil},
    {3, "III", nil},
    // ...
  }
}

然後為了避免程式碼重複,抽象一個執行多個toRomanCase的函數:

func testToRomanCases(cases []toRomanCase, t *testing.T) {
  for _, testCase := range cases {
    got, err := roman.ToRoman(testCase.num)
    if got != testCase.expect {
      t.Errorf("ToRoman(%d) expect:%s got:%s", testCase.num, testCase.expect, got)
    }
    if err != testCase.err {
      t.Errorf("ToRoman(%d) expect error:%v got:%v", testCase.num, testCase.err, err)
    }
  }
}

為每個分組定義一個測試函數:

func testToRomanInvalid(t *testing.T) {
  testToRomanCases(toRomanInvalidCases, t)
}
func testToRomanSingle(t *testing.T) {
  testToRomanCases(toRomanSingleCases, t)
}
func testToRomanNormal(t *testing.T) {
  testToRomanCases(toRomanNormalCases, t)
}

在原來的測試函數中,呼叫t.Run()執行不同分組的測試函數,t.Run()第一個引數為子測試名,第二個引數為子測試函數:

func TestToRoman(t *testing.T) {
  t.Run("Invalid", testToRomanInvalid)
  t.Run("Single", testToRomanSingle)
  t.Run("Normal", testToRomanNormal)
}

執行:

$ go test -v

=== RUN   TestToRoman
=== RUN   TestToRoman/Invalid
=== RUN   TestToRoman/Single
=== RUN   TestToRoman/Normal
--- PASS: TestToRoman (0.00s)
    --- PASS: TestToRoman/Invalid (0.00s)
    --- PASS: TestToRoman/Single (0.00s)
    --- PASS: TestToRoman/Normal (0.00s)
PASS
ok      github.com/darjun/go-daily-lib/testing  0.188s

可以看到,依次執行 3 個子測試,子測試名是父測試名和t.Run()指定的名字組合而成的,如TestToRoman/Invalid

預設情況下,這些測試都是依次順序執行的。如果各個測試之間沒有聯絡,我們可以讓他們並行以加快測試速度。方法也很簡單,在testToRomanInvalid/testToRomanSingle/testToRomanNormal這 3 個函數開始處呼叫t.Parallel(),由於這 3 個函數直接呼叫了testToRomanCases,也可以只在testToRomanCases函數開頭出新增:

func testToRomanCases(cases []toRomanCase, t *testing.T) {
  t.Parallel()
  // ...
}

執行:

$ go test -v
...
--- PASS: TestToRoman (0.00s)
    --- PASS: TestToRoman/Invalid (0.00s)
    --- PASS: TestToRoman/Normal (0.00s)
    --- PASS: TestToRoman/Single (0.00s)
PASS
ok      github.com/darjun/go-daily-lib/testing  0.182s

我們發現測試完成的順序並不是我們指定的順序。

另外,這個範例中我將roman_test.go檔案移到了roman_test包中,所以需要import "github.com/darjun/go-daily-lib/testing/roman"。這種方式在測試包有迴圈依賴的情況下非常有用,例如標準庫中net/http依賴net/urlurl的測試函數依賴net/http,如果把測試放在net/url包中,那麼就會導致迴圈依賴url_test(net/url)->net/http->net/url。這時可以將url_test放在一個獨立的包中。

主測試函數

有一種特殊的測試函數,函數名為TestMain(),接受一個*testing.M型別的引數。這個函數一般用於在執行所有測試前執行一些初始化邏輯(如建立資料庫連結),或所有測試都執行結束之後執行一些清理邏輯(釋放資料庫連結)。如果測試檔案中定義了這個函數,則go test命令會直接執行這個函數,否者go test會建立一個預設的TestMain()函數。這個函數的預設行為就是執行檔案中定義的測試。我們自定義TestMain()函數時,也需要手動呼叫m.Run()方法執行測試函數,否則測試函數不會執行。預設的TestMain()類似下面程式碼:

func TestMain(m *testing.M) {
  os.Exit(m.Run())
}

下面自定義一個TestMain()函數,列印go test支援的選項:

func TestMain(m *testing.M) {
  flag.Parse()
  flag.VisitAll(func(f *flag.Flag) {
    fmt.Printf("name:%s usage:%s value:%vn", f.Name, f.Usage, f.Value)
  })
  os.Exit(m.Run())
}

執行:

$ go test -v
name:test.bench usage:run only benchmarks matching `regexp` value:
name:test.benchmem usage:print memory allocations for benchmarks value:false
name:test.benchtime usage:run each benchmark for duration `d` value:1s
name:test.blockprofile usage:write a goroutine blocking profile to `file` value:
name:test.blockprofilerate usage:set blocking profile `rate` (see runtime.SetBlockProfileRate) value:1
name:test.count usage:run tests and benchmarks `n` times value:1
name:test.coverprofile usage:write a coverage profile to `file` value:
name:test.cpu usage:comma-separated `list` of cpu counts to run each test with value:
name:test.cpuprofile usage:write a cpu profile to `file` value:
name:test.failfast usage:do not start new tests after the first test failure value:false
name:test.list usage:list tests, examples, and benchmarks matching `regexp` then exit value:
name:test.memprofile usage:write an allocation profile to `file` value:
name:test.memprofilerate usage:set memory allocation profiling `rate` (see runtime.MemProfileRate) value:0
name:test.mutexprofile usage:write a mutex contention profile to the named file after execution value:
name:test.mutexprofilefraction usage:if >= 0, calls runtime.SetMutexProfileFraction() value:1
name:test.outputdir usage:write profiles to `dir` value:
name:test.paniconexit0 usage:panic on call to os.Exit(0) value:true
name:test.parallel usage:run at most `n` tests in parallel value:8
name:test.run usage:run only tests and examples matching `regexp` value:
name:test.short usage:run smaller test suite to save time value:false
name:test.testlogfile usage:write test action log to `file` (for use only by cmd/go) value:
name:test.timeout usage:panic test binary after duration `d` (default 0, timeout disabled) value:10m0s
name:test.trace usage:write an execution trace to `file` value:
name:test.v usage:verbose: print additional output value:tru

這些選項也可以通過go help testflag檢視。

其他

另一個函數FromRoman()我沒有寫任何測試,就交給大家了


IT145.com E-mail:sddin#qq.com