我已经看到很多文章都在说单元测试是垃圾,认为它们只会拖慢开发速度,开发人员要花 90% 的时间来debug测试用例而不是修复bug,等等。
我从没想过这会成为一个热门话题,但我坚信测试对于快速、安全、大规模地构建优秀代码至关重要。我曾以为这是共识,但显然不是。
通常,测试被诟病是因为人们不理解如何编写测试用例。我们花了那么多时间思考、阅读、迭代设计模式、面向对象编程、组合优于继承、不同的架构等等。但是,当我们看着我们的测试套件时,却发现它们实际上是我们编写过的最差的代码,无论是在性能、可读性、简洁性方面,还是在编写生产代码时我们试图避免的所有方面。
难怪人们会抱怨写得不好的代码性能很差。唯一的问题是,根本原因并不在于测试这个概念,而在于测试用例写得不好。
如果你也在抱怨,我很抱歉地告诉你,问题不在测试,而在你自己。所以,让我们试着解决它!
免责声明:本文面向 TS/JS 开发人员,但其中的思想可以轻松应用于任何其他语言。如果你想得到比本文更好的指导,我强烈建议你看看文末的参考文献。我把这篇文章分为三个部分,因为其中任何一个部分的知识差距都可能导致你不愿继续读下去,即使其他两个部分都很扎实。
第一部分是基础知识和理论,第二部分是实际示例,第三部分是一些在Jest中快速调试的技巧。
基础与理论
唯一有用的测试是当你的应用程序无法运行时将失败的测试。
是的,这很基础,但在指南的开头提到很值得。如果你的测试无法断言某种不良行为会被避免,那么编写这样的测试就没有意义。
测试就像一门科学。它们不能证明你的软件有效,只能证明它无效。
同样,测试只能确保没有发生bug。编写一个能确保完美行为的测试是非常困难的,大多数情况下是不可能的。你可以编写使用一组特定输入的一些正向case,但通常情况下,你不会也不应该测试每一个可能的输入。
这就意味着,你不需要过度复杂测试,也不需要做大量的断言,只要遵循Pareto定律(也称二八法则),就能找到平衡点。
编写代码和获得反馈之间的距离越小,开发就变得越可预测。
这就是测试如此重要的根本原因。如果我需要等到测试人员/质量保证人员进行彻底的(手工)验证,那么很难预测我什么时候能完成任务。
另一方面,如果我在流程的早期就编写了一组测试,确保我正在构建的代码遵循某些行为规范,那么我就可以轻松地知道何时能实际完成编码。
什么是行为
行为是指系统在特定状态下对一系列输入做出响应的任何保证。
项目的生命周期越长,测试就越重要。
如果你只是在为黑客马拉松编写快速代码,那很好。成本与回报并不成比例。但如果你的测试将在数年甚至数十年内运行,那就意味着代码将以某种方式运行数年。
项目周期越长,你所写的每一个测试用例的价值就越大。
确定性
当输入相同时,代码总是产生相同的输出,这就是确定性。
在我们可能的范围内,测试必须是确定的。正如 Fowler 曾经说过的:非确定性测试毫无用处。
封闭测试
简单地说,每个测试都应该是独立且自包含的。我的意思是,测试不依赖于非确定性组件,如网络延迟或测试环境之外的交互。如果你依赖的服务器位于大洋的另一端,那么即使其他代码都完美无缺,连接中的故障也会破坏你的测试。
虽然大型测试无法避免,但单元测试应保证封闭测试。
测试是一种反馈机制
它们不是技术要求。它们在一定程度上保证了你的代码运行正确。在开发工作流程中,测试运行得越频繁,效果越好。
测试即文档
如果你以清晰的方式编写测试,那么它们在检查时就会一目了然。这意味着,无论谁读了你的测试,都会对你的公共 API 有足够的了解。这可能是你的团队、公司中的其他 POD,也可能是正在查看你的公共 API 的外部开发人员。如果有清晰的测试,每个读者都会受益匪浅。
其他必要的定义
- Spy:观察行为,但不以任何方式与之交互
- Stub:观察行为,并以某种方式改变数据或实现
- Mock: 带有期望的存根
- Fake:它是一个实现第三方接口的类,但有自己的实现
使用mock
来指代其他术语可能会引起混淆。这就是为什么一些公司和开发者更喜欢test doubles
这个术语。
动手实践
严格断言的好处
当应用程序代码出现问题时,编写更严格的断言会使测试更难通过,从而更容易捕获错误。换句话说
expect(typeof result).toBe("number"); // 松耦合,有大量的意外结果是该测试无法捕获的
expect(result).ToBe(2); // 现在只接受一个值,更严格,更利于捕获bug
如果 result 是两个数字的和,输入是 a= 1,b=1,你认为哪一个测试能更好地检测函数是否正常运行?
尽可能避免编写否定断言
让我们用一个小例子来说明这一点:
const result = await service.getCreatedUser(id);
expect(result).not.ToBeEmpty();
// 与
expect(result).toEqual({id:1, user: 'test_user'});
第一个 expect 只涵盖了 getCreatedUser
方法返回某些内容的事实,而第二个断言则验证了它返回的正是你所期望的用户。
另外,我们的大脑更难处理否定语句 。因此,避免使用 not
有助于提高清晰度。
尽可能编写更清晰的断言
比方说,在当前的票据中, 你正在进行一个包含日期的测试。是使用布尔比较好,还是实际的日期比较好?
请看来自 jest-extended
文档的代码片段:
test('passes when input is before date', () => {
expect(new Date('01/01/2018')).toBeBefore(new Date('01/01/2017'));
expect('01/01/2019').not.toBeBefore(new Date('01/01/2018'));
});
这将产生以下断言错误:
这个断言比expected true but got false
更酷、更清晰。
循环断言是大忌
重复/硬编码字符串比重复使用同一函数来验证预期与实际要好得多。让我们看一下以下代码片段:
export class Service {
public getFoo(id){
return helper(id);
}
}
// test file
describe('mytest', ()=>{
it('Should generate the appropriate result', ()=> {
const expected = helper(1);
const result = service.getFoo(1);
expect(result).toEqual(expected);
})
});
这个测试什么都没做。如果引入了bug,测试也不会失败,因为helper被用来产生预期结果和实际结果。
解决此问题的一种方法是编写自己的预期结果
export class Service {
public getFoo(id){
return helper(id);
}
}
// test file
describe('mytest', ()=>{
it('Should generate baz for odd numbers', ()=> {
const expected = "baz";
const result = service.getFoo(1);
expect(result).toEqual(expected);
})
});
如果mock依赖关系过于复杂,那就不要mock。
要么做集成测试,要么就不要编写测试。榨干果汁不值得。如果你需要设置 4-5 个spies、mocks、修改全局窗口,并且每次添加单个测试时都祈求上帝的怜悯,那么可维护性成本将超过你的单元测试。
如果你需要助手,那么Builders会非常棒,因为它们会在每个测试中明确说明Given/When。
让我们来看一个例子:
describe('myAdminService', ()=> {
it('Should reject the request if user is not an admin', ()=> {
const user = UserBuilder.newUser()
.addName('Alec')
.addPermissions(['employee']);
expect(service.withdrawAllTheMoney(user)).toThrow();
}
})
在这里,builder可以帮助你避免使用大量的模板代码,并轻松完成设置。很容易看出用户拥有“员工”权限。
调试技巧
这些是您可以在 Jest 中使用的一些简单技巧,可以让你只关注到那些未正常工作的测试。虽然这些技巧并不新颖,但我认为它们是测试中的 console.log
跳过不相关的测试
如果你有两个测试,但不确定其中一个是否会导致另一个失败,你可以在 jest 中简单地 skip
它们,而不是注释它
describe('Test Suite', ()=>{
it.skip('Should assert case 1', ()=>{
// 此测试不会运行
})
it('Should assert case 2', ()=>{
// 这个测试将运行
});
});
你可以将它们与 it
和 test
函数以及 describe
块一起使用。
调试时只运行重要的测试
如果你只想运行一个测试,并且暂时只关注该测试,你只需这样做即可:
describe('Test Suite', ()=>{
it('Should assert case 1', ()=>{
// 此测试不会运行
})
it.only('Should assert case 2', ()=>{
// 这个测试将运行
});
});
同上,适用于test
和describe
块。
使用Concurrent测试你的测试是否真正相互隔离
使用 concurrent
功能可以很好地了解你的 mock 是否相互干扰,或者你的测试是否很不稳定。
默认情况下,每个测试套件都会并行运行,但套件中的每个describe
块都会按顺序运行。让我们来看看它是如何工作的:
describe('Test Suite', ()=>{
it.concurrent('Should assert case 1', ()=>{
// this will run at the same time as case 2
})
it.concurrent('Should assert case 2', ()=>{
// this one will run at the same time as case 1
});
});
如果它们按顺序通过,但同时运行时却失败了,则可能是使用test doubles做了一些不稳定的设置。
- silent=false 会让你走得更远
很多组织默认关闭此标记。运行一个抛出 128391237 条日志的 Jenkins 流水线完全是在浪费资源,而且也不能说明什么问题。不过,你应该知道,如果你看不到任何 console.logs 文件,原因很可能是该标记被设置为true
。
你应该只测试自己编写的软件
这里有什么诀窍?如果你明确测试的是第三方,你可以选择:
- 删除测试。这不是你的责任。
- 如果你是个好公民,而且第三方是开源的,那就在他们的 repo 中写一份 PR。
这样你就不必维护它了,而且还能为社区做贡献。
参考文献
如果你想要一个很好的方法论来将测试集成到的开发工作流程中:
- Test Driven Development: By Example, Kent Beck. 点击此处获取
如果你想知道如何在 javascript 中进行出色的测试,本文的主要资料来源是:
- Testing Javascript Applications, Luca da Costa. 点击此处获取
如果你想了解测试的重要性以及如何处理大型测试,请查看第11-14章:
- Software Engineering at Google, Titus Winters, Tom Manshreck, Hyrum Wright et al. 点击此处获取
消除测试中的非确定性的重要性:
- Non determinism — Martin Fowler: 点击此处获取
如果你有其他建议,欢迎在评论中提出。祝你编码愉快!