Why单元测试
- 让我们对重构与修改有信心
新功能的增加,代码复杂性的提高,优化代码的需要,或新技术的出现都会导致重构代码的需求。在没有写单元测试的情况下,对代码进行大规模修改,是一件不敢想象的事情,因为写错的概率实在太大了。而如果原代码有单元测试,即使修改了代码单测依然通过,说明没有破坏程序正确性,一点都不慌! - 及早发现问题,降低定位问题的成本
bug发现的越晚,修复它的费用就越高,而且呈指数增长的趋势。作为编码人员,也是单元测试的主要执行者,能在单测阶段发现的问题,就不用等到联调测试再暴露出来,减少解决成本。 - 代码设计的提升
为了实现功能而编码的时候,大多时候我们考虑的是函数实现,一顿编写,写好了运行成功就万事大吉了。而写单元测试的时候,我们跳出了函数,从输入输出的角度去思考函数/结构体的功能。此时我们不由得,这个函数真的需要吗?这个函数的功能是不是可以简化/抽象/拆分一下?这个函数考虑的情况似乎不够全面吧?这里的使用外部依赖是否真的合适?这些思考,能推动我们更仔细思考代码的设计,加深对代码功能的理解,从而形成更合理的设计和结构。 - 单元测试也是一种编写文档的行为
单元测试是产品代码的第⼀个使⽤者,并伴随代码⽣命周期的始终。它⽐任何⽂字⽂档更直观、更准确、更有效,⽽且永不过时。当产品代码更新时单元测试就会同步更新(否则通不过测试);而⽂字⽂档则更新往往滞后,甚⾄不更新,从⽽对后来的开发者和维护者产⽣误导,正所谓:过时的⽂档⽐没有⽂档更有害。
单元测试的时机
编码前:TDD
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kk20OGG3-1610712462891)(https://s3.ax1x.com/2021/01/11/s87acD.png)]
Test-Driven Development, 测试驱动开发,是敏捷开发的⼀项核⼼实践和技术,也是⼀种设计⽅法论。TDD原理是开发功能代码之前,先编写测试⽤例代码,然后针对测试⽤例编写功能代码,使其能够通过。其好处在于通过测试的执⾏代码,肯定满⾜需求,⽽且有助于接⼝编程,降低代码耦合,也极⼤降低bug出现⼏率。
然而TDD的坏处也显而易见:由于测试⽤例在未进⾏代码设计前写;很有可能限制开发者对代码整体设计,并且由于TDD对开发⼈员要求⾮常⾼,跟传统开发思维不⼀样,因此实施起来比较困难,在客观情况不满足的情况下,不应该盲目追求对业务代码使用TDD的开发模式。
编码后:存量
在完成业务需求后,可能由于上线时间较为紧、没有单测相关规划的历史缘故,当时只手动测试是否符合功能。
而这部分存量代码出现较大的新需求或者维护已经成为问题,需要大规模重构时,是推动补全单测的好时机。因为为存量代码补充上单测一方面能够推进重构者进一步理解原先逻辑,另一方面能够增强重构后的信心,降低风险。
但补充存量单测可能需要再次回忆理解需求和逻辑设计等细节,甚至写单测者并不是原编码设计者。
与编码同步进行:增量
及时为增量代码写上单测是一种良好的习惯。因为此时有对需求有一定的理解,能够更好地写出单元测试来验证正确性。并且能在单测阶段发现问题,修复的成本也是最小的,不必等到联调测试中发现。
另一方面在写单测的过程中也能够反思业务代码的正确性、合理性,能推动我们在实现的过程中更好地反思代码的设计并及时调整。
Golang单测框架选型 & 示例
主要介绍golang原生testing框架、testify框架、goconvey框架,看一下哪种框架是结合业务体验更好的。
golang原生testing框架
特点
文件形式:文件以_test.go 结尾
函数形式:func TestXxx(*testing.T)
断言:使用 t.Errorf 或相关方法来发出失败信号
运行:使用go test –v执行单元测试
示例
// 原函数 (in add.go)
func Add(a,b int) int {
return a + b
}
// 测试函数 (in add_test.go)
func TestAdd(t *testing.T) {
var (
a = 1
b = 1
expected = 2
)
var got = Add(a, b)
if got != expected {
t.Errorf("Add(%d, %d) = %d, expected %d", a, b, got, expected)
}
}
扩展:Table-Driven 的测试模式
Table-Driven 是很多 Go 语言开发者所推崇的测试代码编写方式,Go 语言标准库的测试也是通过这个结构来撰写的。简单来说其实就是将多个测试用例封装到数组中,依次执行相同的测试逻辑。值得一提的是改涉及思想并不是golang 自带testing框架特有,即使是用其他测试框架,也可以应用此种写法。
一般来说大概长这个样子:
func TestAdd(t *testing.T) {
var addTests = []struct {
a int
b int
expected int // expected result
}{
{
1, -1, 0},
{
3, 2, 5},
{
7, 3, 10},
}
for _, tt := range addTests {
got := Add(tt.a, tt.b)
if got != tt.expected {
t.Errorf("Add(%d, %d) = %d, expected %d", tt.a, tt.b, got, tt.expected)
}
}
}
其中可见我们通过匿名结构体构建了每一个测试用例的结构,一个输入 in 和一个我们期望的输出 out,然后在真实的测试函数中,通过 range 轮询每一个测试用例,并且调用测试函数,比较输出结果,如果输出结果不等于我们期望的结果,即报错。这种测试框架最好的一点在于,结构清晰,并且添加新的测试 case 会非常方便。而另一方面,缺点在于测试用例之间的层级关系不明显 都是平铺关系,并且各个测试用例的断言方式相对单一,mock、stub的相对不灵活。
Testify
简介
Testify基于gotesting编写,所以语法上、执行命令行与go test完全兼容,只是其是比较清晰的断言定义。它提供 assert 和 require 两种用法,分别对应失败后的执行策略,前者失败后继续执行,后者失败后立刻停止。 但是它们都是单次断言失败,当前test case就失败。
示例
import (
"testing"
"github.com/stretchr/testify/assert"
)
...
// 直观使用assert断言能力
func TestFind(t *testing.T) {
service := ...
firstName, lastName := service.find(someParams)
assert.Equal(t, "John", firstName)
assert.Equal(t, "Dow", lastName)
}
// Table-Driven的的模式使用assert
func TestCalculate(t *testing.T) {
assert := assert.New(t)
var tests = []struct {
input int
expected int
}{
{
2, 4},
{
-1, 1},
{
10, 2},
{
-5, -