写点什么

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

  • 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:002825

评论 1 条评论

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

java程序员跳槽难吗?掌握这些经验,轻松入职阿里,rabbitmq的消息持久化原理

Java 程序员 后端

Java线程池相关知识点总结,mybatis基础面试题

Java 程序员 后端

Java毕设项目-校园失物招领管理系统,HR的话扎心了

Java 程序员 后端

Java注解-一文就懂,mysql注入攻击原理

Java 程序员 后端

java注解解析,10天拿到字节跳动Java岗位offer

Java 程序员 后端

Java流程控制语句-分支结构(选择结构),java技术专家面试题

Java 程序员 后端

Java程序员经典面试题集大全 (二),最全SpringBoot学习教程

Java 程序员 后端

Java模板工具类实现解析简单的表达式,kafka实战项目

Java 程序员 后端

Java的四大面向对象编程概念,程序员必须要了解的知识点

Java 程序员 后端

Java的新未来:逐渐“Kotlin化,java接口菜鸟教程

Java 程序员 后端

Java程序员被逼迫,挣着卖白菜的钱,操着卖白粉的心,java语言入门自学书

Java 程序员 后端

Java框架总结,mysql教程视频

Java 程序员 后端

Java程序员黄金年龄25-28岁,我们30+的人该去哪儿,linux资料

Java 程序员 后端

Java注解(1),headfirstjavapdf百度云

Java 程序员 后端

Java程序员:终于,在一个艰难而又轻松的工作日之后,java开发工程师常见面试题

Java 程序员 后端

Java笔记 IO —— 序列化和反序列化,面试大厂应该注意哪些问题

Java 程序员 后端

java教程——线程,整合springboot集成实现动态刷新配置

Java 程序员 后端

Java状态模式(State),马哥linux2020全套视频下载

Java 程序员 后端

Java类与类之间的继承关系,java算法面试代写

Java 程序员 后端

JAVA线程池源码之深入状态值分析| Java Debug 笔记,java面试题库百度云链接

Java 程序员 后端

Java虚拟机 —— 类的加载机制,linux操作系统实用教程第二版课后答案

Java 程序员 后端

java教程——泛型,java零基础教学视频

Java 程序员 后端

Java数组的拷贝 优化冒泡排序 二分查找,神策数据java面试题

Java 程序员 后端

Java程序员拿80W年薪很难吗?这套阿里P7的进阶路线让我惭愧了

Java 程序员 后端

Java程序员裸辞两个月,面试阿里、美团,mysql视频教程百度云

Java 程序员 后端

Java线程状态以及 sheep(),开发这么久这些问题都不会

Java 程序员 后端

java整理,springboot2精髓百度云

Java 程序员 后端

Java日志体系(二) log4j 配置文件详解 缓存问题,mybatis基本工作原理

Java 程序员 后端

Java最新高频大厂面试集锦(附答案),看完老板哭着让我留下来

Java 程序员 后端

Java注解,mysql教程入门到精通

Java 程序员 后端

Java的Io模型你了解多少?RPC的通信Netty-Netty的底层是Nio-

Java 程序员 后端

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