单元测试的不同方式

对运行时间的测试,基于用例的测试,基于特性的测试

#对读者的知识要求&前言

#要求

#前言

在本文中我将介绍编写单元测试(和组件测试,实践中这两个概念拆不开)时可使用的三种调用-期望方式,用途各不相同:

  1. 对运行时间的测试

    • 特征:调用被测函数,并记录调用前后时间差值,计算运行时间。
    • 目的:测试运行时间表现。
  2. 基于用例的测试(example-based testing)

    • 特征:写明特定的桩(stub)的反应(mock::given…thenReturn…), 写明调用被测函数时的测试数据,期望(expect,assert)特定的输出。
    • 目的:确认模块对特定输入的输出符合预期的特定值 (包括对被测在运行中调用桩时传入的参数,和对调用本身的返回值两方面)。
  3. 基于特性的测试(property-based testing)

    • 特征:基于函数与调用者的契约(contract),生成随机的测试数据并反复调用被测函数, 期待被测因没有正确根据输入修正自己的行为,抛出被测逻辑中没有考虑到的异常。
    • 目的:测试被测函数是否满足契约。

有研究(没找到论文链接)显示,人脑对信息的记忆和理解不仅发生在接受时,还发生在接受后的一段时间。 我打算先介绍运行时间测试(很短很容易理解),再讲“基于用例的测试”(我们都熟悉的那种),最后试着让你理解一个新概念:“基于特性的测试”。

  • 这篇文章结构是头轻脚重,篇幅比例是2:3:5,运行时间测试这部分的存在非常突兀,但我不想把这部分剔除。
  • 标题包含“单元测试”就是想说明,下文介绍的内容都能在单元测试层面实现,并指导编码。
  • “基于特性的测试”在网上有诸多争议性观点,我试着对其中一些给出基于自己理解的解释。
  • 文中唯一的举例代码所用的编程语言为 Java,无需担心的是,不同语言到撰文止都已有各自的成熟的基于用例的和基于特性的测试库。

#1. 对运行时间的测试

解决方案是在《The Clean Coder(程序员的职业素养)》 中读到的。 书里的一段对测试用例的协商对话(7.2验收测试)无意间描述了这个方案:

  • 测试人员A:该函数运行时间不应超过两秒。
  • 测试人员B:无法为“不应超过两秒”编写测试,我们可以引入概率,即保证99.5%情况下符合。
  • 测试人员A: 如果要编写循环1000次测试,期望超时次数小于5,这个用例将耗时1小时,不现实。
  • 测试人员B: 可以执行15次操作,期望运行时间确切为2秒的 Z-score(标准分数) 大于2.57。

上述对话提到了两点:

  1. 需要用概率约束关于性能的需求,这种需求才可编写测试体现。
  2. 合理运用抽样统计思想可减少测试所需的重复运行次数。

#在勉强把它归为一类吗?

嗯,有自觉。在单元测试层面,因测试框架对程序的控制权有限,能测的所谓“性能”仅仅指运行时间。 这个方式本质算作第二种“基于用例的测试”,但它的期望目标比较特殊,所以单独提出来介绍印象会比较深。

虽然提起性能就是集成测试,需求中对性能要求的范围也往往是整个流程链路, 但这个确实能在单元测试层面实现。 比如每个高级些的语言都会有查看当前时间戳的库函数, 调用前后计算差值,就能得到与真实情况偏差不大的运行时间。 注意测试框架往往提供方式限制单个测试用例的运行时间, 那个的设计意图是防止单个用例影响整体测试运行, 不适合用来测运行时间,因为不能测量重复运行,单样本在统计学上不能验证假设。

在单元测试中测试运行时间有两个好处:

  1. 不需要等到集成测试时发现问题再打回来优化,fall fast 嘛,至少保证这部分没有拖后腿。
  2. 根据“单元测试指导编码”的原则,如果对模块的内部的函数有不同实现方案, 该函数运行负担较重且可测试,就可计算并利用运行时间作为方案取舍的参考。

#2. 基于用例的测试(example-based testing, EBT)

用例本该用来翻译 case 或 use case,这是术语, 但想了想还是把 example 翻译成用例合适,搜了下中文的博客也都用的这个翻译。 清楚与明确,就是 EBT 的优点:

  1. 对于计算类函数,可以做边界值测试。
  2. 对于功能类函数(后端 CRUD 工作),可以写个冒烟测试(冒烟是集成测试的术语,在此借其定义)检查代码能否正常工作。 或者输入异常测试数据、给桩 mock 异常反应,测试被测函数的异常处理流程。
  3. TDD 时需要故意写使测试失败的测试用例以启发编码,这是标准操作流程。
  4. 再比如你想逐步 debug,使用 EBT 可以让你清楚地知道程序走到每一步时应有的状态。

#运用设计模式简化构造复杂测试数据实例的过程

在 MVx 架构模式族中,中间模块的方法的参数通常是领域定义的 Model,Model 的某种集合,甚至主 Model 嵌套属性 Model。 关于如何简生成过程,有以下内容供参考:

#示范代码

下面示例代码使用 Java 语言及其下的测试框架与库: JunitHamcrestMockito 。 目的是测试 MVC 模型中一个 Service 的 getEntitiesBy()方法的正常逻辑(略去部分代码):

设定环境
假设 DB 中存在(对 Mapper 进行 mock)与输入 Request 内容对应的记录。
期望
1. 桩:该方法会使用输入的 Request 中的索引值"normal"调用 Mapper 查询数据库,获取对应记录包装成的 Entity Model。
2. 返回值:该方法应返回一个消息代码为"ok"的 Response 对象,其数据应为一个 Entity 对象的列表,首个元素的状态应与 Mapper 返回的对象相同。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// -------service.ServiceTest
// Given-When-Then - Behavior Driven Development (BDD)
@Test
public void withNormalResponse(){
    String index = "normal";
    // given, Mockito
    given(mapper.getEntitiesBy(index))
        .willReturn(buildDummyMapperReturn());
    // when
    Response actual=service.getEntitiesBy(buildRequest(index));
    // then, Hamcrest, 对调用的返回值的期望
    assertThat(actual.getMessage(),is("ok"));
    assertThat(actual.getData().get(0).getCategory(),is("c"));
    assertThat(actual.getData().get(0).getName(),is("n"));
    // Mockito, 对被测调用桩时传入的参数的期望
    ArgumentCaptor<String> mapperArgCaptor=ArgumentCaptor.forClass(String.class);
    verify(mapper,times(1)).getEntitiesBy(mapperArgCaptor.capture());
    String whatMapperGot=mapperArgCaptor.getValue();
    assertThat(whatMapperGot,is(index));
}

// 构造 Mapper 桩所返回的测试数据
private static List<Entity> buildDummyMapperReturn(){
    return Collections.singletonList(new Entity().setCategory("c").setName("n"));
}

// -------dto.Response
@Getter // Lombok
@Setter
@Accessors(chain = true)
public class Response {
    private String message;
    private List<Entity> data;

    public Response(String message) {
        this.message = message;
    }
}

// -------dao.entity.Entity
@Getter
@Setter
@Accessors(chain = true) 
public class Entity {
    private String name;
    private String category;
}

#3. 基于特性的测试(property-based testing, PBT)

#名词解释

实在不好意思,我得先让你读一些难懂的名词定义, 关键内容大多是从《程序员修炼之道(第2版)》 抄来的:

  • 例程(routine)

    • “例程是某个系统对外提供的功能接口或服务的集合。 比如操作系统的 API、服务等就是例程; Delphi 或C++Builder 提供的标准函数和库函数等也是例程。” –百度百科
  • 契约式设计(development based on contract, DBC, 或 contract-driven development)

    • 出自《面向对象软件构造(Object-Oriented Software Construction)》
    • 前置条件:例程期望自己运行前环境的状态符合它运行的需求。
    • 后置条件:例程保证自己退出时,环境的状态是什么样子。
    • 类的不变式
      • 从调用者的角度来看,类会确保不变式始终为真。
      • 在例程的内部处理期间,可以不遵守不变式,但退出时不变式需为真。
      • 因为 Eiffel 是 OO 语言,所以《OOSC》的作者将该概念命名为“类”的不变式, 实际上这个名词指的是例程所依靠的模块的内部状态,在 OO 语言里,就是类实例的状态。 《修炼之道》作者指出,在函数式语言中,状态是在函数间传递的数据本身,不变式概念依然有效。
    • 例程与调用者的契约:
      • 如果调用者满足例程所有前置条件,则例程保证退出时所有后置条件和不变式为真。
      • 如果任一方没有履行契约,就会调用(之前同意的)补救措施——抛出异常,或程序终止。
  • 基于特性的测试

    • 《修炼之道》:契约和不变式放在一起并称为特性,我们基于它做自动化测试。
    • PBT 库 Hypothesis 的作者的解释 PBT 概念的文章
      1. 内部对调用者透明(referential transparency))
      2. 指定随机实例的类型(types)
      3. 随机生成(randomization)
      4. 对相关工具的使用(the use of any particular tool or library)

#只有 EBT 还不够

EBT 优点在精准,缺点在太精准。一言以蔽之,EBT 引入了太多主观且与需求无关的细节,且能测试到的代码运行路径的覆盖率及其有限。

甚至个别用例会导致类似机器学习过拟合的效果,让代码偏离了原本的意图。 这更多是错认为“测试所表达的意图不会变且一直正确”的思维陷阱所导致的。 在 EBT 中,测试用例就是主观对需求的理解,需要在修改功能时不断检查无法通过的测试是否仍具有意义。 比如 TDD 实践中所使用的单元测试方式就是 EBT, 在这篇记录两个开发者使用 TDD 流程结对编程实现一个 Kata 的 文章 中,两人会讨论“是否该把之前写的一个测试用例删除,因为它所代表的操作已不会在现实发生,虽然之前是有意义的”,还好他们凭经验避开了。

从 EBT 转向 PBT,像从命令式(imperative)编程范式 到声明式(declarative)编程范式的转变。 命令式是给出具体过程的实现,就像 EBT 要明确构造调用被测函数时的测试数据的状态。 而声明式只是声明想做什么,具体过程交给解释器, PBT 也是只对特性的假设下准确定义,但关于在抽象定义的特性的限制下如何生成大量测试数据,交给 PBT 库。 这样我们仅仅给出了足够定义特性的信息,而没有引入对具体取值的依赖,符合ISP 原则 。 两者仅在对测试数据的构造方式上有差别,而对于被测函数的期望两者是相同精确的,都要明确写成 assert, expect…语句。

这篇文章 里不仅有关于 EBT 与 PBT 的代码级比较,还有使用 PBT 发现 EBT 难以暴露的 bug 的两个真实案例。

不过我个人认为,PBT 并不能完全取代 EBT,比如边界值这个案例, 如果不是 CI 那种持续地测,没准儿只运行一次单元测试没能随机出特定的边界值呢。 而且两者也不互斥,可以针对同一个 API 写两种测试,更可靠。

另外,我看到不止一个地方这样描述 PBT:它会给你带来惊喜。

#PBT 和 Fuzzing 概念的区分

PBT 很像小到被测函数范围的Fuzzing , 目的是用大量随机输入无情地对待代码,尝试找出它代码上没有体现到,但是需求上承诺要做到的特性 (对于 Fuzzing 来说,被测软件应该承诺做到尽可能的鲁棒性和安全性)。 这篇文章 阐述了两者间差别与相同之处:

  • Fuzzing 是完全由计算机控制的随机过程,而 PBT 在随机的范围上基于特性进行了人为限定。
  • PBT 工具与 Fuzzing 工具在运行流程上高度相似,两者可相互替代使用。

从这个区别上我们也能看出,PBT 不仅会利用计算机的随机与自动化摆脱思维对需求的偏见, 还能反过来促使开发人员思考总结并用代码表达出函数真正的、广范围的契约与不变式。 PBT 可作为单元测试的一种方式,在单元测试层面,其最重要目的也是“启发编码”。

#PBT 到底在哪个概念层级

我认为 PBT 的明确对立概念应该是 EBT,发生混淆的原因有两点:

  1. EBT 已与太多的熟悉的概念绑定:TDD 实践中明确使用 EBT,谈起单元测试就是 EBT; 谈起集成测试、QA 以及更高层的测试,也是用具体的测试集去书面化需求文档, 测试集大多为 EBT(但同时也存在引入随机测试数据的测试平台)。
  2. PBT 运行时间多于 EBT,不符合单元测试或者 TDD 要求的秒级结果反馈。

你可能会看到有观点说单元测试、TDD 和 PBT 是对立的概念,《修炼之道》里部分文字也有这个意思。 当人们使用对比手法介绍 PBT 时,如果无法把 EBT 这个细类概念从大类概念中抽出,并明确地指出来,就会无意中造成这种表达理解上的误差。

也有观点说 PBT 独立于单元测试、集成测试等测试层级概念,是“额外的一类测试”,我反对这个观点。 首先,我了解到的几个现有的 PBT 库都兼容在了原先存在的为 EBT 编写的成熟测试框架下, 或者应该说是测试框架逐渐变成了无关测试方式的抽象框架,这是事实依据。 其次从定义上区分,测试层级描述的是项目活动,区别主要在参与该活动的项目干系人(stakeholder) 的类别,和运行时对系统覆盖的范围,和 PBT 所定义的东西相差甚远。 PBT 可以用在单元测试中,也可以用在集成测试中,它描述的仅仅是一种构造测试数据的思路。

#我认为单元测试中可以使用 PBT

在编程实践而非项目管理定义上,单元测试(和组件测试,组件测试在定义上比单元测试多涉及一个 mock)有几个共识的特征:

  1. 由开发人员为指导编码所写。
  2. 被测代码范围最大为单个模块,我们会使用 Mock 技术编写模块所依赖的桩, 模拟上层调用者调用模块的 API,模拟被测模块的上下文。
  3. 自动化与可重复运行。
  4. 测试粒度比集成测试等包含多模块的层级更细。
  5. 测试反映了开发人员对代码行为的期望。

我们可以判断说 PBT 没有违反上述单元测试的定义。PBT 与单元测试的矛盾大概在运行时间上。

#单元测试运行时间的大部分受不可控因素影响

TDD 等倡导测试优先的学派,为了实践需要,额外为单元测试增加了“测试集运行速度需缩短到秒级”的要求。 这个要求是基于现代电脑的高性能前提提出的,而且我猜测,单元测试被 TDD 概念反过来影响,加之开发人员们主观感受, 认为单元测试就是而且应该是很快的。

但我们在实践中已经了解到,一旦引入 Mock 机制,运行一次某个类对应的单元(严格来说是组件)测试的总时间, 绝大部分为测试框架执行 mock 加载上下文的时间,正比于我们想测的模块所涉及的依赖的启动复杂程度。

举例,以我的经验(大概只是工作电脑性能低),对于 Java 的 Spring-boot 应用中的单个 Junit 测试类, 使用@RunWith(MockitoJUnitRunner.class)最快,大概启动10秒左右; 如果要用@RunWith(SpringRunner.class)@SpringBootTest就要启动30多秒; 如果被测代码还包含从配置文件中读取配置的内容,需要额外用@TestPropertySource在启动时导入测试配置文件, 又要加个10秒左右。 不管测一个方法还是测所有方法也是加载同一个上下文,最小化上下文还是需要这么多启动时间。 肯定每次运行时都要看控制台里 application 启动时慢慢输出的 log,然后刷刷两下所有用例都测完,测试退出。 这类代码从实践上考虑就无法使用 TDD 开发,但这些测试就是它们的单元测试。

我个人认为,正确理解单元测试的速度要求需要相对于集成测试来说, 单元测试相比集成测试运行时间低至少一个数量级,这种说法是对的。 同时通过 Mock 这个例子,我们发现单元测试的运行时间取决于代码的依赖的复杂度、电脑性能等不可控因素上 (有些牵强?哈哈抱歉,大概就是想表达这么个意思,没讨论到的因素还有很多)。

#单元测试中能否用 PBT,取决于具体欲测特性的复杂度

这个说法真模糊啊,但我只能拿出这种说法了。 你看,单个 PBT 用例相比单个 EBT 用例,慢就慢在要测多于一个测试数据,加上生成数据的时间,关键在运行时间。 但是生成数据的范围和单个数据测试的时间又是由特性决定的,下面分别对两方面解释一下。

关于大家和 PBT 库认为单个 PBT 用例的运行次数,或者说生成测试集的大小,我搜索的结果是, 从提供限定大小的参数的库,到默认100个的固定数量的随机测试集的库, 到正统的不许设定数据集大小,粒度只能指定到数据类型,需要持续运行测试的看法,都有。 首先,肯定运行越多越好,持续集成里的测试可以这么写。其次,单元测试肯定受不了相对较长的运行时间。

《修炼之道》给的介绍 PBT 的例子有这两个:

  1. 运行100次,随机列表作为输入,测试排序方法调用前后列表长度没变,排序结果正确,运行时间0.95秒。
  2. 运行100次,从大小为4的集合中随机取元素作为输入,测试“库存始终大于0”不变式,找到了 bug,没写运行时间。

嗯,第二个例子100次不算多,能找到 bug 是因为这个 bug 比较“容易”被引爆,“容易”是个主观定性的词,描述了这个特性的复杂度。 这篇文章 里介绍了一些 PBT 常见模式, 不少模式(所代表的特性)复杂度并不高。

鉴于我们的单元测试时间可能大部分还是会消耗在执行 mock 上,花10秒运行1000次测试来测试一个特性, 这样对整个模块的 PBT 单元测试大概要跑几分钟(共十几个特性),似乎是可以接受的。

#如何构造 PBT 所需测试数据

基本每个编程语言都有 PBT 库 (这篇2016年的文章信息稍有过时), 这类 PBT 测试库通常会提供,对其服务的语言的原生库内的类的实例的随机构造方法。 有点绕,用 Java 下的junit-quickcheck 库举例就是:该库会提供对 Java 基本类型、String、常用集合类、Date 等类的随机构造方法。 这种构造方法因库而异,在提供给用户使用的方式上稍有不同, 比如junit-quickcheck 使用参数上的注解, Python 下的Hypothesis 使用方法上的注解。

这样方便你写测试时进一步构造自己软件所在领域的 Model 之类的实例。 构造方式与 PBT 相似,还是要运用上文 EBT 部分所述的技巧自己手工编写, 只是把 Model 的属性赋值部分换成库提供的随机生成方法。 具体到代码,可以参照这篇文章 的做法。

#更多关于 PBT 的参考内容

#5. 总结

希望这篇文章给你讲明白了,或者至少你可以把所有参考资料读一遍,自己形成理解。 试试在工作中把 PBT 用起来,如果害怕擅自导入 PBT 包被发现,不提交至 VCS 就行。 顺带一提我还没实践过,打算下次迭代时试试。我预计即使是后端日常 CRUD 功能, 根据那篇PBT Patterns 文章 ,针对数据变换或判断的方法都可能能总结出特性。

抛砖引玉,欢迎在评论中指点指正,或给出另一个角度的看法,好让我对这些概念了解更深。

updatedupdated2023-08-082023-08-08