在 VS Code 快速生成单元测试

每次写 LeetCode 解题,都会强调 准备纸笔 和 单元测试。但这是正确的废话,特别是单元测试。老手看来这是常识,新手听说了却也还是不知道具体怎么操作。

那今天就来说一说。

环境

下面用 VS Code 写 Go 举例。

VS Code 是开源 IDE 里一个不错的选择。依托强大的插件生态,啥开发都能做。我现在基本把各种开发都挪里面,甚至包括 幻灯制作 和 一些简单的制图。

但 VS Code 不是唯一的选项,你也不一定写 Go。很多朋友觉得 IDEA 更好用(具体到 Go 是 GoLand),也有人习惯 Eclipse,我也体会过 Vim 纯用键盘工作的效率。这无所谓,只是提供一个思路,在你习惯的 IDE 和 语言也总是可以找到工具提高效率的。就是商业软件记得买授权,要不就用免费开源的社区工具。

配置

VS Code 的安装,跟着官网走,不展开。Go 环境的安装配置,也不是重点。

打开 VS Code,在扩展商店(Extensions)搜索 Go ,一般排在第一位的结果就是要安装的插件。(插件描述为 Rich Go language support for Visual Studio Code。开发者就是 Microsoft。)

安装完插件,它会提示安装各种 Go 开发需要用到的工具,按提示同意安装即可。注意的是,这些工具需要用到 go get / install 命令,所以走到这一步之前,先确认 Go 的环境已经配置好了。

下面我们主要用到 gotests 。如果已经安装,这一部分就可以跳过了。

如果不确定,按 F1 或者 Ctrl + Shift + P唤出指令面板(Command Palette),输入 Go: Generate Unit Tests ,如果出现三个提示 For FileFor FunctionFor Package ,就说明安装好了。实际上,指令面板的内容不需要完整输入,只输入开头一部分,或者首字母(像 GGUT),都能够智能匹配。善用指令面板,可以一定程度上摆脱鼠标。

如果没有,唤出指令面板,执行 Go: Install/Update Tools,找到 gotests 安装即可。

干活

这里用 LeetCode 的题目 https://leetcode.com/problems/regular-expression-matching/ 举例。

实际开发稍复杂一些,原理是一样的。主要差别是,题目帮你定好了函数签名;而实际工作中,如何模块化、如何命名、传递哪些参数,恰恰是比较难的一部分。不过,如果题目比较复杂,最好也不要一个函数到底,也要考虑抽取模块梳理逻辑,还有复用。

初始化

题目要求实现一个函数,返回字符串 s 和 正则式 p 是否匹配(bool 值)。函数签名为 func isMatch(s string, p string) bool

VS Code 打开工作目录。我启用了 go modules ,需要初始化 go.mod ,module name 随便给一个: go mod init leetcode 。然后新建一个 ans.go 文件用来写代码实现:

1
2
3
4
5
6
// ans.go
package main
func isMatch(s string, p string) bool {
return true // 先随便返回,确保能编译运行
}

将焦点(也就是输入光标)停留在 isMatch 函数上,F1 唤出 指令面板,执行 Go: Generate Unit Tests For Function ,测试代码就自动生成了。熟练之后,F1 接着 GGUTFF (不必输完),方向键选一下,回车,很快的。也可以试试 For FileFor Package ,就是生成 文件 / 包 内所有函数的测试。只有一个函数时,其实没有差别。

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
// ans_test.go
package main
import (
"testing"
)
func Test_isMatch(t *testing.T) {
type args struct {
s string
p string
}
tests := []struct {
name string
args args
want bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isMatch(tt.args.s, tt.args.p); got != tt.want {
t.Errorf("isMatch() = %v, want %v", got, tt.want)
}
})
}
}

到这里为止,用时不到一分钟,熟练的话,十几秒 就做完了。

添加测试用例

接着添加测试用例。这里直接用题目提供的 5 个例子。

实际做题和开发中,一定还要多考虑不同的边界条件,以及随机生成大数据量进行测试。当然,在比赛中,如果时间非常紧,又实在想不出其他 cases,判断一次错误提交对于得分影响不大,也可以用基本用例测试完之后,直接提交看看。一般情况下,还是要重视 Test Cases 的设计。

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
// ans_test.go
package main
import (
"testing"
)
var tests = []struct {
name string
s string
p string
want bool
}{
{
name: "1", // 按个人习惯随便给,只要后面出错时,能够区分、定位就行
s: "aa",
p: "a",
want: false,
},
{
name: "2",
s: "aa",
p: "a*",
want: true,
},
{
name: "3",
s: "ab",
p: ".*",
want: true,
},
{
name: "4",
s: "aab",
p: "c*a*b",
want: true,
},
{
name: "5",
s: "mississippi",
p: "mis*is*p*.",
want: false,
},
// 篇幅关系,只提供题目的例子
// 实际开发一定要多考虑不同的测试用例
}
func Test_isMatch(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isMatch(tt.s, tt.p); got != tt.want {
t.Errorf("isMatch() = %v, want %v", got, tt.want)
}
})
}
}

除了填测试数据,我还根据自己的习惯做了一些调整,仅供参考:

  • 我习惯把测试数据放在测试函数外定义,这样:
    • 测试数据和测试逻辑分离,视觉上比较清晰,分别修改也比较容易定位。
    • 如果有多个测试共用测试数据,方便复用。
  • 测试数据嵌套层次太多,数据定义会又麻烦又乱,所以我把 args 直接展开了。

完成之后,直接执行一次测试。(可以鼠标点击 Test_isMatch 上面的 run test。如果不想动鼠标,也可以将焦点停留在测试函数,指令面板执行 Go: Test Function At Cursor。)

1
2
3
4
5
6
7
8
9
10
Running tool: go.exe test -timeout 30s leetcode -run ^(Test_isMatch)$
--- FAIL: Test_isMatch (0.00s)
--- FAIL: Test_isMatch/1 (0.00s)
ans_test.go:51: isMatch() = true, want false
--- FAIL: Test_isMatch/5 (0.00s)
ans_test.go:51: isMatch() = true, want false
FAIL
FAIL leetcode 0.309s
FAIL

FAIL 是肯定的,因为待测函数根本没有实现。如果 PASS 反而有问题,说明测试用例太弱。明知道是 FAIL ,也要执行一次,首先是确保没有编译错误,能正常运行;其次就是确认一下,测试用例没有弱到直接可以 PASS。

到这里为止, 用时不到两分钟 。当然这没有包括生成更多测试用例的时间。这个时间根据要解决的问题、开发者、测试要求的不同,差别很大。

之后就是实现函数,然后回来测试,直到所有 cases 都 PASS 为止。

一点展开

看到这里,你可能会问,你要说的就这?是的,本文说的不是什么很复杂的内容。

然而就是这么简单的东西,很多人要么不知道,要么知道却觉得没必要、懒得写。 你在刷题和实际项目中有坚持写单元测试吗 ?你能说出你的常用语言至少一个测试工具和框架吗?我猜读者朋友里,除了部分关注 Go,写 Java 的比较多。这些朋友有好好写 JUnit 或者别的测试框架代码吗?

如果你的答案都是肯定的,必须说你有很好的开发习惯。

根据我以前做算法内训讲师时的观察,大多数学员在做题时的习惯是这样的:

想到哪写到哪,凭感觉写出差不多的代码,然后 手敲输入数据 进行验证。这样测试既低效,又没有足够的测试强度。换言之,手动测试时间和精力花掉了,没有达到效果。而且因为实现、测试、修改 都依赖注意力集中,这样会很累很慢,测试和调试不知不觉就变成了 时间黑洞 。总感觉差一点就可以了,但是不断还是有错,不断消磨你的耐心。

为什么图里说通过是『侥幸正确』?因为手敲几个输入的测试,输入数据复杂一点的题,甚至连题目提供的例子都很难敲完,这种测试强度下居然一两次通过,要么题太水,要么人思维极其清晰,要么运气好。

UTDD

你不能指望遇到水题,不能指望自己不犯错和运气好。唯一可靠的,是可重复的、自动完成的 测试,帮你 低成本覆盖 所有能想到的边界条件。这其实就是 TDD (测试驱动开发)中的 UTDD(Unit Test Driven Development 单元测试驱动开发)。(篇幅关系,这里不讨论 TDD 的实施细节和优劣。)

看着好像步骤多了很多,变复杂了。但如果你看了本文前面的部分,你就会明白,大多数步骤是不怎么消耗注意力的机械操作,甚至可以借助工具自动完成:

  • 伪实现:写空白函数,返回默认值,能编译就行。
  • 编写测试代码:根据实现自动生成测试代码,花时间列测试用例。
  • 自动测试:辛苦鼠标点一下。

费点脑细胞的,就是改进代码实现(第一次因为从空白实现开始,其实是从头实现)。另外,列出测试用例,特别是提交之后有错,考虑遗漏了什么边界用例,需要比较细心。

刷题如此,实际开发比这复杂(需要自己模块化,测试用例变更更频繁),但道理相通。

关键在于:

  • 每个步骤责任分割得足够细,只需要关注一个焦点。这样有助于聚焦思维,也方便借助工具,乃至自动化。
  • 实现目标以测试用例的形式存在,每次测试都可以复用。强化用例并不会增加自动测试的负担,反而有助于引导实现。
  • 测试代码告诉调用方、同事或者以后的自己,如何调用代码,哪些用法是对的哪些会引起错误。这甚至比不能运行的文档效果更好。

最后

必须承认,我很长一段时间里面也是不写测试的(包括但不限于 单元测试)。一方面是学校里教测试局限于理论;另一方面工作后多数企业对此没有规范要求,反而 deadline 逼得很紧;最后,不还有测试部门在后面守着吗。说来惭愧,近些年才开始意识到测试的重要性,逐渐要求自己,并摸索到一点点技巧。

多数人不写 单元测试 的理由,无外乎 没有必要 和 没有时间。

如果代码非常简单,一眼看清做了什么,当然没有必要。但究竟有多少代码能简单到这种程度?何况最初简单的代码,在不断变更需求和改进实现之后,还能保持简单吗?写过测试用例,认真考虑过边界条件,就会发现根本不简单。

至于时间,被 毫无头绪的调试 和 痛苦的重构 折磨过就明白,与之相比,熟练之后写单元测试的时间简直不值一提。以此为理由就好像说磨刀耽误砍柴一般。多数人(包括我)的问题是出在 根本没有了解过怎样能写好测试,更不要提有为此练习过。


知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。