公司最大的内卷,偷偷做单元测试
一位读者在看过我的《理解这八大优势,才算精通单元测试》后,问我:知道单元测试有好处,但实在没空写。看完文章后又想重新落实一下,有没有啥写好单元测试的技巧?
这位读者绝对不是第一个和我抱怨单元测试的人。这很好理解,中国互联网公司太多太卷,想要抢夺市场就要推出不同功能,而这些压力一部分落在了程序员身上,拼命赶需求。单元测试这种费力不讨好的事情,自然而然就没有人做。
就我多年的经验来看,写单元测试其实不会拖延项目,反而能够加快功能研发进度。单元测试的好处我就不在这里赘述了,只有真正尝试过的人才能理解。
马克·吐温曾说:“取得成功的秘诀就是开始。”本篇文章想和大家分享一下写好单元测试的技巧,希望可以给大家带来新方向。
一、单元测试的注意事项
单元测试是为了让我们快速查找并隔离损坏的代码片段。正因如此,这些函数和类在测试时不应该依赖于mock(模拟)和stub(存根)以外的其他元素。在测试中,如果试图覆盖的逻辑过于复杂,就难以确保覆盖的可靠性,也难以准确找出失败的原因。
因此,我们要注意单元测试包括以下几点。
01 简洁性
短函数更容易阅读和理解。我们每次只测试一个逻辑点,因此测试代码应该控制在几行之内。但如果逻辑可能具有多个依赖项,这就需要大量样板代码来初始化模拟和存根。此外,单元测试同样适用DRY原则(Don't repeat yourself,一次且仅一次),我们在写单元测试时要避免到处复制粘贴混乱的代码,最好使用组合而不是继承。爱因斯坦曾说:“当你在生活中感到困顿时,也许是你把事情复杂化了。”所以,当我们对单元测试产生困惑时,也许是因为我们在单元测试中使用复杂的逻辑。注意一点:单元测试的目的在于测试代码,不要让单元测试本身也成为测试的一部分。
02 明确性
单元测试要使用详尽的长名称。这样的名称不仅能清楚表达信息,还能起到索引作用、快速定位相应测试。就算需求发生变化,我们只需要针对相应的测试进行更改,不必查看所有内容并检查受影响的内容。
好的单元测试一般只有一个断言,因此命名起来也很容易。例如,在处理金额计算时,It('should return 0 for an empty cart')
要比It('works for 0')
或者It('empty cart')
好得多。对于使用函数名称作为测试名称的框架也是如此,shouldReturnZeroForAnEmptyCart
就是一个很不错的的命名。
正如丁玲所言:“人生就像爬坡,要一步一步来。”单元测试也是如此,不要一次性测试整个方法,要一步一步来。我们只针对单个需求写单元测试,代码就会变得易于阅读和维护。
03 可维护性
测试框架需要提供各种断言方法。它们提供不同的方法来检查结果,并且当断言失败时,它们还会显示更具体的错误消息,从而提供更多上下文来查看错误所在。
例如,
expect(result === expected).toBeTruthy();
将会失败
expect(received).toBeTruthy() Received: false
尽管
expect(result).toBe(expected);
将提供更多有关具体失败原因的信息:
expect(received).toBe(expected) // Object.is equality Expected: "John Doe" Received: "JohnDoe"
框架还为不同的测试方式提供了各种断言。例如,在使用Jest进行测试时,toBe
使用Object.is
测试是否完全相等,而toEqual
和toStrictEqual
则深入比较对象,确保他们的类型和结构一致。
为了判断浮点数是否相等,我们需要采用一种特殊的匹配器,这种匹配器能够忽略由于浮点数在内存中的表示方式导致的微小舍入误差。在Jest中,匹配器是toBeCloseTo
。虽然toEqual
有时也能适用,但即使是看似简单的测试,如expect(0.1+0.2).toEqual(0.3)
也可能无法通过。
二、单元测试的AAA原则
遵循AAA原则(Arrange、Act、Assert,安排、执行、断言),可以娴熟提升单元测试代码的清晰度、可靠性和可维护性。
第一步,安排阶段(Arrange)。我们需要完成变量赋值、对象实例化对象以及测试运行所需的其余前置设置,并且定义预期结果。这样做的好处在于:一方面,我们需要在执行测试逻辑前就有明确预期;另一方面,这更方便在输入数据后立即查看预期输出,有助于避免代码混淆。
第二步,执行阶段(Act)。我们将执行测试函数并存储其结果。结果存储其实是准备工作的自然延伸,有助于我们对结果进行回顾总结。
第三步,断言阶段(Assert)。我们在这个阶段可以判断假设的正确性了。这正是单元测试的核心所在,因为这一环节实际上是对某些具体内容的测试。其目的在于是检查实际得到的结果否与预期结果相匹配。
我们要确保代码可靠性,避免错误输入、缺少参数、空数据、调用函数中的异常等情况的出现。代码覆盖率工具可以帮助我们查漏补缺,找到未测试的代码分支。我们要始终明确我们单元测试的目标,过于追求100%测试覆盖率反而会让单元测试代码越来越繁杂。这与《吕氏春秋》中的论点不谋而合:“不知轻重,则重者为轻,轻者为重矣。若此,则每动无不败”。
三、单元测试的优化和维护
为了提高单元测试效率,我们需要模拟所有可能影响速度的外部依赖项,例如API调用、数据库或文件系统访问。我们在写单元测试时,应尽量避免线程休眠、等待和超时。如果必须设置超时,就应该将其缩短至几毫秒。在处理多线程或异步竞争条件时,精确控制出发条件比简单的等待要有效得多。
单元测试应当确保不会改变作用域外的任何内容。如果测试仅在按照特定顺序执行时才能成功,这可能表明测试用例或测试代码存在问题。每个测试用例应独立运作。由于现代测试框架默认并行执行测试,因此我们不应依赖全局变量或之前测试的遗留效应。这也是全局变量常被视为不良编程习惯的原因之一,这会隐藏真正的依赖关系,导致代码耦合度升高,并在处理多线程问题时需要格外留意。
当测试需要复杂的重复配置时,应利用框架提供的设置和清理功能。这些功能保障了在每个测试用例或整个测试套件开始前后,相关代码能够得到执行。这样,无论是单独运行测试还是作为测试套件的一部分,都能确保测试结果的确定性,执行顺序不会对测试结果造成影响。
四、单元测试贵在坚持
《荀子·大略》:“夫尽小者大,积微成著,德至者色泽洽,行尽而声问远。”单元测试的作用只有经过长期积累才会变得显著。其实,写单元测试更多的是对自己的代码负责。有测试用例的代码,别人更容易看懂,以后别人接手你的代码时,也可能放心做改动。
根据上述方法开始行动,单元测试也不是什么难事,毕竟“世上无难事,只怕有心人”。我发现关于单元测试有很多读者感兴趣,还有人曾问我单元测试到底该由测试进行还是开发进行。如果大家感兴趣,我也可以写一篇文章和大家简单分享一下。
*参考文章:Andriy Obrizan,How to Write Good Unit Tests: 14 Tips