接着上一篇内容,我们继续~
测试的快速反馈有两个方面的含义:
1.测试运行要快速出结果。
2.当测试失败时,要能快速定位失败原因。
测试运行效率决定了开发的工作周期运转的快慢。在理想的 TDD 模型中,开发人员一遍又一遍地重复着“测试 -> 实现 -> 测试“ 这样的周期循环,直到所有用例通过。持续集成和持续交付的过程也是如此。不管是单元测试还是大型测试,运行效率都是应该追求的目标。
同样在的道理,当测试出现用例失败时,如果我们要花很长的时间来定位到原因,也会拖慢我们从“测试”到“实现”的速度。要提高快速定位的能力,一方面要提高被测代码的可观测性,另一方面要给用例合理命名,还有在断言时加入有价值,易读易懂的 message. 在去年的一次分享中,已经提到过如何建设被测系统的可观测性并且做了一些支撑工具。在用例命名和断言信息方面,越是大型测试要求越高,因为其所覆盖的代码范围广,失败节点多,而在单测中则相对要求低一些,因为其被测代码覆盖范围小,相对容易定位。
但是!在单元测试时依然要认真给用例命名,充分添加断言信息。这一建议单独另起一段,考虑的是当我们写单测时的心智问题。不少开发在写单测时多心智是:“单测是写给我自己看的,这个用例测试的是我负责的代码,出了问题我很快就知道定位”,但是从团队和业务的角度出发,测试都是写给整个开发团队看的,这与代码的 readability 是一样的。code for team, test for team! 测试用例怎样命名,断言信息写些啥,有很多博客和问答可以提供参考.这里提供一个建议:在命名和添加断言信息时,想象着它们在报告中是如何显示的。以下用一个实际的例子来对比一下断言信息好坏的明显区别。
用例 v1
func TestQueryRecentExecs(t *testing.T) {
t.Run("count 100 is too large", func(t *testing.T) {
proxy := clientProxy()
ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*5)
defer cancelFunc()
req := &caselog.QueryCaseRecentExecsRequest{
CaseId: 5,
Count: 100,
}
_, err := proxy.QueryCaseRecentExecs(ctx, req)
require.Error(t, err)
assert.Equal(t, errs.ErrorTypeBusiness, trpcErrType(err))
})
}
该用例中,被测接口要求入参中
Count
不能大于等于 100,我们向被测服务发起异常参数的请求,期望其返回业务类型的错误(不能返回框架类型的错误,框架类型的错误是诸如服务寻址失败,超时之类的,跟业务无关的错误,具体参考 trpc 错误手册)。某次运行用例失败后得到的 log 如下:
test log v1
Failed
=== RUN TestQueryRecentExecs/count_100_is_too_large
caselog_test.go:56:
Error Trace: caselog_test.go:56
Error: Not equal:
expected: 2
actual : 1
Test: TestQueryRecentExecs/count_100_is_too_large
--- FAIL: TestQueryRecentExecs/count_100_is_too_large (2.00s)
从这个错误信息中,我们从用例名中知道目的是验证
Count
值为 100 过大而产生错误,但是在错误信息中我们读到的是“因为我们期望 2 而实际值为 1,用例失败”。这里 2 和 1 分别是什么?没有信息。
下面进行一版改进:
用例 v2
func TestQueryRecentExecs(t *testing.T) {
t.Run("large count 100 cause biz error", func(t *testing.T) {
proxy := clientProxy()
ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*5)
defer cancelFunc()
req := &caselog.QueryCaseRecentExecsRequest{
CaseId: 5,
Count: 100,
}
_, err := proxy.QueryCaseRecentExecs(ctx, req)
require.Error(t, err)
assert.Equal(t, "business", trpcErrTypeName(err), "unexpected trpc error type")
})
}
test log v2
Failed
=== RUN TestQueryRecentExecs/large_count_100_cause_biz_error
caselog_test.go:80:
Error Trace: caselog_test.go:80
Error: Not equal:
expected: "business"
actual : "framework"
Diff:
--- Expected
+++ Actual
@@ -1 +1 @@
-business
+framework
Test: TestQueryRecentExecs/large_count_100_cause_biz_error
Messages: unexpected trpc error type
--- FAIL: TestQueryRecentExecs/large_count_100_cause_biz_error (0.81s)
v2 中很明确,trpc error type 与预期不符,预期是 business 而实际值为 framework. 这是一个接口测试,出现 Framework error 通常是被测服务没有正确部署或者测试流水线所在网络环境与被测服务不通。(用例中使用 testify 断言,其断言格式固定,可能不一定是最好的格式,略显啰嗦,但是它在其他方面比较方便,为了整个项目统一我们只能“因地制宜”。更简洁的断言信息是
t.Errorf("QueryCaseRecentExecs response error type mismatch got = %s, want = %s", trpcErrTypeName(err), "business")
即使该用例不是我写的,只要具备基本的 trpc 背景知识,看到报告后第一反应就是去确认被测服务是否健康,以及流水线网络环境是否正确,而在 v1 中,我可能还得打开 ide 查看一下用例代码,看用例代码还只能知道不是 business 错误,实际是什么错误并不知道,还得跳转到 trpc 的源代码才知道 1 是 framework 错误。(还有另一种错误是 “callee framework”)。孰快孰慢一目了然。
1.功能代码有可读性要求,测试代码也有,同样,功能代码有可维护性要求,测试代码也有可维护性要求。可维护性最佳实践与功能代码是相通的,仅举几例:
DRY, 以提取函数/提取常量来替代复制粘贴。
2.以配置代替写死值,可以参考一些 go 的标准库里面单元测试的方法,可以在测试时指定 flag,在用例中 parseFlags 来读取配置。
3.不要滥用设计模式,测试代码复杂度不宜过高,否则我们是否还有给测试代码写测试代码?
参考阅读
《Unit Testing: Principles, Practices, and Patterns》
脚注
[1]覆盖率并不一定是越高越好,对于没有测试价值的,低风险的代码,强行覆盖会消耗过多的时间和精力。怎样定一个覆盖率红线是另一个话题,长话短说就是代码库的 owner 自行决定才是最科学的。
[2]那些单元测试已经充分测试过的逻辑,无需在接口测试和端到端测试中重复。
优测测试平台简介:
是一个为企业与开发者提供专业的测试工具和服务的平台,沉淀十年产品测试经验,提供终端测试、接口测试、性能测试、安全测试等多领域测试服务与产品,协助客户提高效率降低成本,保证产品质量。