一个测试工程师走进一家酒吧……
一个测试工程师走进一家酒吧,要了一杯啤酒;
一个测试工程师走进一家酒吧,要了一杯咖啡;
一个测试工程师走进一家酒吧,要了 0.7 杯啤酒;
一个测试工程师走进一家酒吧,要了-1 杯啤酒;
一个测试工程师走进一家酒吧,要了 2^32 杯啤酒;
一个测试工程师走进一家酒吧,要了一杯洗脚水;
一个测试工程师走进一家酒吧,要了一杯蜥蜴;
一个测试工程师走进一家酒吧,要了一份 asdfQwer@24dg!&*(@;
一个测试工程师走进一家酒吧,什么也没要;
一个测试工程师走进一家酒吧,又走出去又从窗户进来又从后门出去从下水道钻进来;
一个测试工程师走进一家酒吧,又走出去又进来又出去又进来又出去,最后在外面把老板打了一顿;
一个测试工程师走进一家酒吧,要了一杯烫烫烫的锟斤拷;
一个测试工程师走进一家酒吧,要了 NaN 杯 Null;
一个测试工程师冲进一家酒吧,要了 500T 啤酒咖啡洗脚水野猫狼牙棒奶茶;
一个测试工程师把酒吧拆了;
一个测试工程师化装成老板走进一家酒吧,要了 500 杯啤酒并且不付钱;
一万个测试工程师在酒吧门外呼啸而过;
一个测试工程师走进一家酒吧,要了一杯啤酒’;DROP TABLE 酒吧;
测试工程师们满意地离开了酒吧。
然后一名顾客点了一份炒饭,酒吧炸了。
上面是网上流行的一个关于测试的笑话,其主要核心思想是——你永远无法把所有问题都充分测试。
在软件工程中,测试是极其重要的一环,比重通常可以与编码相同,甚至大大超过。那么在 Golang 里,怎么样把测试写好,写正确?本文将对这个问题做一些简单的介绍。 当前文章将主要分两个部分:
-
Golang 测试的一些基本写法和工具
-
如何写“正确”的测试,这个部分虽然代码是用 golang 编写,但是其核心思想不限语言
由于篇幅问题,本文将不涉及性能测试,之后会另起一篇来谈。
为什么要写测试
我们举个不太恰当的例子,测试也是代码,我们假定写代码时出现 bug 的概率是 p(0<p<1),那么我们同时写测试的话,两边同时出现 bug 的概率就是(我们认为两个事件相互独立)
P(代码出现 bug) * P(测试出现 Bug) = p^2 < p
例如 p 是 1%的话,那么同时写出现 bug 的概率就只有 0.01%了。
测试同样也是代码,有可能也写出 bug,那么怎么保证测试的正确性呢?给测试也写测试?给测试的测试继续写测试?
我们定义 t(0)为原始的代码,任意的 i,i > 0,t(i+1)为对于 t(i)的测试,t(i+1)正确为 t(i)正确的必要条件,那么对所有的 i,i>0,t(i)正确都是 t(0)正确的必要条件。。。
我们无法在出错的时候对单个测试重新执行 所以推荐尽可能对每个 t.Run 都要独立书写,例如:
f := func(a, b, exp int) func(t *testing.T) { return func(t *testing.T) { require.Equal(t, exp, Add(a, b)) } } t.Run("t1", f(1, 2, 3)) t.Run("t2", f(4, 5, 9))
测试分包
我们上面的 add.go 和 add_test.go 文件都处于同一个目录下,顶部的 package 名称都是 add,那么在写测试的过程中,也可以为测试启用与非测试文件不同的包名,例如我们现在将测试文件的包名改为 add_test:
// add_test.go package add_test import ( "testing" ) func TestAdd(t *testing.T) { res := Add(1, 2) if res != 3 { t.Errorf("the result is %d instead of 3", res) } }
这个时候执行 go test 会发现
% go test # code.byted.org/ek/demo_test/t03_diffpkg_test [code.byted.org/ek/demo_test/t03_diffpkg.test] ./add_test.go:9:9: undefined: Add FAIL code.byted.org/ek/demo_test/t03_diffpkg [build failed]
由于包名变化了,我们无法再访问到 Add 函数,这个时候我们增加 import 即可:
// add_test.go package add_test import ( "testing" . "code.byted.org/ek/demo_test/t03_diffpkg" ) func TestAdd(t *testing.T) { res := Add(1, 2) if res != 3 { t.Errorf("the result is %d instead of 3", res) } }
我们使用上面的方式来导入包内的函数即可。 但使用了这种方式后,将无法访问包内未导出的函数(以小写开头的)。
测试的工具库
github.com/stretchr/testify
我们可以使用强大的 testify 来方便我们写测试 例如上面的测试我们可以用这个库写成:
// add_test.go package correct import ( "testing" "github.com/stretchr/testify/require" ) func TestAdd(t *testing.T) { res := Add(1, 2) require.Equal(t, 3, res) /* must := require.New(t) res := Add(1, 2) must.Equal(3, res) */ }
如果执行失败,则会在命令行看到如下输出:
% go test ok code.byted.org/ek/demo_test/t04_libraries/testify/correct 0.008s --- FAIL: TestAdd (0.00s) add_test.go:12: Error Trace: add_test.go:12 Error: Not equal: expected: 3 actual : -1 Test: TestAdd FAIL FAIL code.byted.org/ek/demo_test/t04_libraries/testify/wrong 0.009s FAIL
库提供了格式化的错误详情(堆栈、错误值、期望值等)来方便我们调试。
github.com/DATA-DOG/go-sqlmock
对于需要测试 sql 的地方可以使用 go-sqlmock 来测试
-
优点:不需要依赖数据库
-
缺点:脱离了数据库的具体实现,所以需要写比较复杂的测试代码
github.com/golang/mock
强大的对 interface 的 mock 库,例如我们要测试函数 ioutil.ReadAll
func ReadAll(r io.Reader) ([]byte, error)
我们 mock 一个 io.Reader
// package: 输出包名 // destination: 输出文件 // io: mock对象的包 // Reader: mock对象的interface名 mockgen -package gomock -destination mock_test.go io Reader
可以在目录下看到 mock_test.go 文件里,包含了一个 io.Reader 的 mock 实现 我们可以使用这个实现去测试 ioutil.Reader,例如
ctrl := gomock.NewController(t) defer ctrl.Finish() m := NewMockReader(ctrl) m.EXPECT().Read(gomock.Any()).Return(0, errors.New("error")) _, err := ioutil.ReadAll(m) require.Error(t, err)
net/http/httptest
通常我们测试服务端代码的时候,会先启动服务,再启动测试。官方的 httptest 包给我们提供了一种方便地启动一个服务实例来测试的方法。
其他
其他一些测试工具可以前往 awesome-go#testing 查找
-
https://github.com/avelino/awesome-go#testing
如何写好测试
上面介绍了测试的基本工具和写法,我们已经完成了“必先利其器”,下面我们将介绍如何“善其事”。
并发测试
在平时,大家写服务的时候,基本都必须考虑并发,我们使用 IDE 测试的时候,IDE 默认情况下并不会主动测试并发状态,那么如何保证我们写出来的代码是并发安全的? 我们来举个例子,比如我们有个计数器,作用就是计数。
type Counter int32 func (c *Counter) Incr() { *c++ }
很显然这个计数器在并发情况下是不安全的,那么我们如何写一个测试来做这个计数器的并发测试呢?
import ( "sync" "testing" "github.com/stretchr/testify/require" ) func TestA_Incr(t *testing.T) { var a Counter eg := sync.WaitGroup{} count := 10 eg.Add(count) for i := 0; i < count; i++ { go func() { defer eg.Done() a.Incr() }() } eg.Wait() require.Equal(t, count, int(a)) }
通过多次执行上面的测试,我们发现有些时候,测试的结果返回 OK,有些时候测试的结果返回 FAIL。也就是说,即便写了测试,有可能在某次测试中被标记为通过测试。那么有没有什么办法直接发现问题呢?答案就是在测试的时候增加-race 的 flag
-race 标志不适合 benchmark 测试
go test -race
这时候终端会输出:
WARNING: DATA RACE Read at 0x00c00001ca50 by goroutine 9: code.byted.org/ek/demo_test/t05_race/race.(*A).Incr() /Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race.go:6 +0x6f code.byted.org/ek/demo_test/t05_race/race.TestA_Incr.func1() /Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race_test.go:18 +0x66 Previous write at 0x00c00001ca50 by goroutine 8: code.byted.org/ek/demo_test/t05_race/race.(*A).Incr() /Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race.go:6 +0x85 code.byted.org/ek/demo_test/t05_race/race.TestA_Incr.func1() /Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race_test.go:18 +0x66 Goroutine 9 (running) created at: code.byted.org/ek/demo_test/t05_race/race.TestA_Incr() /Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race_test.go:16 +0xe4 testing.tRunner() /usr/local/Cellar/go/1.15/libexec/src/testing/testing.go:1108 +0x202 Goroutine 8 (finished) created at: code.byted.org/ek/demo_test/t05_race/race.TestA_Incr() /Users/bytedance/go/src/code.byted.org/ek/demo_test/t05_race/race/race_test.go:16 +0xe4 testing.tRunner() /usr/local/Cellar/go/1.15/libexec/src/testing/testing.go:1108 +0x202
go 主动提示,我们的代码中发现了竞争(race)态,这个时候我们就要去修复代码
type Counter int32 func (c *Counter) Incr() { atomic.AddInt32((*int32)(c), 1) }
修复完成后再次伴随-race 进行测试,我们的测试成功通过!
Golang 原生的并发测试
golang 的测试类 testing.T 有一个方法 Parallel(),所有在测试中调用了该方法的都会被标记为并发,但是注意,如果需要使用并发测试的结果的话,必须在外层用一个额外的测试函数将其包住:
func TestA_Incr(t *testing.T) { var a Counter t.Run("outer", func(t *testing.T) { for i := 0; i < 100; i++ { t.Run("inner", func(t *testing.T) { t.Parallel() a.Incr() }) } }) t.Log(a) }
如果没有第三行的 t.Run,那么 11 行的打印结果将不正确
Golang 的 testing.T 还有很多别的实用方法,大家可以自己去查看一下,这里不详细讨论
正确测试返回值
作为一个 gopher 平时要写大量的 if err != nil,那么在测试一个函数返回的 error 的时候,我们比如有下面的例子
type I interface { Foo() error } func Bar(i1, i2 I) error { i1.Foo() return i2.Foo() }
Bar 函数希望依次处理 i1 和 i2 两个输入,当遇到第一个错误就返回,于是我们写了一个看起来“正确”的测试
import ( "errors" "testing" "github.com/stretchr/testify/require" ) type impl string func (i impl) Foo() error { return errors.New(string(i)) } func TestBar(t *testing.T) { i1 := impl("i1") i2 := impl("i2") err := Bar(i1, i2) require.Error(t, err) // assert err != nil }
这个测试结果“看起来”很完美,函数正确返回了一个错误。但是实际上我们知道这个函数的返回值是错误的,所以我们应当把测试稍作修改,将 error 当作一个返回值来校验起内容,而不是简单的判 nil 处理
func TestBarFixed(t *testing.T) { i1 := impl("i1") i2 := impl("i2") err := Bar(i1, i2) // 两种写法都可 require.Equal(t, errors.New("i1"), err) require.Equal(t, "i1", err.Error()) }
这个时候我们就能发现到,代码中出现了错误,需要修复了。 同理可以应用到别的返回值,我们不应当仅仅做一些简单的判断,而应当尽可能做“精确值”的判断。
测试输入参数
上面我们讨论过了测试返回值,输入值同样需要测试,这一点我们主要结合 gomock 来说,举个例子我们的代码如下:
type I interface { Foo(ctx context.Context, i int) (int, error) } type bar struct { i I } func (b bar) Bar(ctx context.Context, i int) (int, error) { i, err := b.i.Foo(context.Background(), i) return i + 1, err }
我们想要测试 bar 类是否正确在方法中调用了 Foo 方法 我们使用 gomock 来 mock 出我们想要的 I 接口的 mock 实现:
mockgen -package gomock -destination mock_test.go io Reader
接下来我们写了一个测试:
import ( "context" "testing" . "code.byted.org/ek/testutil/testcase" "github.com/stretchr/testify/require" ) func TestBar(t *testing.T) { t.Run("test", TF(func(must *require.Assertions, tc *TC) { impl := NewMockI(tc.GomockCtrl) i := 10 j := 11 ctx := context.Background() impl.EXPECT().Foo(ctx, i). Return(j, nil) b := bar{i: impl} r, err := b.Bar(ctx, i) must.NoError(err) must.Equal(j+1, r) })) }
测试运行成功,但实际上我们看了代码发现,代码中的 context 并没有被正确的传递,那么我们应该怎么去正确测试出这个情况呢? 一种办法是写一个差不多的测试,测试中修改 context.Background()为别的 context:
t.Run("correct", TF(func(must *require.Assertions, tc *TC) { impl := NewMockI(tc.GomockCtrl) i := 10 j := 11 ctx := context.WithValue(context.TODO(), "k", "v") impl.EXPECT().Foo(ctx, i). Return(j, nil) b := bar{i: impl} r, err := b.Bar(ctx, i) must.NoError(err) must.Equal(j+1, r) }))
另一种办法是加入随机测试要素。
为测试加入随机要素
同样是上面的测试,我们稍做修改
import ( "context" "testing" randTest "code.byted.org/ek/testutil/rand" . "code.byted.org/ek/testutil/testcase" "github.com/stretchr/testify/require" ) t.Run("correct", TF(func(must *require.Assertions, tc *TC) { impl := NewMockI(tc.GomockCtrl) i := 10 j := 11 ctx := context.WithValue(context.TODO(), randTest.String(), randTest.String()) impl.EXPECT().Foo(ctx, i). Return(j, nil) b := bar{i: impl} r, err := b.Bar(ctx, i) must.NoError(err) must.Equal(j+1, r) }))
这样就可以很大程度上避免由于固定的测试变量,导致的一些边缘 case 容易被误测为正确,如果回到之前的 Add 函数的例子,可以写成
import ( "math/rand" "testing" "github.com/stretchr/testify/require" ) func TestAdd(t *testing.T) { a := rand.Int() b := rand.Int() res := Add(a, b) require.Equal(t, a+b, res) }
经过修改的入参
如果我们修改一下之前的 Bar 的例子
func (b bar) Bar(ctx context.Context, i int) (int, error) { ctx = context.WithValue(ctx, "v", i) i, err := b.i.Foo(ctx, i) return i + 1, err }
函数基本相同,只是传递给 Foo 方法的 ctx 变成了一个子 context,这个时候之前的测试就无法正确执行了,那么如何来判断传递的 context 是最上层的 context 的一个子 context 呢?
通过手写实现判断
一个方法是在测试中,传递给 Bar 一个 context.WithValue,然后在 Foo 的实现中去判断收到的 context 是否带有特定的 kv
t.Run("correct", TF(func(must *require.Assertions, tc *TC) { impl := NewMockI(tc.GomockCtrl) i := 10 j := 11 k := randTest.String() v := randTest.String() ctx := context.WithValue(context.TODO(), k, v) impl.EXPECT().Foo(gomock.Any(), i). Do(func(ctx context.Context, i int) { s, _ := ctx.Value(k).(string) must.Equal(v, s) }). Return(j, nil) b := bar{i: impl} r, err := b.Bar(ctx, i) must.NoError(err) must.Equal(j+1, r) }))
gomock.Matcher
还有一种方法是实现 gomock.Matcher 这个 interface
import ( randTest "code.byted.org/ek/testutil/rand" ) t.Run("simple", TF(func(must *require.Assertions, tc *TC) { impl := NewMockI(tc.GomockCtrl) i := 10 j := 11 ctx := randTest.Context() impl.EXPECT().Foo(ctx, i). Return(j, nil) b := bar{i: impl} r, err := b.Bar(ctx, i) must.NoError(err) must.Equal(j+1, r) }))
randTest.Context 的主要代码如下:
func (ctx randomContext) Matches(x interface{}) bool { switch v := x.(type) { case context.Context: return v.Value(ctx) == ctx.value default: return false } }
gomock 会自动利用这个接口来判断输入参数的匹配情况。
测试含有很多子调用的函数
我们来看下面的函数:
func foo(i int) (int, error) { if i < 0 { return 0, errors.New("negative") } return i + 1, nil } func Bar(i, j int) (int, error) { i, err := foo(i) if err != nil { return 0, err } j, err = foo(j) if err != nil { return 0, err } return i + j, nil }
这里的逻辑看起来比较简单,但是如果我们想象 Bar 的逻辑和 foo 的逻辑都非常复杂,也包含比较多的逻辑分支,那么测试的时候会遇到两个问题
-
测试 Bar 函数的时候可能需要考虑各种 foo 函数返回值的情况,需要根据 foo 的需求特别构造入参
-
可能需要大量重复测试到 foo 的场景,与 foo 本身的测试重复
那么如何解决这个问题?我这里给大家提供一个思路,虽然可能不是最优解。有更好解法的希望能够在评论区提出。 我的思路是将 foo 函数从固定的函数变成一个可变的函数指针,可以在测试的时候被动态替换
var foo = func(i int) (int, error) { if i < 0 { return 0, errors.New("negative") } return i + 1, nil } func Bar(i, j int) (int, error) { i, err := foo(i) if err != nil { return 0, err } j, err = foo(j) if err != nil { return 0, err } return i + j, nil }
于是在测试 Bar 的时候,我们可以替换 foo:
func TestBar(t *testing.T) { f := func(newFoo func(i int) (int, error), cb func()) { old := foo defer func() { foo = old }() foo = newFoo cb() } t.Run("first error", TF(func(must *require.Assertions, tc *TC) { expErr := randTest.Error() f(func(i int) (int, error) { return 0, expErr }, func() { _, err := Bar(1, 2) must.Equal(expErr, err) }) })) t.Run("second error", TF(func(must *require.Assertions, tc *TC) { expErr := randTest.Error() first := true f(func(i int) (int, error) { if first { first = false return 0, nil } return 0, expErr }, func() { _, err := Bar(1, 2) must.Equal(expErr, err) }) })) t.Run("success", TF(func(must *require.Assertions, tc *TC) { f(func(i int) (int, error) { return i, nil }, func() { r, err := Bar(1, 2) must.NoError(err) must.Equal(3, r) }) })) }
上面的写法就可以单独分别测试 foo 和 Bar 了
-
使用了这个方法后可能需要多写比较多的 mock 相关的代码(这个部分可以考虑搭配使用 gomock)
-
这个方法在做并发的测试时候,需要考虑到你 mock 的函数对并发的处理是否正确
-
这个测试总体上正确的必要条件是 foo 函数的测试正确,并且 foo 函数的 mock 也与正确的 foo 函数的行为一致,所以必要时还是需要额外书写不 mock foo 函数的总体测试
测试的覆盖率
写测试的时候,我们经常会提到一个词,覆盖率。那么什么是测试覆盖率呢?
测试覆盖率是在软件测试或是软件工程中的软件度量,表示软件程式中被测试到的比例。覆盖率是一种判断测试严谨程度的方式。有许多不同种类的测试覆盖率: 代码覆盖率 特征覆盖率 情景覆盖率 屏幕项目覆盖率 模组覆盖率 每一种覆盖率都会假设待测系统已有存在形态基准。因此当系统有变化时,测试覆盖率也会随之改变。
一般情况下,我们可以认为,测试覆盖率越高,我们测试覆盖的情况越全面,测试的有效性就越高。
Golang 的测试覆盖率
在 golang 中,我们通过附加-cover 标志,在测试代码的同时,测试其覆盖率
% go test -cover PASS coverage: 100.0% of statements ok code.byted.org/ek/demo_test/t10_coverage 0.008s
我们可以看到当前测试覆盖率为 100%。
100%测试覆盖率不等于正确的测试
测试覆盖率越高不等于测试正确,我们分几种情况分别举例。
并没有正确测试输入输出
这个在上面已经有所提及,可以参考上面“正确测试返回值”的例子,在例子中,测试覆盖率达到了 100%,但是并没有正确测试出代码的问题。
并没有覆盖到所有分支逻辑
func AddIfBothPositive(i, j int) int { if i > 0 && j > 0 { i += j } return i }
下面的测试用例覆盖率达到了 100%,但是并没有测试到所有的分支
func TestAdd(t *testing.T) { res := AddIfBothPositive(1, 2) require.Equal(t, 3, res) }
并没有处理异常/边界条件
func Divide(i, j int) int { return i / j }
Divide 函数并没有处理除数为 0 的情况,而单元测试的覆盖率是 100%
func TestAdd(t *testing.T) { res := Divide(6, 2) require.Equal(t, 3, res) }
上面的例子说明 100%的测试覆盖并不是真的“100%覆盖”了所有的代码运行情况。
覆盖率的统计方法
测试覆盖率的统计方法一般是: 测试中执行到的代码行数 / 测试的代码的总行数 然而代码在实际运行中,每一行运行到的概率、出错的严重程度等等也是不同的,所以我们在追求高覆盖率的同时,不能迷信覆盖率。
测试是不怕重复书写的
这里的重复书写,可以一定程度上认为是“代码复用”的反义词。我们主要从下面的几方面来说。
重复书写类似的测试用例
测试用例只要不是完全一致,那么即便是比较雷同的测试用例,我们都可以认为是有意义的,没有必要为了代码的精简特地删除,例如我们测试上面的 Add 函数
func TestAdd(t *testing.T) { t.Run("fixed", func(t *testing.T) { res := Add(1, 2) require.Equal(t, 3, res) }) t.Run("random", func(t *testing.T) { a := rand.Int() b := rand.Int() res := Add(a, b) require.Equal(t, a+b, res) }) }
虽然第二个测试看起来覆盖了第一个测试,但没有必要去特地删除第一个测试,越多的测试越能增加我们代码的可靠性。
重复书写(源)代码中的定义和逻辑
比如我们有一份代码
package add const Value = 3 func AddInternalValue(a int) int { return a + Value }
测试为
func TestAdd(t *testing.T) { res := AddInternalValue(1) require.Equal(t, 1+Value, res) }
看起来非常完美,但是如果某天内部变量 Value 的值被不小心改动了,那么这个测试无法反应出这个改动,也就无法及时发现这个错误了。如果我们写成
func TestAdd(t *testing.T) { const value = 3 res := AddInternalValue(1) require.Equal(t, 1+value, res) }
就不用担心无法发现常量值的变化了。
本文转载自:字节跳动技术团队(ID:toutiaotechblog)
原文链接:一个测试工程师走进一家酒吧……
本文文字及图片出自 InfoQ
你也许感兴趣的:
- 【外评】电脑从哪里获取时间?
- 【外评】为什么 Stack Overflow 正在消失?
- Android 全力押注 Rust,Linux 却在原地踏步?谷歌:用 Rust 重写固件太简单了!
- 【外评】哪些开源项目被广泛使用,但仅由少数人维护?
- 【外评】好的重构与不好的重构
- C 语言老将从中作梗,Rust for Linux 项目内讧升级!核心维护者愤然离职:不受尊重、热情被消耗光
- 【外评】代码审查反模式
- 我受够了维护 AI 生成的代码
- 【外评】Linux 桌面市场份额升至 4.45
- 【外评】作为全栈开发人员如何跟上 AI/ML 的发展?
你对本文的反应是: