文末有彩蛋。
作者:yukkizhang,腾讯 CSIG 专项技术测试工程师
本篇文章站在测试的角度,旨在给行业平台乃至其他团队的开发同学,进行一定程度的单元测试指引,让其能够快速的明确单元测试的方式方法。 本文主要从单元测试出发,对Golang的单元测试框架、Stub/Mock框架进行简单的介绍和选型推荐,列举出几种针对于Mock场景的最佳实践,并以具体代码示例进行说明。
[url] https://www.jianshu.com/p/743...[/url]
[url] https://www.jianshu.com/p/7e3...[/url]
一、单元测试
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 个字符,可使用字母、数字、下划线,需以字母开头。
测试用例:
4.3 边界值分析法
边界值分析法使用与等价类测试方法相同的等价类划分,只是边界值分析假定 错误更多地存在于两个划分的边界上。
边界值测试在软件变得复杂的时候也会变得不实用。边界值测试对于非向量类型的值(如枚举类型的值)也没有意义。
例如,和4.1相同的需求:划分(ii)的边界为 0 和最大正实数;划分(i)的边界为最小负实数和 0。由此得到以下测试用例:
- 输入 {最小负实数}
- 输入 {绝对值很小的负数}
- 输入 0
- 输入 {绝对值很小的正数}
- 输入 {最大正实数}
4.4 基本路径测试法
基本路径测试法是在程序控制流图的基础上,通过分析控制构造的环路复杂性,导出基本可执行路径集合,从而设计测试用例的方法。设计出的测试用例要保证在测试中程序的每个可执行语句至少执行一次。
基本路径测试法的基本步骤:
- 程序的控制流图:描述程序控制流的一种图示方法。
- 程序圈复杂度:McCabe 复杂性度量。从程序的环路复杂性可导出程序基本路径集合中的独立路径条数,这是确定程序中每个可执行语句至少执行一次所必须的测试用例数目的上界。
- 导出测试用例:根据圈复杂度和程序结构设计用例数据输入和预期结果。
- 准备测试用例:确保基本路径集中的每一条路径的执行。
二、Golang 的测试框架
Golang 有这几种比较常见的测试框架:
从测试用例编写的简易难度上来说: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 第一个例子
被测代码:
1. func Fib(n int) int {
2. if n < 2 {
3. return n
4. }
5. return Fib(n-1) + Fib(n-2)
6. }
测试代码:
1. func TestFib(t *testing.T) {
2. var (
3. in = 7
4. expected = 13
5. )
6. actual := Fib(in)
7. if actual != expected {
8. t.Errorf("Fib(%d) = %d; expected %d", in, actual, expected)
9. }
10. }
执行 go test .
,输出:
1. $ go test .
2. ok chapter09/testing 0.007s
表示测试通过。我们将 Sum
函数改为:
1. func Fib(n int) int {
2. if n < 2 {
3. return n
4. }
5. return Fib(n-1) + Fib(n-1)
6. }
再执行 go test .
,输出:
1. $ go test .
2. --- FAIL: TestSum (0.00s)
3. t_test.go:16: Fib(10) = 64; expected 13
4. FAIL
5. FAIL chapter09/testing 0.009s
1.2 Table-Driven 测试
Table-Driven 的方式将多个 case 在同一个测试函数中测到:
1. func TestFib(t *testing.T) {
2. var fibTests = []struct {
3. in int // input
4. expected int // expected result
5. }{
6. {1, 1},
7. {2, 1},
8. {3, 2},
9. {4, 3},
10. {5, 5},
11. {6, 8},
12. {7, 13},
13. }
15. for _, tt := range fibTests {
16. actual := Fib(tt.in)
17. if actual != tt.expected {
18. t.Errorf("Fib(%d) = %d; expected %d", tt.in, actual, tt.expected)
19. }
20. }
21. }
2. GoConvey:简单断言
Convey 适用于书写单元测试用例,并且可以兼容到 testing 框架中,go test
命令或者使用goconvey
命令访问localhost:8080
的 Web 测试界面都可以查看测试结果。
1. Convey("Convey return : ", t, func() {
2. So(...)
3. })
一般 Convey 用So
来进行断言,断言的方式可以传入一个函数,或者使用自带的ShouldBeNil
、ShouldEqual
、ShouldNotBeNil
函数等。
2.1. 基本用法
被测代码:
1. func StringSliceEqual(a, b []string) bool {
2. if len(a) != len(b) {
3. return false
4. }
6. if (a == nil) != (b == nil) {
7. return false
8. }
10. for i, v := range a {
11. if v != b[i] {
12. return false
13. }
14. }
15. return true
16. }
测试代码
1. import (
2. "testing"
3. . "github.com/smartystreets/goconvey/convey"
4. )
6. func TestStringSliceEqual(t *testing.T) {
7. Convey("TestStringSliceEqual的描述", t, func() {
8. a := []string{"hello", "goconvey"}
9. b := []string{"hello", "goconvey"}
10. So(StringSliceEqual(a, b), ShouldBeTrue)
11. })
12. }
2.2. 双层嵌套
1. import (
2. "testing"
3. . "github.com/smartystreets/goconvey/convey"
4. )
6. func TestStringSliceEqual(t *testing.T) {
7. Convey("TestStringSliceEqual", t, func() {
8. Convey("true when a != nil && b != nil", func() {
9. a := []string{"hello", "goconvey"}
10. b := []string{"hello", "goconvey"}
11. So(StringSliceEqual(a, b), ShouldBeTrue)
12. })
14. Convey("true when a == nil && b == nil", func() {
15. So(StringSliceEqual(nil, nil), ShouldBeTrue)
16. })
17. })
18. }
内层的 Convey 不需要再传入 t *testing.T 参数
3. testify
testify 提供了 assert 和 require,让你可以简洁地写出if xxx { t.Fail() }
3.1. assert
1. func TestSomething(t *testing.T) {
3. //断言相等
4. assert.Equal(t, 123, 123, "they should be equal")
6. //断言不相等
7. assert.NotEqual(t, 123, 456, "they should not be equal")
9. //对于nil的断言
10. assert.Nil(t, object)
12. //对于非nil的断言
13. if assert.NotNil(t, object) {
14. // now we know that object isn't nil, we are safe to make
15. // further assertions without causing any errors
16. assert.Equal(t, "Something", object.Value)
17. }
3.2. require
require 和 assert 失败、成功条件完全一致,区别在于 assert 只是返回布尔值(true、false),而 require 不符合断言时,会中断当前运行
3.3. 常用的函数
1. func Equal(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
2. func NotEqual(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
4. func Nil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
5. func NotNil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
7. func Empty(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
8. func NotEmpty(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
10. func NoError(t TestingT, err error, msgAndArgs ...interface{}) bool
11. func Error(t TestingT, err error, msgAndArgs ...interface{}) bool
13. func Zero(t TestingT, i interface{}, msgAndArgs ...interface{}) bool
14. func NotZero(t TestingT, i interface{}, msgAndArgs ...interface{}) bool
16. func True(t TestingT, value bool, msgAndArgs ...interface{}) bool
17. func False(t TestingT, value bool, msgAndArgs ...interface{}) bool
19. func Len(t TestingT, object interface{}, length int, msgAndArgs ...interface{}) bool
21. func NotContains(t TestingT, s, contains interface{}, msgAndArgs ...interface{}) bool
22. func NotContains(t TestingT, s, contains interface{}, msgAndArgs ...interface{}) bool
23. func Subset(t TestingT, list, subset interface{}, msgAndArgs ...interface{}) (ok bool)
24. func NotSubset(t TestingT, list, subset interface{}, msgAndArgs ...interface{}) (ok bool)
26. func FileExists(t TestingT, path string, msgAndArgs ...interface{}) bool
27. func DirExists(t TestingT, path string, msgAndArgs ...interface{}) bool
三、Stub/Mock 框架
Golang 有以下 Stub/Mock 框架:
- GoStub
- GoMock
- Monkey
一般来说,GoConvey 可以和 GoStub、GoMock、Monkey 中的一个或多个搭配使用。
Testify 本身有自己的 Mock 框架,可以用自己的也可以和这里列出来的 Stub/Mock 框架搭配使用。
1. GoStub
GoStub 框架的使用场景很多,依次为:
- 基本场景:为一个全局变量打桩
- 基本场景:为一个函数打桩
- 基本场景:为一个过程打桩
- 复合场景:由任意相同或不同的基本场景组合而成
1.1. 为一个全局变量打桩
假设 num 为被测函数中使用的一个全局整型变量,当前测试用例中假定 num 的值大于 100,比如为 150,则打桩的代码如下:
1. stubs := Stub(&num, 150)
2. defer stubs.Reset()
stubs 是 GoStub 框架的函数接口 Stub 返回的对象,该对象有 Reset 操作,即将全局变量的值恢复为原值。
1.2. 为一个函数打桩
假设我们产品的既有代码中有下面的函数定义:
1. func Exec(cmd string, args ...string) (string, error) {
2. ...
3. }
我们可以对 Exec 函数打桩,代码如下所示:
1. stubs := StubFunc(&Exec,"xxx-vethName100-yyy", nil)
2. defer stubs.Reset()
1.3. 为一个过程打桩
当一个函数没有返回值时,该函数我们一般称为过程。很多时候,我们将资源清理类函数定义为过程。
我们对过程 DestroyResource 的打桩代码为:
1. stubs := StubFunc(&DestroyResource)
2. defer stubs.Reset()
GoStub 的更多用法以及 GoStub+GoConvey 的组合使用方法
2. GoMock
GoMock 是由 Golang 官方开发维护的测试框架,实现了较为完整的基于 interface 的 Mock 功能,能够与 Golang 内置的 testing 包良好集成,也能用于其它的测试环境中。GoMock 测试框架包含了 GoMock 包和 mockgen 工具两部分,其中 GoMock 包完成对桩对象生命周期的管理,mockgen 工具用来生成 interface 对应的 Mock 类源文件。
2.1. 定义一个接口
我们先定义一个打算 mock 的接口 Repository。
Repository 是领域驱动设计中战术设计的一个元素,用来存储领域对象,一般将对象持久化在数据库中,比如 Aerospike,Redis 或 Etcd 等。对于领域层来说,只知道对象在 Repository 中维护,并不 care 对象到底在哪持久化,这是基础设施层的职责。微服务在启动时,根据部署参数实例化 Repository 接口,比如 AerospikeRepository,RedisRepository 或 EtcdRepository。
1. package db
3. type Repository interface {
4. Create(key string, value []byte) error
5. Retrieve(key string) ([]byte, error)
6. Update(key string, value []byte) error
7. Delete(key string) error
8.