程序员的自我修养: 如何写好单元测试

今天我们来聊聊程序员中最讨论的一个部分,单元测试

概论

首先先摆明一个立场,CodeReview 的作用大于单元测试,但是因为测试框架的存在,我们可以通过单元测试发现更多类型的错误。

The primary purpose of collaborative construction is to improve software quality. As
noted in Chapter 20, “The Software-Quality Landscape,” software testing has limited
effectiveness when used alone—the average defect-detection rate is only about 30 percent for unit testing, 35 percent for integration testing, and 35 percent for low-volume
beta testing. In contrast, the average effectivenesses of design and code inspections are
55 and 60 percent (Jones 1996). The secondary benefit of collaborative construction
is that it decreases development time, which in turn lowers development costs.
by《Code Complete》

单元测试通常占用整个项目的 8% - 35%,从开发者的角度的看起来,大概会消耗 50% 的研发时间,因此在估算时间的时候不要太过于乐观。

绝大多数的开发者习惯于在业务逻辑写完之后进行单元测试的编写,认为可以节约时间,根据 Kent Beck 的《Test Driven Development》 中的调查,实则在编写代码之前完成测试Case 的编写是降低整体研发时间的。

  • 在编码之前编写单元测试不用考虑代码的实现逻辑,可以写出更加代码测试
  • 单侧让你考虑代码实现的时候更加的全面
  • 单侧可以迫使代码更利于测试,减少后续重构的返工

测试的方法论

当前的测试方法论主要有如下两种 TDD BDD

TDD

TDD是测试驱动开发(Test-Driven Development)的英文简称。TDD的原理是在开发功能代码之前,先编写单元测试用例代码,测试代码确定需要编写什么产品代码。

TDD 是我们最为常用的一种测试方式,也是非常符合设计思路的一种测试,我们在 Coding 具体逻辑之前,我们就会考虑我们的系统抽象,针对抽象接口编写测试。

BDD

行为驱动开发(BDD)是测试驱动开发的延伸,开发使用简单的,特定于领域的脚本语言。这些DSL将结构化自然语言语句转换为可执行测试。结果是与给定功能的验收标准以及用于验证该功能的测试之间的关系更密切。因此,它一般是测试驱动开发(TDD)测试的自然延伸。

这两者在行为上区别也并没有想象中那么大,往往这两者在一个系统中会同时存在。

TDD BDD
该过程从编写测试用例开始。 该过程从编写行为场景开始。
TDD 专注于如何实现功能。 BDD 专注于最终用户的应用程序行为。
测试用例是用编程语言编写的。 场景更具可读性。
TDD 中的测试只能被具有编程知识的人理解。 任何人都可以理解 BDD 中的测试,包括没有任何编程知识的人。
应用程序功能的变化对TDD 中的测试用例影响很大。 BDD 场景受功能更改的影响不大。
仅开发测试人员之间需要协作。 需要所有利益相关者之间的协作。
一些支持 TDD 的工具有:JUnit、TestNG、NUnit 等。 支持 BDD 的一些工具是 SpecFlow、Cucumber、MSpec 等。

从实践的角度上,我们一般在对无副作用的函数采用 TDD, 对一些小型点的 e2e 测试会采用 BDD

场景分析

比如我们在项目中常见的 utils 工具包,包装一些静态函数,因此我们尝尝使用如下方式进行测试

首先我们先定义好我们的函数

1
2
3
func ListToMap(conditions []string) map[string]struct{} {

}

然后我们在测试文件中,定义好测试类

1
2
3
4
5
func TestListToMap(t *testing.T) {
result := ListToMap([]string{"1", "2"})
assert.NotNil(t, result)
assert.Equal(t, 2, len(result))
}

然后我们编写我们的主题逻辑即可,不过这里的测试其实就犯了一个场景的单侧的问题。

Developer tests tend to be “clean tests” Developers tend to test for whether the code
works (clean tests) rather than test for all the ways the code breaks (dirty tests).
Immature testing organizations tend to have about five clean tests for every dirty test.
Mature testing organizations tend to have five dirty tests for every clean test. This ratio
is not reversed by reducing the clean tests; it’s done by creating 25 times as many dirty
tests (Boris Beizer in Johnson 1994).

我们编写的是一个被称为 happly testcase,因为我们接受了一个最为普通的 case,我们只能保证在一个非常适用的场景下的可靠性,而真正有经验的开发同学会需要增加更多的边界测试。

1
2
3
4
5
6
7
8
9
10
func TestListToMap(t *testing.T) {
result := ListToMap([]string{"1", "2"})
assert.NotNil(t, result)
assert.Equal(t, 2, len(result))

// nil get nil
assert.IsNil(t, ListToMap(nil))
// empty get empty
assert.Equal(t, map[]string{}, ListToMap([]string{}))
}

这里列举一些常见的边界

  1. 入参为 Nil
  2. 入参为 Empty
  3. 入参超长(IntMax)
  4. 复杂条件冲突存在

工具箱

除了上面提到的 TDD BDD 这样的方法论,我们在测试的过程中,我们也会相当的使用一些常见的测试范式。

Table Driven Tests

当我们编写一个工具函数的时候,我们按照 TDD的思维已经编写了一个单元测试如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func Split(s, sep string) []string {
var result []string
i := strings.Index(s, sep)
for i > -1 {
result = append(result, s[:i])
s = s[i+len(sep):]
i = strings.Index(s, sep)
}
return append(result, s)
}

func TestSplit(t *testing.T) {
got := Split("a/b/c", "/")
want := []string{"a", "b", "c"}
if !reflect.DeepEqual(want, got) {
t.Fatalf("expected: %v, got: %v", want, got)
}
}

这个时候我们需要增加一个新的测试项目,就需要在下面再加一行,或者新增一个测试函数

1
2
3
4
5
6
7
func TestSplitWrongSep(t *testing.T) {
got := Split("a/b/c", ",")
want := []string{"a/b/c"}
if !reflect.DeepEqual(want, got) {
t.Fatalf("expected: %v, got: %v", want, got)
}
}

随着我们测试边界的增加,会导致我们的测试函数无休止的增加。而 Table Driven test 就是将测试变成一个表格来进行测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func TestSplit(t *testing.T) {
type test struct {
input string
sep string
want []string
}

tests := []test{
{input: "a/b/c", sep: "/", want: []string{"a", "b", "c"}},
{input: "a/b/c", sep: ",", want: []string{"a/b/c"}},
{input: "abc", sep: "/", want: []string{"abc"}},
}

for _, tc := range tests {
got := Split(tc.input, tc.sep)
if !reflect.DeepEqual(tc.want, got) {
t.Fatalf("expected: %v, got: %v", tc.want, got)
}
}
}

在对无副作用的函数测试中,推荐采用这种测试方式。

Data Driven Tests

对于一些入参比较的复杂的对象,比如我们经常需要从 Yaml 构建一个相对复杂的对象的时候,这样的测试方式就会显得有点不足。这个时候我们可以引入 Data Driven 的模式。

举个例子,我们有如下的一个测试函数,我们的测试源是 loginData,当前是固化在代码之中的,维护起来不是很方便。

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
@DataProvider
public Object[][] loginData(){
return new Object[][] {
{ "valid_email", "valid_pass", "success_login" },
{ "valid_phone", "valid_pass", "success_login" },
{ "invalid_email", "valid_pass", "failed_login" },
{ "invalid_phone", "valid_pass", "failed_login" },
{ "valid_email", "invalid_pass", "failed_login" },
{ "valid_phone", "invalid_pass", "failed_login" },
{ "null_email_phone", "valid_pass", "failed_login" },
{ "valid_email", "null_pass", "failed_login" }
};
}

//passing value dari DataProvider ke parameter di method test
@Test(dataProvider = "loginData")
public void login(String phoneEmail, String pass, String expected){
driver.findElement(By.id("email")).sendKeys(phoneEmail);
driver.findElement(By.id("pass")).sendKeys(pass);
driver.findElement(By.name("login")).click();
//compare expected result dengan actual result dari website
if (expected.equals("failed_login")){
WebElement errorBox = driver.findElement(By.id("error_box"));
Assert.assertTrue(errorBox.isDisplayed());
} else if (expected.equals("success_login")){
WebElement searchBox = driver.findElement(By.xpath("//input[@aria-label = 'Cari di Facebook']"));
Assert.assertTrue(searchBox.isDisplayed());
}
}

因此我们可以将测试集变成一个外部文件。

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
[
{
"phoneEmail" : "valid_email", "pass" : "valid_pass", "expected" : "success_login"
},
{
"phoneEmail" : "valid_phone", "pass" : "valid_pass", "expected" : "success_login"
},
{
"phoneEmail" : "invalid_email", "pass" : "valid_pass", "expected" : "failed_login"
},
{
"phoneEmail" : "invalid_phone", "pass" : "valid_pass", "expected" : "failed_login"
},
{
"phoneEmail" : "valid_email", "pass" : "invalid_pass", "expected" : "failed_login"
},
{
"phoneEmail" : "valid_phone", "pass" : "invalid_pass", "expected" : "failed_login"
},
{
"phoneEmail" : "null_email_phone", "pass" : "invalid_pass", "expected" : "failed_login"
},
{
"phoneEmail" : "valid_email", "pass" : "null_pass", "expected" : "failed_login"
}
]

将数据集从硬编码转化为读取文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@DataProvider
public Object[][] loginData(){
return File.ReadAll("/example.json")
}

//passing value dari DataProvider ke parameter di method test
@Test(dataProvider = "loginData")
public void login(String phoneEmail, String pass, String expected){
driver.findElement(By.id("email")).sendKeys(phoneEmail);
driver.findElement(By.id("pass")).sendKeys(pass);
driver.findElement(By.name("login")).click();
//compare expected result dengan actual result dari website
if (expected.equals("failed_login")){
WebElement errorBox = driver.findElement(By.id("error_box"));
Assert.assertTrue(errorBox.isDisplayed());
} else if (expected.equals("success_login")){
WebElement searchBox = driver.findElement(By.xpath("//input[@aria-label = 'Cari di Facebook']"));
Assert.assertTrue(searchBox.isDisplayed());
}
}

Behavior-Driven Development

当我们尝试使用一些模拟用户行为的测试方式时,我们可以考虑采用 ginkgo

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
...
)

Describe("Checking books out of the library", Label("library"), func() {
var library *libraries.Library
var book *books.Book
var valjean *users.User
BeforeEach(func() {
library = libraries.NewClient()
book = &books.Book{
Title: "Les Miserables",
Author: "Victor Hugo",
}
valjean = users.NewUser("Jean Valjean")
})

When("the library has the book in question", func() {
BeforeEach(func() {
Expect(library.Store(book)).To(Succeed())
})

Context("and the book is available", func() {
It("lends it to the reader", func() {
Expect(valjean.Checkout(library, "Les Miserables")).To(Succeed())
Expect(valjean.Books()).To(ContainElement(book))
Expect(library.UserWithBook(book)).To(Equal(valjean))
})
})

Context("but the book has already been checked out", func() {
var javert *users.User
BeforeEach(func() {
javert = users.NewUser("Javert")
Expect(javert.Checkout(library, "Les Miserables")).To(Succeed())
})

It("tells the user", func() {
err := valjean.Checkout(library, "Les Miserables")
Expect(error).To(MatchError("Les Miserables is currently checked out"))
})

It("lets the user place a hold and get notified later", func() {
Expect(valjean.Hold(library, "Les Miserables")).To(Succeed())
Expect(valjean.Holds()).To(ContainElement(book))

By("when Javert returns the book")
Expect(javert.Return(library, book)).To(Succeed())

By("it eventually informs Valjean")
notification := "Les Miserables is ready for pick up"
Eventually(valjean.Notifications).Should(ContainElement(notification))

Expect(valjean.Checkout(library, "Les Miserables")).To(Succeed())
Expect(valjean.Books()).To(ContainElement(book))
Expect(valjean.Holds()).To(BeEmpty())
})
})
})

When("the library does not have the book in question", func() {
It("tells the reader the book is unavailable", func() {
err := valjean.Checkout(library, "Les Miserables")
Expect(error).To(MatchError("Les Miserables is not in the library catalog"))
})
})
})

Mock

对于测试中我们需要依赖的第三方函数来说,如果需要在测试过程中初始化,可能到来的负担是极大的,因此我们可以考虑采用 Mock 的方式。
比如我们有如下函数

1
2
3
4
5
6
7
8
9
10
f// RegisterUser if the user is not registered before
func RegisterUser(user User) error {
if userdb.UserExist(user.Email) {
return fmt.Errorf("email '%s' already registered",
user.Email)
}
// ...code for registering the user...
log.Println(user)
return nil
}

对于 Register 来说,我们主要逻辑并不是 Db 相关,因此,我们如果要去测试这个需要跑一个数据库,然后初始化系统很烦,因此,我们会选择将 userdb mock 掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func TestRegisterUser(t *testing.T) {
ctrl := gomock.NewController(t)

// Assert that Bar() is invoked.
defer ctrl.Finish()

db := NewMockUserDB(ctrl)

// Asserts that the first and only call to Bar() is passed 99.
// Anything else will fail.
db.
EXPECT().
UserExist(gomock.Any()).
Return(false)

// 这里我们就可以测试我们的逻辑了。
}

最佳实践

代码需要易于测试

比如我们有如下代码

1
2
3
4
5
6
7
8
9
10
// 将某个特定的错误转为通用错误 JSON,并且返回
func bindingErrToStandardError(ctx *gin.RequestContext, bindingErr *binding.Error) {
if bindingErr.ErrType == BindingErr && isMissingParamErr(bindingErr.Msg) {
standardError = errors.ToCommonError(errors.MissingParameter_parameter(bindingErr.FailField))
} else {
standardError = errors.ToCommonError(errors.InvalidParameter_parameter(bindingErr.FailField))
}

ctx.AbortWithStatusJSON(int(standardError.GetHTTPCode()), response)return standardError
}

但是这样的代码其实并不适合测试,因此我们的代码没有返回,我们测试的化,需要间接的依赖 gin.Context 这个对象。因此将代码重构为

1
2
3
4
5
6
7
8
9
10
11
12
func bindingErrToStandardError(bindingErr *binding.Error) (standardError *errors.Error) {
// binding err including missing param, parameter type err and so on
// here we only handle missing param, other kind of err we all regard as
// invalid param
if bindingErr.ErrType == BindingErr && isMissingParamErr(bindingErr.Msg) {
standardError = errors.ToCommonError(errors.MissingParameter_parameter(bindingErr.FailField))
} else {
standardError = errors.ToCommonError(errors.InvalidParameter_parameter(bindingErr.FailField))
}
return standardError
}

更为方便我们进行测试,也是 TDD 的优点,当我们如果一开始在代码的适合,非常的习惯写出上面一种代码,而如果我们基于测试进行开发,我们就会很自然的写出下面一种代码。

Table Driven

对于如下最为简单的一个函数,我们进行测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func CheckSucceed(rm *v1alpha1.RegistryManager, reason string) {
meta.SetStatusCondition(&rm.Status.Conditions,
v1.Condition{
Type: string(v1alpha1.RegistryManagerChecking),
Status: v1.ConditionFalse,
Message: "Finished health check, waiting for next turn",
Reason: reason,
})
meta.SetStatusCondition(&rm.Status.Conditions,
v1.Condition{
Type: string(v1alpha1.RegistryManagerHealthy),
Status: v1.ConditionTrue,
Message: "Finished health check. CR is healthy",
Reason: reason,
})
}

如果我们写成如下模式

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
func TestCheckSucceed(t *testing.T) {
type args struct {
rm *v1alpha1.RegistryManager
reason string
}
tests := []struct {
name string
args args
}{
{
name: "Checking-> Waiting",
args: args{
rm: &v1alpha1.RegistryManager{
Status: v1alpha1.RegistryManagerStatus{
Conditions: []v1.Condition{
{
Type: string(v1alpha1.RegistryManagerChecking),
Status: v1.ConditionTrue,
},
},
},
},
reason: "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
CheckSucceed(tt.args.rm, tt.args.reason)
healthy := meta.FindStatusCondition(tt.args.rm.Status.Conditions, string(v1alpha1.RegistryManagerHealthy))
assert.Equal(t, v1.ConditionTrue, healthy.Status)
checking := meta.FindStatusCondition(tt.args.rm.Status.Conditions, string(v1alpha1.RegistryManagerChecking))
assert.Equal(t, v1.ConditionFalse, checking.Status)
})
}
}

我们看上去使用了 Table Test Driven,但是实际上并没有,因为我们将主要的逻辑都扔到了 Run 之中,而更好的方式应该是如下方式

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
unc TestCheck(t *testing.T) {
tests := []struct {
name string
args args
}{
{
name: "Succeed",
args: args{
rm: states[checking].DeepCopy(),
reason: "",
expect: &v1alpha1.RegistryManager{
Status: v1alpha1.RegistryManagerStatus{
Conditions: []v1.Condition{
{
Type: string(v1alpha1.RegistryManagerChecking),
Status: v1.ConditionFalse,
Message: "Finished health check, waiting for next turn",
},
{
Type: string(v1alpha1.RegistryManagerHealthy),
Status: v1.ConditionTrue,
Message: "Finished health check. CR is healthy",
},
},
Phase: v1alpha1.PhaseRunning,
},
}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
CheckSucceed(tt.args.rm, tt.args.reason)
if diff := cmp.Diff(tt.args.rm, tt.args.expect, cmputils.SkipTimeCompare); len(diff) != 0 {
t.Fatalf("want diff is %s", diff)
}
})
}
}

推荐阅读