总结
测试程序的价值:
- 编写优良的测试程序,可以极大提高我的编程速度。因为,编程花费在调试上的时间是最多的,一套测试就是一个强大的 bug 侦测器,能够大大缩减查找 bug 所需的时间。
测试的基本步骤:
- 测试包含了两个步骤。第一步设置好一些测试夹具(fixture),也就是测试所需要的数据和对象等(就本例而言是一个加载好了的行省对象);第二步则是验证测试夹具是否具备某些特征(就本例而言则是验证算出的缺额应该是期望的值)。
**好的测试程序的特征:**自动化+确定性+精简
- 确保所有测试都完全自动化,让它们检查自己的测试结果,频繁地运行它们。 只要写好一点功能,就立即添加对应的测试。
- 保持测试的确定性,测试结果的这种不确定性,往往使你陷入漫长而又艰难的调试,使用共享夹具时需要小心。(测试夹具移除阶段也需要注意)
- 测试应该是一种风险驱动的行为,而不是追求全面。如果尝试撰写过多测试,结果往往反而导致测试不充分。
- 测试写得太多的一个征兆是,相比要改的代码,我在改动测试上花费了更多的时间——并且我能感到测试就在拖慢我。
好的测试代码编写习惯:
- 撰写测试代码的最好时机是在开始动手编码之前。****编写测试代码还能帮我把注意力集中于接口而非实现(这永远是一件好事)。预先写好的测试代码也为我的工作安上一个明确的结束标志:一旦测试代码正常运行,工作就可以结束了。
- 测试驱动开发(TDD)的编程方式依赖于下面这个短循环:先编写一个(失败的)测试,编写代码使测试通过,然后进行重构以保证代码整洁。
- 我总会担心测试没有按我期望的方式检查结果,我想看到每个测试都至少失败一遍。我最爱的方式莫过于在代码中暂时引入一个错误。
- 我为既有代码添加测试时最常用的方法:先随便填写一个期望值,再用程序产生的真实值来替换它,然后引入一个错误,最后恢复错误。
- 考虑可能出错的边界条件,把测试火力集中在那儿。(集合为空,数值为0,负值等等),积极思考如何破坏代码。我发现这种思维能够提高生产力。
- 如果测试的错误已经超越可观测的范畴,删掉这条测试。
- 任何测试都不能证明一个程序没有 bug。 当测试数量达到一定程度之后,继续增加测试带来的边际效用会递减。不要因为测试无法捕捉所有的 bug 就不写测试,因为测试的确可以捕捉到大多数 bug。
- 一个值得养成的好习惯是,每当你遇见一个bug,先写一个测试来清楚地复现它。仅当测试通过时,才视为 bug 修完。只要测试存在一天,我就知道这个错误永远不会再复现。
自测试代码的价值
- 我发现,编写优良的测试程序,可以极大提高我的编程速度,即使不进行重构也一样如此。这让我很吃惊,也违反许多程序员的直觉,所以我有必要解释一下这个现象。
- 如果你认真观察大多数程序员如何分配他们的时间,就会发现,他们编写代码的时间仅占所有时间中很少的一部分。有些时间用来决定下一步干什么,有些时间花在设计上,但是,花费在调试上的时间是最多的。
- 之所以能够拥有如此强大的 bug 侦测能力,不仅仅是因为我的代码能够自测试,也得益于我频繁地运行它们。
- 确保所有测试都完全自动化,让它们检查自己的测试结果。
- 注意到这一点后,我对测试的积极性更高了。我不再等待每次迭代结尾时再增加测试,而是只要写好一点功能,就立即添加它们。
- 一套测试就是一个强大的 bug 侦测器,能够大大缩减查找 bug 所需的时间。
- 如果测试需要手动运行,那的确是令人烦闷。但是,如果测试可以自动运行,编写测试代码就会真的很有趣。
- 事实上,**撰写测试代码的最好时机是在开始动手编码之前。**当我需要添加特性时,我会先编写相应的测试代码。听起来离经叛道,其实不然。编写测试代码其实就是在问自己:为了添加这个功能,我需要实现些什 么?编写测试代码还能帮我把注意力集中于接口而非实现(这永远是一件好事)。预先写好的测试代码也为我的工作安上一个明确的结束标志:一旦测试代码正常运行,工作就可以结束了。
- Kent Beck 将这种先写测试的习惯提炼成一门技艺,叫测试驱动开发(Test-Driven Development,TDD)[mf-tdd]。测试驱动开发的编程方式依赖于下面这个短循环:**先编写一个(失败的)测试,编写代码使测试通过,然后进行重构以保证代码整洁。**这个“测试、编码、重构”的循环应该在每个小时内都完成很多次。这种良好的节奏感可使编程工作以更加高效、有条不紊的方式开展。我就不在这里再做更深入的介绍,但我自己确实经常使用,也非常建议你试一试。
- 开发软件的时候,我一边写代码,一边写测试。但有时我也需要重构一些没有测试的代码。在重构之前,我得先改造这些代码,使其能够自测试才行。
测试代码编写实践
- 开始测试这份代码前,我需要一个测试框架。 测试框架很多的,可以选择使用度和声誉还不错的。
- 测试包含了两个步骤。第一步设置好一些测试夹具(fixture),也就是测试所需要的数据和对象等(就本例而言是一个加载好了的行省对象);第二步则是验证测试夹具是否具备某些特征(就本例而言则是验证算出的缺额应该是期望的值)。
- 总是确保测试不该通过时真的会失败:当我为类似的既有代码编写测试时,发现一切正常工作固然是好,但我天然持怀疑精神。特别是有很多测试在运行时,我总会担心测试没有按我期望的方式检查结果,从而没法在实际出错的时候抓到 bug。因此编写测试时,我想看到每个测试都至少失败一遍。我最爱的方式莫过于在代码中暂时引入一个错误。
- 框架会报告哪个测试失败了,并给出失败的根本原因——这里是因为实际算出的值与期望的值不相符。于是我总算见到有什么东西失败了,并且还能马上看到是哪个测试失败,获得一些出错的线索(这个例子中,我还能确认这就是我引入的那个错误)。
- **频繁地运行测试。**对于你正在处理的代码,与其对应的测试至少每隔几分钟就要运行一次,每天至少运行一次所有的测试。
- 我可能会讲“看到红条时永远不许进行重构”,意思是:测试集合中还有失败的测试时就不应该先去重构。有时我也会讲“回退到绿条”,表示你应该撤销最近一次更改,将测试恢复到上一次全部通过的状态(通常是切回到版本控制的最近一次提交点)。
- 现在,我将继续添加更多测试。我遵循的风格是:**观察被测试类应该做的所有事情,然后对这个类的每个行为进行测试,包括各种可能使它发生异常的边界条件。**这不同于某些程序员提倡的“测试所有 public 函数”的风格。记住,测试应该是一种风险驱动的行为,我测试的目标是希望找出现在或未来可能出现的 bug。所以我不会去测试那些仅仅读或写一个字段的访问函数,因为它们太简单了,不太可能出错。
- 这一点很重要,因为**如果尝试撰写过多测试,结果往往反而导致测试不充分。**事实上,即使我只做一点点测试,也从中获益良多。测试的重点应该是那些我最担心出错的部分,这样就能从测试工作中得到最大利益。
- 编写未臻完善的测试并经常运行,好过对完美测试的无尽等待。
- 这是最终写出来的测试,但我是怎么写出它来的呢?首先我随便给测试的期望值写了一个数,然后运行测试,将程序产生的实际值(230)填回去。
- 这个模式是我为既有代码添加测试时最常用的方法:先随便填写一个期望值,再用程序产生的真实值来替换它,然后引入一个错误,最后恢复错误。
- **保持测试的确定性:**但共享测试夹具会使测试间产生交互,这是滋生 bug 的温床——还是你写测试时能遇见的最恶心的 bug 之一。 如果未来有一个测试改变了这个共享对象,测试就可能时不时失败,因为测试之间会通过共享夹具产生交互,而测试的结果就会受测试运行次序的影响。测试结果的这种不确定性,往往使你陷入漫长而又艰难的调试,严重时甚至可能令你对测试体系的信心产生动摇。
- 测试夹具移除阶段也需要注意:(其实还有第四个阶段,只是不那么明显,一般很少提及,那就是拆除阶段。此阶段可将测试夹具移除,以确保不同测试之间不会产生交互。因为我是在 beforeEach 中配置好数据的,所以测试框架会默认在不同的测试间将我的测试夹具移除,相当于我自动享受了拆除阶段带来的便利。多数测试文献的作者对拆除阶段一笔带过,这可以理解,因为多数时候我们可以忽略它。但有时因为创建缓慢等原因,我们会在不同的测试间共享测试夹具,此时,显式地声明一个拆除操作就是很重要的。)
- 作为一个基本规则,一个 it 语句中最好只有一个验证语句,否则测试可能在进行第一个验证时就失败,这通常会掩盖一些重要的错误信息,不利于你了解测试失败的原因。
探测边界条件
- 考虑可能出错的边界条件,把测试火力集中在那儿。
- 到目前为止我的测试都聚焦于正常的行为上,这通常也被称为“正常路径”(happy path),它指的是一切工作正常、用户使用方式也最符合规范的那种场景。同时,把测试推到这些条件的边界处也是不错的实践,这可以检查操作出错时软件的表现。
- 无论何时,当我拿到一个集合(比如说此例中的生产商集合)时,我总想看看集合为空时会发生什么。
- 如果拿到的是数值类型,0 会是不错的边界条件:
- 负值同样值得一试:
- 我积极思考如何破坏代码。我发现这种思维能够提高生产力,并且很有趣——它纵容了我内心中比较促狭的那一部分。
- 失败”指的是在验证阶段中,实际值与验证语句提供的期望值不相等;而这里的“错误”则是另一码事,它是在更早的阶段前抛出的异常(这里是在配置阶段)。
- 如果这样的测试是在重构前写出的,那么我很可能还会删掉它。重构应该保证可观测的行为不发生改变,而类似的错误已经超越可观测的范畴。删掉这条测试,我就不用担心重构过程改变了代码对这个边界条件的处理方式。
- 如果这个错误会导致脏数据在应用中到处传递,或是产生一些很难调试的失败,我可能会用引入断言(302)手法,使代码不满足预设条件时快速失败。我不会为这样的失败断言添加测试,它们本身就是一种测试的形式。
- 什么时候应该停下来?我相信这样的话你已经听过很多次:“任何测试都不能证明一个程序没有 bug。”
- 我曾经见过好几种测试规则建议,其目的都是保证你能够测试所有情况的一切组合。这些东西值得一看,但是别让它们影响你。 当测试数量达到一定程度之后,继续增加测试带来的边际效用会递减;如果试图编写太多测试,你也可能因为工作量太大而气馁,最后什么都写不成。
- 不要因为测试无法捕捉所有的 bug 就不写测试,因为测试的确可以捕捉到大多数 bug。
结语
- 如今一个架构的好坏,很大程度要取决于它的可测试性,这是一个好的行业趋势。
- 这里我展示的测试都属于单元测试,它们负责测试一块小的代码单元,运行足够快速。它们是自测试代码的支柱,是一个系统中占绝大多数的测试类型。同时也有其他种类的测试存在,有的专注于组件之间的集成,有的会检验软件跨越几个层级的运行结果,有的用于查找性能问题,不一而足。(
- 我发觉我持续地在测试集上工作,就与我在主代码库上的工作一样多。很自然,这意味着我在增加新特性时也要同时添加测试,有时还需要回顾已有的测试:它们足够清晰吗?我需要重构它们,以帮助我更好地理解吗?我拥有的测试是有价值的吗?
- **一个值得养成的好习惯是,每当你遇见一个bug,先写一个测试来清楚地复现它。仅当测试通过时,才视为 bug 修完。只要测试存在一天,我就知道这个错误永远不会再复现。**这个 bug 和对应的测试也会提醒我思考:测试集里是否还有这样不被阳光照耀到的犄角旮旯?
- 一个测试集是否足够好,最好的衡量标准其实是主观的,请你试问自己:如果有人在代码里引入了一个缺陷,你有多大的自信它能被测试集揪出来?这种信心难以被定量分析,盲目自信不应该被计算在内,但自测试代码的全部目标,就是要帮你获得此种信心。如果我重构完代码,看见全部变绿的测试就可以十分自信没有引入额外的 bug,这样,我就可以高兴地说,我已经有了一套足够好的测试。
- 测试写得太多的一个征兆是,相比要改的代码,我在改动测试上花费了更多的时间——并且我能感到测试就在拖慢我。