阅读《有效的单元测试》关于单元测试所做的一些笔记

测试的价值

  • 帮助捕获错误
  • 帮助我们针对实际使用来塑造设计
  • 明确地指出所需的行为,避免过度镀金
  • 编写测试最大的价值不在于结果,而在于编写过程中的学习

注: TDD为测试驱动开发,BDD为行为驱动开发,是对TDD的进一步总结,将其提升至需求层面。

什么是优秀的测试

  • 测试代码的可读性和可维护性
  • 测试代码要有合理的结构,能准确地定位问题
  • 确保测试方法的命名和所测内容一致,便于定位问题
  • 测试要易于独立运行,通过传入测试替身避免环境等复杂的不确定因素影响
  • 测试要具备可靠性,确保测试结果是可靠的,不随机失败

注: 编写自动化测试的三个工具,测试框架、测试替身及构建工具

测试替身

测试替身的好处

  • 隔离被测代码
  • 加速执行测试
  • 使测试变得确定
  • 模拟特殊情况
  • 访问隐藏信息

测试替身的类型

  • 测试桩(stub):通常为短小的,方法内常为空或做简单处理,返回固定的处理结果,示例为日志接入远程服务器
  • 伪造对象(fake):能差异地实例化来返回不同值,没有副作用或使用真实事物的后果,示例为持久化对象(DAO)
  • 间谍对象(spy):除了能完成接口指定的方法外还能通过实现额外的方法,对一些结果进行记录,为之后的验证铺垫
  • 模拟对象(mock):反对惊喜,对特定场景可配置行为,配置期望的方法入参,返回,甚至调用次数,但不能配置模拟对象提供方法以外的行为

测试编码的一般过程

简单分为:准备 ——> 执行 ——> 断言

测试代码的可读性

  1. 避免抽象层次过低的基本断言,尽量在调用方法上展示代码的意图,少用一些如==!=等比较符善于更加语言化的assertThat()和Harmset匹配器工具也是技巧之一;
  2. 避免事无巨细的过度断言,不要过分地关注整体,一个测试应该只有一个失败地原因,编写单元测试应该能快速地把握到问题地精髓,测试点要有细粒度和专注性;
  3. 一个方法内尽量只有一个抽象层次,附加细节尽可能独立分开,夹具对象分离,在通过简单易懂的方法名命名,使测试代码层次清晰,一目了然;
  4. 抽象层次进一步上升到类层面,一个类内只测试一类情况,并善用虚类及继承等方法,优化类结构;
  5. 避免过度分块导致的逻辑分割,测试方法中逻辑和数据的内联及分割问题,处理的一般方法如下:假如数据较小则内敛,假如数据过长则将其藏到工厂方法或测试数据构建器背后,假如还是不便则放到单独的文件;
  6. 将魔法数字用常量或变量代替,提高代码可读性;
  7. 安装方法也应该像测试方法一样清晰,有层次感,更直观,因此需要将一些附加细节抽取成类内的私有方法;
  8. 避免过度保护产生冗余代码;

测试代码的可维护性

  1. 避免重复,其中包括数据重复、结构重复以及语义重复;
  2. 避免将断言放入条件语句中并使得断言被跳过,大量的条件语句也使得测试代码变得晦涩难懂;
  3. 避免构建出具有除被测代码外不确定因素的测试代码,此类脆弱的测试不确定性常与系统平台、多线程及随机对象的生成有关;
  4. 避免在测试代码中使用绝对路径,尽可能地将项目的资源到放到项目的根目录下,另外用流来代替文件也是不错的选择;
  5. 避免使用物理文件,假如需要指定文件到名称,需要注意删除幽灵文件。当不需要具体到具体名称时,可调用File#createTemFile方法创建临时文件;
  6. 避免迟缓测试,因为其往往也是脆弱的测试,特别是在测试代码涉及多线程同步等问题,善用java的同步对象,少用Thread.sleep(),因为其充满了不确定性;
  7. 避免参数化测试模式所带来的,可读性降低及匿名测试方法导致的调试难度上升,做适当封装及错误信息来规避;
  8. 避免低内聚所带的多个测试方法间使用的测试数据或夹具对象不清晰的问题,使用共有部分提取为基类及组合关系可提高内聚;

测试代码的可信赖性

  1. 避免在测试代码中出现方法内部代码被注释到的情况;
  2. 避免词不达意的歧义注释,注意好的代码注释解释我为什么,而非做了什么;
  3. 避免永不失败的测试,如将断言放入到catch代码块内;
  4. 避免轻率承诺的测试,如注释方式内代码、无任何断言或测试方法名或代码功能不完全对应;
  5. 避免降低期望的测试,如断言不够精准地实现测试方法期望;
  6. 避免有平台偏见的测试,即测试代码依赖于运行的操作系统,如文件的盘符;
  7. 避免有条件的测试,应该用断言代替条件;

测试的进阶

可测的设计

可测的设计应该是模块化设计的软件,即SOLID原则,以下是对应首字母的各规则:

  • 单一职责原则(Single Responsibility Principle, SRP): “类发生变化的原因应该只有一个”,即类应该小且专注,并具有高内聚。
  • 开闭原则(Open Close Principle, OCP): 类应该对扩展开放,而对修改关闭
  • 里氏替换原则(Liskov Substitution Priciple, LSP): 子类应该能替换掉父类,即良好的继承关系,而不是单单只是为了代码复用
  • 接口隔离原则(Interface Segregation Principle, ISP): 接口应该要小而专注
  • 依赖反转原则(Denpendency Inversion Principle, DIP) : 代码应该依赖于抽象,而非细节,类不应该自己实例化协作者

基于以上良好的设计原则规范下,以下是一些指导规则:

  1. 避免复杂的私有方法;
  2. 避免final方法;
  3. 避免static方法;
  4. 使用new时要当心,避免在方法内new需要替换为替身的类;
  5. 避免在构造函数内包含逻辑;
  6. 避免单例,单例建议在返回时和实体类间建一层接口用于抽象;
  7. 组合优于继承;
  8. 封装外部库,即在外部库与调用类间抽象一层接口;
  9. 避免服务查找,应该在构造函数内将对象与协作者联系到一起;

可编写测试的其他JVM语言

优势:

  • 语言简洁明了,提高了测试可读性和可依赖性
  • 丰富的API
  • 更多强大的语法结构

劣势:

  • 性能比不上静态语言

常用的用于编写测试的JVM语言为Groovy,形容为“剥了皮的java”,有括号书写可选,变量可用def关键字定义,return可选等优点。

BDD工具将TDD概念再往上抽象了一层,可以用于写测试的需求说明,常见工具有easyb和Spock Framework。

加速执行测试

加速测试速度前,应该利用构建工具(maven或ant)的插件了解构建过程中各阶段的用时,找出性能瓶颈。

以下时一些从编写测试代码提升构建速度的建议:

  1. 不在测试方法内使用线程休眠;
  2. 避免不良的继承关系,导致基类膨胀,子类执行一些不必要的setupteardown方法;
  3. 挑剔地添加新测试;
  4. 保持本地运行,保持快速,mock一些外部服务;
  5. 抵御访问数据库地诱惑,或者ORM下使用轻量的内存数据库mock真实的数据库;
  6. 避免在测试中访问文件系统的文件,不得已可以读取一次并缓存,部分日志功能也可以考虑设置文件关闭;

除了从编码上加速构建,更明显提速的是构建的基础设施的改善:

  1. 使用更多的CPU或更多核心的CPU或更多的服务器;
  2. 使用RAM替代文件I/O;
  3. 并行构建,最好推送到远程高性能主机上构建并将构建结果输出回本地;
  4. 分布式构建,但注意需要处理好分片测试到各主机的问题;

Junit使用的简单说明

Junit的测试生命周期

  1. 实例化测试类的一个实例;
  2. 调用测试类实例的setup方法;
  3. 调用测试类;
  4. 调用测试类实例的teardown方法;

注: 依次调用@BeforeClass(一个测试类只执行一次)、@Before@Test@After@AfterClass注解方法。

测试方法的一般形式

首先,需要在测试类上制定测试运行器,形式如@Runwith(SpringRunner.class),制定了一个实现了抽象Runner接口的运行器类SpringRunner。

测试方法是带有@Test注解的方法,为public属性且无入参和返回值,一般步骤为准备、执行最后断言。

执行断言,有Junit自带的基本断言,如assertEquals(Obejct object),用于判断两个对象相不相等,另外还可以用@Test(expected = Exception.class)指定测试方法期望的异常类,当需要有判定异常输出信息等需求也可以手动try...cactch...处理,但需要注意的是必须加上fail(String message)令无异常时测试失败;另外,也可以使用assertThat(Object object, BaseMatcher matcher)使用Hamcrest实现的匹配器。

另外,你还可以使用Junit的内置规则,运行期会执行带@Rule注解的public字段,可以用于设置全局测试方法超时时间,设置预期异常,或者创建临时文件夹或临时文件,测试方法结束时即销毁。