什么是单元测试
什么叫单元测试呢?可以拆分成两个词“单元”和“测试”,“单元”通常我们可以理解为最小独立模块,在编程中可以是我们代码中的一个函数、一个类或是一个模块;“测试”就很好理解了,就是对一个函数、类或模块做正确性验证。注意单元测试都是针对系统内部的测试,它不会跨系统。单元测试提现在测试对象小,测试执行时间短,测试结果稳定。
单元测试应该满足以下几个要求
- 单元测试中不应该有远程调用。比如说我在单元测试中发起网络调用去互联网获取一个资源然后执行后续测试。如果你在你的单元测试中写了这样的逻辑,你可能在本地执行OK的,但是提交代码跑流水线时,跑流水线的机器可能根本不能访问外网,那么你的单元测试将失败。即使可以访问外网,那么这个过程也会很慢,违背了我们单元测试的初衷,单元测试应该是快速执行的。你可能会说那我代码逻辑要查询数据库等应用怎么办?放心你能想到的所有依赖,前辈们早就有解决方案,我们将在后文中讲解如何编写这类单元测试
- 不应该依赖本地环境。比如,你不能在单元测试中读取本地机器的环境变量,你的单元测试应该要保证在任何机器上都可以成功执行
单元测试必要性
在开篇中我们讨论过单元测试的必要性,我在这里还是想再说一次。我敢肯定市面上绝大部分的小公司以及某些大公司都没有编写单元测试的习惯,特别是一些相对比较传统的行业。互联网公司在单元测试编写上相对来说要好一些,因为大家可能都喜欢以google、facebook等知名互联网公司为榜样,乐意学习他们的工程实践方法。
单元测试可以有效减小研发成本
从图中可以看出,一个问题测试发现阶段越晚,那么它的反馈周期就越长,且修复它的成本就会越高,这条曲线近乎是指数函数。
这个是很容易理解,比如一个问题如果单元测试中暴露出,那么修复它只需要开发人员随手就可以修改,因为这个阶段还是停留在开发阶段;如果一个问题是由测试人员发现,不仅增加了测试人员的成本,还会增加后续测试人员与开发人员沟通确认的成本;如果是由最终用户发现问题,那么这就是一个线上故障,它的反馈周期更长,修复它之后需要走发布流程,如果是终端APP,那么修复它的成本将更高,因为如果这个问题是致命的,你可能需要用户终端APP强制升级,非常影响用户体验,可能导致用户流失。
单元测试可以保证代码重构正确性
为什么很多团队代码的“屎山”越来越大,导致团队技术债越来越多,究其原因就是团队只管开发新代码,而不重构历史代码,今天你加一个if,明天我加一个else if,长此以往,代码变得越来越难维护。
我自己也经历过,我曾经在一些项目开发中,看见别人的函数大致可以实现我的功能,但是我需要改一些地方,但是我不敢动别人的代码,除非我百分百确定那样改了没问题,不然可能就要出问题。是什么导致我们不敢轻易动别人代码呢?最大的阻碍就是可能我改了别人的一段代码,无法保证调用了这个函数的其他业务逻辑不会出问题,为了保证不出问题,你可能需要去将调用这个函数的所有业务逻辑都看一遍,这个成本可能已经远远高于你重新编写一个新函数。
最终你可能默默的把这个函数复制粘贴一遍,然后方法名加上v2以满足你的功能需求,长此以往我们就可能在代码中看见很多功能类似的代码,团队的其他开发者可能也很难搞清楚它们之间的关系,这个时候技术债就产生了,如果不解决这些问题,后续的维护将变得越来越困难。当然还有其他很多的问题会导致技术债产生。
一个很好的解决办法就是单元测试+重构,如果项目一开始代码就编写了完善的单元测试,那么后续的开发者可以在适当的时候重构它,重构完后只需要执行单元测试就知道自己的重构是否影响了整个业务逻辑,这样就可以持续让代码保持在一种健康的状态。
如何编写单元测试
接下来将简单介绍如何在日常开发中编写单元测试,单元测试的思想和编成语言本身没有关系,这里将采用Golang来作为示例
小试牛刀
先来一个最简单的让大家认识一下单元测试,以及编写一个单元测试需要那些步骤,方法很简单就是求两个人年龄之和
func sumAge(x, y int) (int, error) {
if x < 0 || y < 0 || x > 150 || y > 150 {
return 0, errors.New("age must greater than zero")
}
return x + y, nil
}
下边是它的单元测试,注意在golang中单元测试必须以Test开头。通常命名的方式是Test跟着被测试方法名(驼峰命名),比如上边方法的单元测试命名就应该是TestSumAge
func TestSumAge (t *testing.T) {
type Args struct {
X int
Y int
}
// 定义并初始化测试用例
tests := []struct {
name string
args Args
want int
err bool
}{
{
name: "success case 1",
args: Args {
X: 1,
Y: 1,
},
want: 3,
err: false,
},{
name: "age less than 0",
args: Args {
X: -1,
Y: -1,
},
want: 0,
err: true,
}, {
name: "age bigger than 150",
args: Args {
X: 151,
Y: 151,
},
want: 0,
err: true,
},
}
// 遍历用例
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 执行待测方法
res, err := sumAge(tt.args.X, tt.args.Y)
// 断言
if ((err != nil && !tt.err) || (err == nil && tt.err)) {
t.Errorf("got %v want err %v", err, tt.err)
}
if res != tt.want {
t.Errorf("got %v want %v", res, tt.want)
}
})
}
}
很简单,通常就是三步
- 定义单元测试的测试用例,这里非常建议用数据驱动模式来编写单元测试,这样测试的逻辑代码是一样的,不一样的是每次执行的用例。就像上边的代码,我们定义了一个struct数组来存放单测用例,一个元素就是一个用例,最好用测试的场景作用name,这样单元测试执行出错时可以一目了然是哪个用例出错。这一步可以说是最重要的,因为设计用例也不是那么容易的,上边的代码只是为了让大家能直观的感受一下单元测试,所以逻辑非常简单。在文章的后边会讲如何设计用例
- 执行待测方法,很简单就是调用要测试的方法把参数传进去,接收返回结果
- 断言,判断结果是否符合预期
测试模块含第三方依赖模块
需要Mock工具的场景
在真实的业务开发中,并不是所有的方法都能像上边的方法不依赖其他第三方模块。可以看看以下常见的场景
- 访问外部第三方应用,比如HTTP、RPC调用或者各类数据库、MQ等
- 依赖模块还没有开发完成,现在比较流行的DDD架构设计模式的开发下场景下,我们一般最开始编写的是核心领域层逻辑,而对于数据库存储层实现并不关心
- 调用的内部函数复杂,构造数据很复杂
那么对于这种场景是不是我们就没有办法编写测试代码呢?当然不是,Mock工具就是用来应对这种场景。
Mock工具安装
go install github.com/golang/mock/mockgen@v1.6.0
安装没有报错的话就是成功。如果执行mockgen提示Command ‘mockgen’ not found,那么检查一下你的GOPATH是不是没有加入环境变量PATH里。如果想了解更多gomock用法,可访问gomock
Mock例子
接下来我们就看一个依赖模块未实现的例子。这里我们用一个数据库转账的例子,很简单,传入两个银行账户ID和转账金额,然后我们判断转账金额是否合法,然后查询两个账户并进行相应的增减余额,最后保存结果然后返回源账户余额。只是为了演示如何使用Mock,所以没有处理事务。
type Account struct {
ID int64
Name string
Balance int64
}
type AccountRepo interface {
Query(id int64) (Account, error)
Save(account Account) error
}
type Bank struct {
accountRepo AccountRepo
}
func NewBank(accountRepo AccountRepo) *Bank {
return &Bank{
accountRepo: accountRepo,
}
}
func (b *Bank) Transfer(sourceID, targetID, transferAmt int64) (int64, error) {
if transferAmt < 0 {
return 0, errors.New("transferAmt must greater than 0")
}
source, err := b.accountRepo.Query(sourceID)
if err != nil {
return 0, err
}
if source.Balance-transferAmt < 0 {
return 0, errors.New("balance not enough")
}
target, err := b.accountRepo.Query(targetID)
if err != nil {
return 0, err
}
source.Balance = source.Balance - transferAmt
target.Balance = target.Balance + transferAmt
if err = b.accountRepo.Save(source); err != nil {
return 0, err
}
if err = b.accountRepo.Save(target); err != nil {
return 0, err
}
return source.Balance, nil
}
我们定义了一个AccountRepo接口来表示我们的依赖,这个是属于依赖倒置,很常用,对代码逻辑不做过多解释,很简单,接下来我们看一些如何利用Mock工具对Transfer做单元测试
首先我们要使用mockgen工具生成AccountRepo的mock实现,-source指定你的接口在哪个文件,-destination指定生成的代码输出到那个文件中
mockgen -source=repo_demo.go -destination repo_mock.go
然后就是编写单元测试了
func TestTransfer(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
account := Account{
ID: 2,
Name: "zhangsan",
Balance: -1,
}
account2 := Account{
ID: 3,
Name: "lisi",
Balance: 1500,
}
account3 := Account{
ID: 4,
Name: "wangwu",
Balance: 1000,
}
mock := NewMockAccountRepo(ctrl)
mock.EXPECT().Query(gomock.Eq(int64(1))).Return(Account{}, errors.New("quer error")).AnyTimes()
mock.EXPECT().Query(gomock.Eq(int64(2))).Return(account, nil).AnyTimes()
mock.EXPECT().Query(gomock.Eq(int64(3))).Return(account2, nil).AnyTimes()
mock.EXPECT().Query(gomock.Eq(int64(4))).Return(account3, nil).AnyTimes()
mock.EXPECT().Save(gomock.Any()).Return(nil).AnyTimes()
type Args struct {
SourceID int64
TargetID int64
TransferAmt int64
}
// 定义并初始化测试用例
tests := []struct {
name string
args Args
want int64
err bool
}{
{
name: "tranfer amt error case",
args: Args{
SourceID: 0,
TargetID: 0,
TransferAmt: -100,
},
want: 0,
err: true,
}, {
name: "source account amt is not enough",
args: Args{
SourceID: 2,
TargetID: 3,
TransferAmt: 500,
},
want: 0,
err: true,
}, {
name: "tranfer amt success",
args: Args{
SourceID: 3,
TargetID: 4,
TransferAmt: 500,
},
want: 1000,
err: false,
},
}
bank := NewBank(mock)
// 遍历用例
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 执行待测方法
res, err := bank.Transfer(tt.args.SourceID, tt.args.TargetID, tt.args.TransferAmt)
// 断言
if (err != nil && !tt.err) || (err == nil && tt.err) {
t.Errorf("got %v want err %v", err, tt.err)
}
if res != tt.want {
t.Errorf("got %v want %v", res, tt.want)
}
})
}
}
其实可以看到mock.EXPECT()那里模拟了我们的接口实现,使得可以接口方法可以按照我们设计好的结果返回,这样就可以让我们的代码可运行,Mock的思想在各个编程语言中都是一样的,无非就是大家的语法不一样。当然这里只是为了给大家演示一些,测试用例设计得并不是很好,没有覆盖到所有场景,大家在设计测试用例时要仔细
单元测试进阶
单元测试实践原则
- 单元测试应该要快
- 避免各类IO操作
- 避免在代码中sleep
- 单元测试命名
- 测试方法名应该包含被测试方法名
- 测试用例的名称最好包含场景和期待行为
- 编排你的单元测试,单元测试模式三部曲
- 第一步,准备测试用例
- 第二步,执行被测试方法
- 第三步,断言
- 和业务逻辑代码一样,单元测试应该具有良好的可读性
- 单元测试方法避免复杂逻辑
- 我们的目的是测试业务逻辑,如果单元测试又写得很复杂可能对单测本身又引入bug
- 避免在一个单测中测试多个方法
- 私有方法不需要单元测试,应该通过测试公有方法来测试私有方法,私有方法是内部实现细节
单元测试测试用例设计方法
等价类划分方法
等价类划分法将程序所有可能的输入数据和输出(有效的和无效的)划分成若干个等价类,然后从每个等价类中选取具有代表性的数据作为测试用例,从而保证测试用例具有完整性和代表性。比如拿我们第一个例子来说求两个年龄和,那么按照有效和无效来划分,我们知道年龄不可能小于0也不可能大于150(应该没有吧),所以小于0和大于150的年龄就是无效的,因此我们很容易设计出测试用例
场景 | 输入 | 输出 |
---|---|---|
年龄小于0 | -1, -1 | error |
年龄大于150 | 151, 151 | error |
正常 | 1, 1 | 2 |
举的例子很简单,大家可以自己体会一下
边界值分析
通常大量的错误发生在输入或输出范围的边界上,而不是发生在输入输出范围的内部。 边界值分析法是对输入或输出的边界值进行测试的一种方法,通常边界值分析法是作为对等价类划分法的补充,此时的测试用例通常来自等价类的边界,其实我们在上边等价类划分中的测试用例和边界值分析的用例差不多。边界值的重点就是找出业务的边界然后设计用例
错误猜测
误猜测法就是根据经验猜想可能的错误,并依此设计测试用例的方法,比较考验设计用例的开发者的经验。 通常我们将这个方法作为测试设计的补充而不是把他当作主要的设计方法,否则可能会造成测试的不充分。错误猜测法的基本思路:
- 列举出程序中所有可能有的错误和容易发生错误的特殊情况
- 根据他们选择测试用例,
比如还是上边的例子,转账的那个场景,我们可以凭借经验知道不可能转账负数
基本路径测试
我感觉这个方法类似白盒测试,这在方法根据业务逻辑的控制流来设计测试用例,可以保证每一个逻辑分支都有测试用例覆盖。但是如果控制流程复杂或者分支的组合多的话,那么测试用例可能非常多。举个例子,一个程序把100分制转换为A、B、C、D、E,如下图
我们在每个逻辑分支上都设计一个测试用例
场景 | 输入 | 输出 |
---|---|---|
error | -1 | error |
E | 59 | E |
D | 69 | D |
C | 79 | C |
B | 89 | B |
A | 90 | A |
忠告
切勿陷入追求单测覆盖率陷阱
很多开发者为了追求单测覆盖率,疯狂编写单元测试,有些逻辑简单很容易保证正确的也花时间去编写单元测试。如果项目不紧急且你有大把的时间,你可以那样。如果你时间不充裕,那么把好钢用在刀刃上,去为那些必要的方法编写完整的单元测试,如何界定这类方法,提供几个建议
- 你自己没有信心的
- 逻辑复杂的一定要写单元测试
- 不确定未来是否会修改的
切勿滥用Mock
不要在被测试函数中有调用其他函数的地方都去作mock,什么时候需要mock呢?满足以下场景时适合mock
- 依赖模块提供非确定的结果(比如当前的时间会让你每次执行单元测试时都会获取不同的值)
- 依赖模块某些状态难以创建或者重现(比如网络错误或者文件读写错误)
- 依赖模块执行太慢(比如在测试开始之前初始化数据库)
- 依赖模块还不存在或者其行为可能发生变化(比如测试驱动开发中驱动创建新的类)