Bug终结之路 --《有效的单元测试》读后感

最近一段时间,看了《有效的单元测试》(Effective Unit Testing)这本书。这是一本对单元测试从重要性到该如何编写和构建都进行了详细阐述的技术书籍,其作者Lasse Koskela,是一名资深敏捷技术实践专家、敏捷教练、培训师、顾问和程序员,具有数十年计算机程序设计和开发经验。该书虽没有如《测试驱动开发》这样的名气,但作者在书中总结的一套理论和实践框架,大多都非常实用,能结合到我们日常开发过程中。
单元测试有什么好处,我想对于有一定开发经验的程序员来说,都能说上一通,但应该怎么做才能高效的完成单元测试却并不是所有开发者能随便总结的出来。本文就书中提出的方法论中能结合到我司的部分做一些简单的总结。

一、好的单元测试标准

任何需要衡量的事物,都需要有一套标准,引用书中对于“好的单元测试”标准如下:

  1. 可读性,与写功能代码一样,写测试代码,包括其注释,都需要遵守“容易让人理解”这一“基本”要求(虽然这一要求实际还是很有难度的)。可读性对于单元测试来说,还更赋予了“可维护性”的效果,因为通常单元测试很多时候不需要深入到底层,所以往往可以写得较为简洁,此时可读性和可维护性就成正比了。
  2. 结构化,能有效帮助表达测试内容,即增强可读性。书中结构化的阐述实际也是告诉我们在编写单元测试时,应该运用所有软件设计中的知识和技能,从设计模式甚至到微服务架构,只要是正确的设计手段就应该使用。
  3. 单元测试的名称须名副其实,不应使用错误命名。这个也比较好理解,当测试用例很多,遇到没跑通的测试用例时,如果单元测试名称起的好,一眼就能定位到程序的问题所在,而无需一步步debug,大大节省了调试时间。
  4. 单元测试应能独立执行,即其执行不依赖于其他单元测试的结果,这样能更易于调试和找出程序问题。
  5. 可靠性,有用且可靠的单元测试才是真的可靠。首先,单元测试必须是对关键部分进行的测试,而不是为了通过测试而测试。如下图,作者总结就是“从不失败或经常失败”的单元测试都几乎没有价值。其中"从不失败”的测试被称为”Happy tests”,比如 “1+1=2”这种常识都知道必然正确的情况就没必要测试;而“经常失败”的测试则被称为“Random tests”,是指那些测试结果会随着每次执行的环境或其他条件而变化的测试。
    在这里插入图片描述
  6. 单元测试应使用主流的标准工具,比如对于Java语言,一般都是使用Junit套件。这样除了方便写单元测试,也方便复用与交流。

二、测试替身(Test Double)

编写单元测试,最常用的方法就是引入测试替身(Test Double)。Test Double 没有找到权威的中文翻译,暂且以我自己的理解来说明一下。实际就是为了使测试更具有独立性,通过Mock之类的“替身”代替被测试对象中,需要关联的其他对象。这块相信对使用过Mockito / PowerMock 之类工具包的同学都不会陌生。

  1. 使用测试替身,会有许多的好处:

    • 可以隔离具有相互关联的测试对象,使单元测试更独立。
    • 可以加速单元测试的执行,因其不用理会关联对象的初始化等繁琐操作。
    • 可以使被测试对象执行行为更确定。
    • 可以方便模拟特定场景的测试用例。
    • 可以使被测试对象的内部隐藏信息更公开化,即通过自定义“替身”注入到被测试对象中,从而通过该替身获取被测试对象作用于其上的行为结果,以达到测试对象行为的目的。
  2. 测试替身的类型主要分为:

    • Test Stub: 测试存根,其目的是通过尽可能简单的实现来支持真正的实现,其所有方法都是单行的,并返回适当的默认值。其返回的可以是硬编码值,也可以实例化自身变体以返回不同的值以模拟不同的场景。
    • Fake Object: 伪造对象,与测试存根的区别在于,其更像是真实对象的经过优化后的精简版本,其可以复制真实对象的行为,但没有使用该真实对象的副作用和其他后果。
    • Test Spy: 测试间谍,我的理解是包括了拦截、植入、窃取等行为的综合,是让被测试对象在无感知的情况下按照其自身原有行为执行,而间谍对象却可记录下被测试对象的行为内容,必要时还能更换拦截点的内容,以改变其行为。与前两个不同之处在于,间谍代码不需要我们实现,一般是测试工具包自动生成,这就有利于我们快速完成测试用例的开发。
    • Mock Object: 模拟对象,是一种特殊的测试间谍。其配置为在特定情况下以特定方式运行,通过该对象通常能准确控制被测试对象的行为方向,从而验证其行为是否符合预期。与Test Spy一样,对象的代码无需我们编写,我们仅需要设定的特殊值,所以通常使用这种方式能高效的实现测试用例,且因为其轻量级的特点通常还能获得更高的执行效率。
  3. 使用测试替身的准则:

    • 根据每个单元测试的特点,选择正确的替身类型。
    • 每个单元测试,按照 arrange,act,assert 这几块进行编写。其中arrange可以认为是准备(安排)阶段,即对于测试内容的预先设置或初始化。act代表执行阶段,即调用被测试对象的主要方法。assert(断言)就是最后的验证阶段,检验被测试对象执行后得到的结果是否于预期的结果一致。
    • 对单元测试检验其行为和结果而不应检验其实现细节。其实质是指对一个测试应仅测试一件事,所以必须要有重点,而不应被其复杂的内部细节左右,变成为了测试诸多细节而导致整个单元测试变得复杂难控。
    • 选好测试工具,这个就无需赘述,常用的一般是Mockito、PowerMock、EasyMock 、JMock等。
    • 善于使用注入依赖。为不破坏被测试对象的结构(假设该对象具有较好的实现结构),应使用注入依赖方式,这对于熟悉Mockito等工具的同学来说不是什么难事。

三、系统的可测试性设计

对单元测试有了衡量标准,以及实现的方法,为了能实现高效的单元测试开发,我们还需要在被测试系统的设计阶段,就要把可测试性考虑进来。那什么是“可测试性设计”?很多有经验的前辈都能给出一些答案:

  • 对程序员而言,对给定的代码段,在单元测试中设置方案应该是轻而易举的,且代码实现也应该能容易而快速完成。
  • 可测试性不仅是描述软件是否可测的词语,而是要实现整个软件都易于测试。

书中阐述了许多可测试性设计的原则及方法:

  1. 首先是模块化设计,这个原则一般大家都比较熟悉,其本质就是使系统由独立模块组成,每个模块在设计中都有特定用途,且互相独立,依赖关系少能有效简化测试的复杂性。
  2. SOLID设计原则,设计模式中强调的原则,无需赘述。
  3. 上下文中的模块化设计,即不仅确保系统设计为由模块化部件组成,还需进一步意识到该系统(无论看起来有多大多美妙)都应该始终被设计为另一个更大系统的一部分。
  4. 面向模块化设计的测试驱动,即使提倡用TDD方式进行开发。

通常编写测试的程序员会遇到问题如:

  • 不能实例化对象
  • 不能调用方法
  • 不能获取到结果
  • 无法替代合作者
  • 无法覆盖方法

归纳起来就是这两类:“受限访问”和“无法替代实现的某些部分”。针对以上,书中给出了“可测试设计”的准则:

  1. 避免使用复杂的私有方法。
  2. 避免使用带final关键字的方法。
  3. 避免使用静态方法(针对你想要使用Test Stub方式的那些方法)。
  4. 谨慎使用 new关键字,在方法中实例化对象时,应问自己:要在测试中交换掉该对象码?如果它是起到协作的作用,且你可能希望逐个测试地修改其实现,则应以某种方式将其传递给方法,而不是从该方法内部实例化它。
  5. 避免在构造方法中实现逻辑,如果构造函数包括“需被测试的内容”,应该将这些内容搬离构造函数,以便通过覆盖方式进行测试。
  6. 避免单例。因单例会使得测试用例编写比较困难,且单例容易造成一些隐形的错误。
  7. 偏向于使用“组合”而非“继承”编写类代码。使用继承应以用于多态为目的,如以重用为目的,则应该使用“组合”方式进行(即在对象中引用来实现原对象的功能,而不是通过继承来实现)。
  8. 包装外部库,即使用第三方库(或自己的其他jar包方法)时,应再包一层以便替换和测试。
  9. 避免服务查找,采用构造函数传入方式代替构造函数内部自动查找“服务方法实例”。因这种方式难于使用前面提到的单元测试工具方法来进行用例的编写。对此可能有同学会提出疑问,如采用Spring框架是不是会有违反这一准则的嫌疑?实际不算,Spring框架的注入仍是遵循由框架本身从对象外部自动进行注入,而不是让对象本身进行构建。

小结

在看完这本对单元测试系统阐述的书后,最大的感悟就是,对之前很多关于单元测试含糊不清的概念与方法都有了明确的答案,并且还有一套可以遵循的准则。在对照我们系统后,发现有不少是使用到的地方,但也有许多需要改进的地方。至于如何使用书中准则进行实操,将会放在该系列下一篇文章中。

作者:侯嘉逊

猜你喜欢

转载自blog.csdn.net/vipshop_fin_dev/article/details/106464983