单元测试

单元测试介绍

什么是单元测试: 由开发者编写,给出待测试代码的输入和期望输出,验证该段代码的行为和期望一致。

单元测试的意义: 如果对函数代码做了修改,只需要再跑一遍单元测试,通过则说明修改不会对函数原有的行为造成影响,不通过则说明修改后与原有行为不一致,要么修改代码,要么修改测试。

单元测试的好处: 这种以测试为驱动的开发模式可以确保一个模块的行为符合我们设计的测试用例。在将来修改的时候,可以极大程度地保证该模块行为仍然是正确的。个人认为单元测试很好地保证了程序的局部正确性以及前后兼容性,从而减少了整体程序的bug的数量。即使出现问题,也能尽快定位,减少调试的时间。

代码覆盖率

代码覆盖是软件测试中的一种度量,描述程序中源代码被测试的比例和程度,所得比例称为代码覆盖率。
在做单元测试时,代码覆盖率常常作为衡量测试好坏的指标。比如,现在做的某些项目在提交时要求单测代码覆盖率必须达到80%以上。

不同语言的单元测试

python 单元测试

unittest 是 python 自带的单元测试框架,其使用也非常简单。下面通过简单的例子来进行介绍。首先是被测试的程序代码 demo.py 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 
#!/usr/bin/python3
# -*- coding:utf-8 -*-

def add(a,b):
return a+b

def minus(a,b):
return a-b

def multi(a,b):
return a*b

def divide(a,b):
return a/b

在 unittest 中,测试类需继承 unittest.TestCase 类,对应的测试方法必须以 test_开头。另外有四个特殊的方法,setUp、tearDown和setUpClass、tearDownClass。区别在于前面两个方法会在每个测试方法执行前后执行一次,如果有多个测试方法则会执行多次,而后面两个方法在一次测试中只会执行一次。

下面是测试文件代码 demo_test.py 。其中 assertEqual 是相等断言。如果断言失败,则抛出一个AssertionError,并标识该测试为失败状态。如果异常,则当做错误来处理。如果成功,则标识该测试为成功状态。unittest 提供了三种类型的断言:

  • 基本的Boolean断言,如 assertTrue、assertEqual等
  • 比较断言,例如 assertAlmostEqual
  • 其他的复杂断言,如assertListEqual, 可以处理元组、列表、字典等更复杂的数据类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
 
#!/usr/bin/python3
# -*- coding:utf-8 -*-
import unittest
import demo

class TestFunc(unittest.TestCase):
# 如果想在所有case执行之前准备一次测试环境,并在所有case执行结束后清理环境,则使用setUpClass、tearDownClass方法
@classmethod
def setUpClass(cls):
print("this setUpClass() method only called once")

@classmethod
def tearDownClass(cls):
print("this tearDownClass() method only called once too")

# 测试方法均以test_开头,否则是不被unittest识别
def test_add(self):
print("add:")
self.assertEqual(3, demo.add(1,2))

def test_minus(self):
print("minus")
self.assertEqual(3, demo.minus(5,2))

# 如果想临时跳过某个case:skip装饰器
@unittest.skip("i don't want to run this case. ")
def test_multi(self):
print("multi")
self.assertEqual(6, demo.multi(2,3))

def test_divide(self):
print("divide")
self.assertEqual(2, demo.divide(5,2))

if __name__ == "__main__":
# 在main()中加verbosity参数,可以控制输出的错误报告的详细程度
# verbosity=*:默认是1;设为0,则不输出每一个用例的执行结果;2-输出详细的执行结果
unittest.main(verbosity=2)

执行命令 python demo_test.py ,即可得到测试结果输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
this setUpClass() method only called once
test_add (__main__.TestFunc) ... add:
ok
test_divide (__main__.TestFunc) ... divide
ok
test_minus (__main__.TestFunc) ... minus
ok
test_multi (__main__.TestFunc) ... skipped "i don't want to run this case. "
this tearDownClass() method only called once too

----------------------------------------------------------------------
Ran 4 tests in 0.000s

OK (skipped=1)

上面的测试结果没有统计代码覆盖率,要想知道覆盖率,我们还需要安装第三方库 Coverage。使用方法如下:

  • run
    输入 coverage run demo_test.py 执行测试代码,同时统计覆盖率。
    执行后,命令行输出同上,同时会在文件夹下自动生成一个覆盖率统计结果文件(data file): .coverage。

  • report
    有了覆盖率统计结果文件,只需要再运行coverage report,就可以在命令里看到统计的结果。

    1
    2
    3
    4
    5
    6
    Name           Stmts   Miss  Cover
    ----------------------------------
    demo.py 8 1 88%
    demo_test.py 21 2 90%
    ----------------------------------
    TOTAL 29 3 90%

其中 Stmts / Miss 表示语句总数/未执行到的语句数 ,Cover = (Stmts-Miss) / Stmts。

  • html
    如果想看到更直观、更详细的测试结果,可以执行命令 coverage html -d covhtml 生成的测试报告。报告直接关联代码,高亮显示覆盖和未覆盖的代码,支持排序。-d 参数指定 html 文件夹名称。更多关于 Coverage 的用法可以参阅官方文档。

go 单元测试

Go语言自带测试框架,使用很简单。主要遵循两个约定:

  • 测试文件的文件名后缀必须为 _test.go(如demo_test.go),文件名前半部分可随意取,通常为被测文件名。
  • 测试函数的函数名必须以 Test 前缀,函数名后半部分可随意取,但后半部分的第一个字母必须为_或大写字母,同时函数的参数必须为 *testing.T(B)类型的指针。

下面同样通过一个小例子来介绍其用法。首先是被测文件 demo.go。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 
package test

func add(a int, b int) int {
return a + b
}

func minus(a int, b int) int {
return a - b
}

func multi(a int, b int) int {
return a * b
}

func divide(a int, b int) int {
return a / b
}

下面是对应的测试文件 demo_test.go。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
 
package test

import "testing"

func Test_Add(t *testing.T){
a, b := 1, 2
if res := add(a, b); res != 3{
t.Errorf("expect 3, but result is %d", res)
}
}

func Test_Minus(t *testing.T){
a, b := 1, 2
if res := minus(a, b); res != -1{
t.Errorf("expect -1, but result is %d", res)
}
}

func Test_Multi(t *testing.T){
a, b := 1, 2
if res := multi(a, b); res != 2{
t.Errorf("expect 2, but result is %d", res)
}
}

func Test_Divide(t *testing.T){
a, b := 4, 2
if res := divide(a, b); res != 2{
t.Errorf("expect 2, but result is %d", res)
}
}

执行 go test 我们可以直接看到测试的执行结果:
1
2
PASS
ok xxx/xxx/xxx/test 0.001s

如果想查看测试的覆盖率,可以首先执行命令

1
go test -test.coverprofile coverage.cov

生成文件 coverage.cov。再执行命令生成对应的 html 文件即可。

1
go tool cover -html=./coverage.cov -o coverage.html

原生的 go 语言框架可以满足一些基本的测试要求,但其缺点也很明显,难以满足复杂的测试需求。其中最致命的缺点是缺乏断言,导致测试写起来繁琐冗长。但好在有很多第三方测试框架供我们选择。下面是一些 go 语言单测框架的对比。

名称 Star Assert Mock Gen 结果Web化 Web环境构造 随机Input 30天内有commit 学习成本 项目特点
testify 6.8k Assert包使用简便,可以快速上手。Mock对代码有入侵,有一定的使用成本。包之间相互独立,可独立对外提供服务。额外提供suite包
goconvey 4.3k 支持超丰富的断言语句。学习成本低,可以快速上手。
httpexpect 1k 如果Handler可以被引用,可以不启动web server对handler进行测试。支持echo,iris,gae等框架的web模拟。
go-fuzz 2.6k 对代码有少量入侵性,需要编译运行。可以生成大量随机数据,挖掘潜在的bug。命令行操作,提供简单的report。更适合Library的UT。非传统的UT,与UT互补。
gotests 1.8k 自动生成测试用例,可以极快速开始测试用例。
monkey 1k 上手较快,Mock部分函数有一些难度。对于一些带有私有声明的方法无法mock。对代码没有入侵性 。可以Mock方法,过程。
net/http/httptest 官方包

go 单元测试tips

缓存问题

当测试代码中使用 time.Now().Unix() 打印时间戳时,你会发现每次执行 test 命令时打印出的时间都是一样的。原因是 Go1.10开始,会缓存测试结果。这会导致我们明明改了某些参数,但再测试时却得到相同的结果,令人困惑。解决这个问题的办法有三种:
一是带上 -count=1 参数禁用缓存。如 go test -v -count=1 test.go
二是手动清除测试缓存,执行 go clean -testcache
三是设置环境变量。设置 GOCACHE=off 即可禁用缓存。

测试时指定方法名

执行命令 go test -v 依赖文件 -test.run 方法名