AI 年度盘点与2025发展趋势展望,50+案例解析亮相AICon 了解详情
写点什么

一个测试工程师走进一家酒吧……

  • 2021-06-25
  • 本文字数:13253 字

    阅读完需:约 43 分钟

一个测试工程师走进一家酒吧……

一个测试工程师走进一家酒吧,要了一杯啤酒;

一个测试工程师走进一家酒吧,要了一杯咖啡;

一个测试工程师走进一家酒吧,要了 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)正确的必要条件。。。



测试的种类

测试的种类有非常多,我们这里只挑几个对一般开发者来说比较重要的测试,做简略的说明。

白盒测试、黑盒测试

首先是从测试方法上可以分为白盒测试和黑盒测试(当然还存在所谓的灰盒测试,这里不讨论)

  • 白盒测试 (White-box testing):白盒测试又称透明盒测试、结构测试等,软件测试的主要方法之一,也称结构测试、逻辑驱动测试或基于程序本身的测试。测试应用程序的内部结构或运作,而不是测试应用程序的功能。在白盒测试时,以编程语言的角度来设计测试案例。测试者输入数据验证数据流在程序中的流动路径,并确定适当的输出,类似测试电路中的节点。

  • 黑盒测试 (Black-box testing):黑盒测试,软件测试的主要方法之一,也可以称为功能测试、数据驱动测试或基于规格说明的测试。测试者不了解程序的内部情况,不需具备应用程序的代码、内部结构和编程语言的专门知识。只知道程序的输入、输出和系统的功能,这是从用户的角度针对软件界面、功能及外部结构进行测试,而不考虑程序内部逻辑结构。


我们写的单元测试一般属于白盒测试,因为我们对测试对象的内部逻辑有着充分了解。

单元测试、集成测试

从测试的维度上,又可以分为单元测试和集成测试:

  • 在计算机编程中,单元测试又称为模块测试,是针对程序模块来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类、抽象类、或者派生类中的方法。

  • 整合测试又称组装测试,即对程序模块采用一次性或增值方式组装起来,对系统的接口进行正确性检验的测试工作。整合测试一般在单元测试之后、系统测试之前进行。实践表明,有时模块虽然可以单独工作,但是并不能保证组装起来也可以同时工作。


单元测试可以是黑盒测试,集成测试亦可以是白盒测试

回归测试

  • 回归测试是软件测试的一种,旨在检验软件原有功能在修改后是否保持完整。


回归测试主要是希望维持软件的不变性,我们举一个例子来说明。例如我们发现软件在运行的过程中出现了问题,在 gitlab 上开启了一个 issue。之后我们并且定位到了问题,我们可以先写一个测试(测试的名称可以带上 issue 的 ID)来复现问题(该版本代码运行此测试结果失败)。之后我们修复问题后,再次运行测试,测试的结果应当成功。那么我们之后每次运行测试的时候,通过运行这个测试,可以保证同样的问题不会复现。

一个基本的测试

我们先来看一个 Golang 的代码:


// add.gopackage add
func Add(a, b int) int {   return a + b}
复制代码

一个测试用例可以写成:

// add_test.gopackage add
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
复制代码

这个时候 go 会执行该目录下所有的以_test.go 为后缀中的测试,测试成功的话会有如下输出:

% go testPASSok      code.byted.org/ek/demo_test/t01_basic/correct       0.015s
复制代码

假设这个时候我们把 Add 函数修改成错误的实现

 // add.gopackage add
func Add(a, b int) int {   return a - b}
复制代码

再次执行测试命令

% go test--- FAIL: TestAddWrong (0.00s)    add_test.go:11: the result is -1 instead of 3FAILexit status 1FAIL    code.byted.org/ek/demo_test/t01_basic/wrong 0.006s
复制代码

会发现测试失败。

只执行一个测试文件

那么如果我们想只测试这一个文件,输入

go test add_test.go
复制代码

会发现命令行输出

% go test add_test.go# command-line-arguments [command-line-arguments.test]./add_test.go:9:9: undefined: AddFAIL    command-line-arguments [build failed]FAIL
复制代码

这是因为我们没有附带测试对象的代码,修改测试后可以获得正确的输出:

% go test add_test.go add.gook      command-line-arguments  0.007s
复制代码

测试的几种书写方式

子测试

通常来说我们测试某个函数和方法,可能需要测试很多不同的 case 或者边际条件,例如我们为上面的 Add 函数写两个测试,可以写成:

 // add_test.gopackage add
import (   "testing")
func TestAdd(t *testing.T) {   res := Add(1, 0)   if res != 1 {      t.Errorf("the result is %d instead of 1", res)   }}
func TestAdd2(t *testing.T) {   res := Add(0, 1)   if res != 1 {      t.Errorf("the result is %d instead of 1", res)   }}
复制代码

测试的结果:(使用-v 可以获得更多输出)

% go test -v=== RUN   TestAdd--- PASS: TestAdd (0.00s)=== RUN   TestAdd2--- PASS: TestAdd2 (0.00s)PASSok      code.byted.org/ek/demo_test/t02_subtest/non_subtest     0.007s
复制代码

另一种写法是写成子测试的形式

// add_test.gopackage add
import (   "testing")
func TestAdd(t *testing.T) {   t.Run("test1", func(t *testing.T) {      res := Add(1, 0)      if res != 1 {         t.Errorf("the result is %d instead of 1", res)      }   })   t.Run("", func(t *testing.T) {      res := Add(0, 1)      if res != 1 {         t.Errorf("the result is %d instead of 1", res)      }   })}
复制代码

执行结果:

% go test -v=== RUN   TestAdd=== RUN   TestAdd/test1=== RUN   TestAdd/#00--- PASS: TestAdd (0.00s)    --- PASS: TestAdd/test1 (0.00s)    --- PASS: TestAdd/#00 (0.00s)PASSok      code.byted.org/ek/demo_test/t02_subtest/subtest 0.007s
复制代码

可以看到输出中会将测试按照嵌套的结构分类,子测试的嵌套没有层数限制,如果不写测试名的话,会自动按照顺序给予序号作为其测试名(例如上面的 #00)

对 IDE(Goland)友好的子测试

有一种测试的写法是:

tcList := map[string][]int{   "t1": {1, 2, 3},   "t2": {4, 5, 9},}for name, tc := range tcList {   t.Run(name, func(t *testing.T) {      require.Equal(t, tc[2], Add(tc[0], tc[1]))   })}
复制代码

看上去没什么问题,然而有一个缺点是,这个测试对 IDE 并不友好:

我们无法在出错的时候对单个测试重新执行 所以推荐尽可能对每个 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.gopackage 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: AddFAIL    code.byted.org/ek/demo_test/t03_diffpkg [build failed]
复制代码

由于包名变化了,我们无法再访问到 Add 函数,这个时候我们增加 import 即可:

 // add_test.gopackage 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.gopackage 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 testok      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:           TestAddFAILFAIL    code.byted.org/ek/demo_test/t04_libraries/testify/wrong 0.009sFAIL
复制代码

库提供了格式化的错误详情(堆栈、错误值、期望值等)来方便我们调试。

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 RACERead 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 -coverPASScoverage: 100.0% of statementsok      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)

原文链接:一个测试工程师走进一家酒吧……

2021-06-25 13:002802

评论 1 条评论

发布
用户头像
哈哈哈,算你狠。
2021-07-05 09:49
回复
没有更多了
发现更多内容

SAP R/3系统的R和3分别代表什么含义,负载均衡的实现原理

汪子熙

负载均衡 abap 1月月更

996统计

你?

【Spring专场】「AOP容器」不看源码就带你认识核心流程以及运作原理

洛神灬殇

spring spring 源码 1月月更 Spring原理

从2021分布式数据库开发者大会里,我们找出了这8个关键词

OceanBase 数据库

OceanBase 开源 OceanBase 社区版

架构实战营第 4 期 -- 模块六作业

烈火干柴烛灭田边残月

架构实战营

《亚马逊编年史》阅读散记

boshi

读书笔记

技术走向管理第一站 技术经理

张老蔫

28天写作

探索CPU的调度原理

元闰子

操作系统 CPU调度

实现DCI架构

元闰子

DDD DCI架构

架构训练营 -- 模块六

LJK

#架构训练营

模块五

撿破爛ぃ

「架构实战营」

架构实战营模块四作业

zhongwy

「架构实战营」

云原生学习总结

好吃不贵

模块四-考试试卷存储方案

Only

架构师实战营 「架构实战营」

09 Prometheus之可靠性及可扩展性

穿过生命散发芬芳

Prometheus 1月月更

每个人都要会的复盘知识

石云升

复盘 迭代管理 1月月更

一次无脑接口测试导致的无效排查的经历

liuzhen007

1月月更

可用性和易用性双重飞跃 | OceanBase社区版3.1.2正式发布

OceanBase 数据库

数据库 OceanBase 社区版 开发者大会

数据产品经理实战-团队搭建

第519区

团队建设 数据产品经理 数据产品 1月月更

深入解读aws graviton3

三少

云计算架构师

这是一个操作系统,但既不是Windows也不是Linux

LAXCUS分布式操作系统

云计算 分布式操作系统 计算机集群 分布式应用 行列混合存储

一图回顾 2021分布式数据库开发者大会精彩看点

OceanBase 数据库

数据库 OceanBase 社区版 开发者大会

设计模式【10】-- 顺便看看享元模式

秦怀杂货店

Java 设计模式 享元模式

在一年一度高交会,看城市发展的“AI三部曲”

脑极体

模块四作业

cqyanbo

什么是 Github 的元数据以及如何备份 github 上的数据

汪子熙

GitHub 1月月更

从零打造Instagram

俞凡

架构 Instagram 大厂实践

教程直播第6期 | OceanBase 如何进行 Benchmark 测试及调优

OceanBase 数据库

数据库 直播 OceanBase 社区版

2021的科技卦象·坤·5G沃土上万物生长

脑极体

探索OS的内存管理原理

元闰子

操作系统 内存管理

一个cpp协程库的前世今生(十四)信号量与条件变量

SkyFire

c++ cocpp

一个测试工程师走进一家酒吧……_语言 & 开发_字节跳动技术团队_InfoQ精选文章