文末有彩蛋。
作者:yukkizhang,腾讯 CSIG 专项技术测试工程师
本篇文章站在测试的角度,旨在给行业平台乃至其他团队的开发同学,进行一定程度的单元测试指引,让其能够快速的明确单元测试的方式方法。 本文主要从单元测试出发,对Golang的单元测试框架、Stub/Mock框架进行简单的介绍和选型推荐,列举出几种针对于Mock场景的最佳实践,并以具体代码示例进行说明。
一、单元测试
1. 单元测试是什么
单元是应用的最小可测试部件。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类、超类、抽象类等中的方法。单元测试就是软件开发中对最小单位进行正确性检验的测试工作。
不同地方对单元测试有的定义可能会有所不同,但有一些基本共识:
单元测试是比较底层的,关注代码的局部而不是整体。
单元测试是开发人员在写代码时候写的。
单元测试需要比其他测试运行得快。
2. 单元测试的意义
提高代码质量。代码测试都是为了帮助开发人员发现问题从而解决问题,提高代码质量。
尽早发现问题。问题越早发现,解决的难度和成本就越低。
保证重构正确性。随着功能的增加,重构(修改老代码)几乎是无法避免的。很多时候我们不敢重构的原因,就是担心其它模块因为依赖它而不工作。有了单元测试,只要在改完代码后运行一下单测就知道改动对整个系统的影响了,从而可以让我们放心的重构代码。
简化调试过程。单元测试让我们可以轻松地知道是哪一部分代码出了问题。
简化集成过程。由于各个单元已经被测试,在集成过程中进行的后续测试会更加容易。
优化代码设计。编写测试用例会迫使开发人员仔细思考代码的设计和必须完成的工作,有利于开发人员加深对代码功能的理解,从而形成更合理的设计和结构。
单元测试是最好的文档。单元测试覆盖了接口的所有使用方法,是最好的示例代码。而真正的文档包括注释很有可能和代码不同步,并且看不懂。
3. 单元测试用例编写的原则
3.1 理论原则
快。单元测试是回归测试,可以在开发过程的任何时候运行,因此运行速度必须快
一致性。代码没有改变的情况下,每次运行得结果应该保持确定且一致
原子性。结果只有两种情况:Pass / Fail
用例独立。执行顺序不影响;用例间没有状态共享或者依赖关系;用例没有副作用(执行前后环境状态一致)
单一职责。一个用例只负责一个场景
隔离。功能可能依赖于数据库、web 访问、环境变量、系统时间等;一个单元可能依赖于另一部分代码,用例应该解除这些依赖
可读性。用例的名称、变量名等应该具有可读性,直接表现出该测试的目标
自动化。单元测试需要全自动执行。测试程序不应该有用户输入;测试结果应该能直接被电脑获取,不应该由人来判断。
3.2 规约原则
在实际编写代码过程中,不同的团队会有不同团队的风格,只要团队内部保持有一定的规约即可,比如:
单元测试文件名必须以 xxx_test.go 命名
方法必须是 TestXxx 开头,建议风格保持一致(驼峰或者下划线)
方法参数必须 t *testing.T
测试文件和被测试文件必须在一个包中
3.3 衡量原则
单元测试是要写额外的代码的,这对开发同学的也是一个不小的工作负担,在一些项目中,我们合理的评估单元测试的编写,我认为我们不能走极端,当然理论上来说全写肯定时好的,但是从成本,效率上来说我们必须做出权衡,衡量原则如下:
优先编写核心组件和逻辑模块的测试用例
逻辑类似的组件如果存在多个,优先编写其中一种逻辑组件的测试用例
发现 Bug 时一定先编写测试用例进行 Debug
关键 util 工具类要编写测试用例,这些 util 工具适用的很频繁,所以这个原则也叫做热点原则,和第 1 点相呼应。
测试用户应该独立,一个文件对应一个,而且不同的测试用例之间不要互相依赖。
测试用例的保持更新
4. 单元测试用例设计方法
4.1 规范(规格)导出法
规范(规格)导出法将需求”翻译“成测试用例。
例如,一个函数的设计需求如下:
函数:一个计算平方根的函数 输入:实数 输出:实数 要求:当输入一个 0 或者比 0 大的实数时,返回其正的平方根;当输入一个小于 0 的实数时,显示错误信息“平方根非法—输入之小于 0”,并返回 0;库函数
printf()
可以用来输出错误信息。
在这个规范中有 3 个陈述,可以用两个测试用例来对应:
测试用例 1:输入 4,输出 2。
测试用例 2:输入-1,输出 0。
4.2 等价类划分法
等价类划分法假定某一特定的等价类中的所有值对于测试目的来说是等价的,所以在每个等价类中找一个之作为测试用例。
按照 [输入条件][有效等价类][无效等价类] 建立等价类表,列出所有划分出的等价类
为每一个等价类规定一个唯一的编号
设计一个新的测试用例,使其尽可能多地覆盖尚未被覆盖地有效等价类。重复这一步,直到所有的有效等价类都被覆盖为止
设计一个新的测试用例,使其仅覆盖一个尚未被覆盖的无效等价类。重复这一步,直到所有的无效等价类都被覆盖为止
例如,注册邮箱时要求用 6~18 个字符,可使用字母、数字、下划线,需以字母开头。
![](https://i-blog.csdnimg.cn/blog_migrate/e11cfb8e2593321c90251bfb9baef313.png)
测试用例:
![](https://i-blog.csdnimg.cn/blog_migrate/6cb01deb2daaad8982a0d243e2f81862.png)
4.3 边界值分析法
边界值分析法使用与等价类测试方法相同的等价类划分,只是边界值分析假定 错误更多地存在于两个划分的边界上。
边界值测试在软件变得复杂的时候也会变得不实用。边界值测试对于非向量类型的值(如枚举类型的值)也没有意义。
例如,和4.1相同的需求:划分(ii)的边界为 0 和最大正实数;划分(i)的边界为最小负实数和 0。由此得到以下测试用例:
输入 {最小负实数}
输入 {绝对值很小的负数}
输入 0
输入 {绝对值很小的正数}
输入 {最大正实数}
4.4 基本路径测试法
基本路径测试法是在程序控制流图的基础上,通过分析控制构造的环路复杂性,导出基本可执行路径集合,从而设计测试用例的方法。设计出的测试用例要保证在测试中程序的每个可执行语句至少执行一次。
基本路径测试法的基本步骤:
程序的控制流图:描述程序控制流的一种图示方法。
程序圈复杂度:McCabe 复杂性度量。从程序的环路复杂性可导出程序基本路径集合中的独立路径条数,这是确定程序中每个可执行语句至少执行一次所必须的测试用例数目的上界。
导出测试用例:根据圈复杂度和程序结构设计用例数据输入和预期结果。
准备测试用例:确保基本路径集中的每一条路径的执行。
二、Golang 的测试框架
Golang 有这几种比较常见的测试框架:
![](https://i-blog.csdnimg.cn/blog_migrate/c193764d71fbea0428d6964f11494cb8.png)
从测试用例编写的简易难度上来说:testify 比 GoConvey 简单;GoConvey 比 Go 自带的 testing 包简单。 但在测试框架的选择上,我们更推荐 GoConvey。因为:
GoConvey 和其他 Stub/Mock 框架的兼容性相比 Testify 更好。
Testify 自带 Mock 框架,但是用这个框架 Mock 类需要自己写。像这样重复有规律的部分在 GoMock 中是一键自动生成的。
1. Go 自带的 testing 包
testing
为 Go 语言 package 提供自动化测试的支持。通过 go test
命令,能够自动执行如下形式的任何函数:
func TestXxx(*testing.T)
注意:Xxx
可以是任何字母数字字符串,但是第一个字母不能是小写字母。
在这些函数中,使用 Error
、Fail
或相关方法来发出失败信号。
要编写一个新的测试套件,需要创建一个名称以 _test.go 结尾的文件,该文件包含 TestXxx
函数,如上所述。将该文件放在与被测试文件相同的包中。该文件将被排除在正常的程序包之外,但在运行 go test
命令时将被包含。有关详细信息,请运行 go help test
和 go help testflag
了解。
1.1 第一个例子
被测代码:
func Fib(n int) int {
if n < 2 {
return n
}
return Fib(n-1) + Fib(n-2)
}
测试代码:
func TestFib(t *testing.T) {
var (
in = 7
expected = 13
)
actual := Fib(in)
if actual != expected {
t.Errorf("Fib(%d) = %d; expected %d", in, actual, expected)
}
}
执行 go test .
,输出:
$ go test .
ok chapter09/testing 0.007s
表示测试通过。我们将 Sum
函数改为:
func Fib(n int) int {
if n < 2 {
return n
}
return Fib(n-1) + Fib(n-1)
}
再执行 go test .
,输出:
$ go test .
--- FAIL: TestSum (0.00s)
t_test.go:16: Fib(10) = 64; expected 13
FAIL
FAIL chapter09/testing 0.009s
1.2 Table-Driven 测试
Table-Driven 的方式将多个 case 在同一个测试函数中测到:
func TestFib(t *testing.T) {
var fibTests = []struct {
in int // input
expected int // expected result
}{
{1, 1},
{2, 1},
{3, 2},
{4, 3},
{5, 5},
{6, 8},
{7, 13},
}
for _, tt := range fibTests {
actual := Fib(tt.in)
if actual != tt.expected {
t.Errorf("Fib(%d) = %d; expected %d", tt.in, actual, tt.expected)
}
}
}