[转]如何编写优美的测试
我在 Crate.io 参与的第一个项目是实现对 COPY FROM 语句的 CSV 支持。这一新特性已在 CrateDB 3.0 版本中发布。
在这个项目中,我为 CrateDB 编写了第一批测试用例,同时也开始思考我开展测试的方法。
在成为一名软件开发工程师之前,我在一家制造业工厂担任过程工程师。
在制造工厂工作具有一定的危险性。有毒或有腐蚀性的化学品,重型的工业设备以及密闭的空间只是过程工程师所须控制的潜在风险中的一小部分而已。
一旦出现故障,后果可能不堪设想。由于工厂安全性问题造成伤亡的案例数不胜数。
过程工程师必须时刻考虑过程和系统可能出现的故障,并对其进行恰当的管控。想要维持安全的可持续生产的环境这是必需的。
鉴于之前的经历,我觉得作为软件工程师也应该承担类似的职责。
通常来说,软件故障并不会像制造工厂出现故障那样,造成灾难性的甚至危及生命的后果。
不过,还是有造成这两种严重后果的可能性。无论如何,作为工程师,我们还是应该高度重视任何可能出现的故障。
不厌其烦的描述我过往的经历,想要表达的是当我从过程工程师转为软件工程师时,这种保持怀疑态度的工程理念也随之而来。
无法测试或者难以测试的系统让我觉得惴惴不安。
经过精心测试的系统则让我心情愉悦。
1. 为什么要进行彻底的测试?
恰当的测试技术能帮你透彻理解你所设计的系统的行为,从而,也就能够透彻理解系统的故障状态。进而,这一过程可以让你避免故障、消除故障或减轻故障的影响。
此外,长远来看,恰当的测试代码还能够帮你节省更多的时间。
尽管不可能面面俱到地考虑到每一种可能遇到的边界情况,但是考虑更加周全就意味着代码更加健壮,而且在出现缺陷时,需要再重新检查这段代码的几率更低。
无论我们对代码做了多么完善的测试,缺陷还是会出现。调试之前编写的代码(无论是自己的还是别人留下的)总是代价高昂并且令人沮丧的。我们通常需要花费大量的时间用于重新熟悉代码,还要在脑海中构建代码如何工作的模型,之后才能够开始修复问题。这也是精心编写的测试可以帮助解决的。
当进行代码重构时,测试也能够为重构提供双重保障。全面的测试套件能够对在重构过程中无意中引入的代码行为变化做出提醒。
2. 测试方法
测试代码的方法多种多样。在我看来,有三种方法是至关重要的:
2-1. 手工测试
基于提前设定的验收标准、用户故事或其他类似的内容,模拟终端用户如何使用软件的测试方法。
2-2. 集成测试
验证各个系统部件能够正确地协同工作的测试方法。
由于同时测试多个组件的复杂性较高,这类测试通常需要投入更大的精力。正因如此,集成测试通常会对少量几个关键场景尽可能多地进行测试。
2-3. 单元测试
对代码的单个组件进行的测试。
在我看来,单元测试应该是全面的,并需要考虑到所有可能的边界情况。
3. 不要把编写测试留到最后!
当我听见有人说:“我马上就要写完代码实现了。接下来只需要编写测试就可以了。”,我就想对他吹胡子瞪眼。
在我的内心深处有一个声音在大声呐喊:“你的顺序颠倒了!”
如果说这话的是一个工程师,我倾向于以下几种假设之一:
- 这个工程师尚未重视测试。做软件测试只是因为逼不得已。
- 他们可能只会编写很少量的测试用例,而且极有可能不会考虑边界情况。
- 这是一名新手工程师,他认为软件的健壮性是事后才要考虑的。
为什么我对写完代码后不进行测试持有如此强硬的态度?
因为如果在写完代码之后再写测试,你可能只会对已经编写的代码进行测试,而不是对系统所期望的行为进行测试。这样测试的意义就会大打折扣。
在我看来,测试应该用于对系统期望的行为进行建模,而代码应该围绕测试编写。
这样做有助于确保实质重于形式。此外,还有助于保持架构的紧凑性,因为只需要编写足以让测试通过的代码即可。
我倾向于将此视为由外而内的解决问题,而非由内而外。
4. 由外而内的策略
由外而内的开展工作意味着从清晰定义的验收标准开始。利用这些验收标准,编写一组策略性的集成测试,之后再转至详细描述代码期望行为的单元测试,最后,开始编写代码。
从外部开始的主要好处是,它可以帮助开发人员专注于想要达成的目标,从而尽量避免迷失于实现细节当中。
个人而言,我是一个很难保持专注的人,因此需要找到合适的方法帮助我达成这一目标。由外而内开展工作可以系统化地帮助我考虑清楚我正在进行的工作。
另外,还有两种开发方法可以帮助我们达成这一目标:行为驱动开发和测试驱动开发。
5. 行为驱动开发
行为驱动开发(BDD)是一种让开发人员、测试人员、产品负责人及其他利益相关者就某一特性或故事的用户价值达成一致的开发方法。
BDD 通过场景(或用户旅程)规划用户与特性交互的过程以及交互的结果。
理想情况下,应该用清晰、简单、非技术化的语言编写场景,以便可以从根本上消除歧义,将聚焦于用户身上,而非实现细节之上。Gherkin 语法是实现这一目标的常用方法。
一个典型的场景结构通常是这样的:
1 2 3 |
给定【前置条件】 当【调用此操作时】 然后【预期的结果】 |
让我们以我正在开发的 CrateDB 特性为例。使用COPY FROM
命令导入 CSV 数据的场景如下:
1 2 3 4 5 6 7 8 |
假设我创建了一个表格,my_table 我要导入一个 CSV 文件,file:///example.csv,文件中包含如下内容: Code,Country IRL,Ireland 当我执行如下语句时: COPY my_table FROM file:///example.csv my_table 包含两列:‘Code’和‘Country’ 这两列中分别有一行数据‘IRL’和‘Ireland’ |
这个场景的描述语言通俗易懂,这样技术人员和非技术人员都能够很好的理解。
除此之外,我发现用这种方式描述场景能够更加直观地探索值得我们考虑的用户可能与系统进行交互的方式,这反过来又帮助我发现更多的边界情况。
此外,这一过程的产出是一系列场景的集合,这些场景清晰地定义了验收标准和设计假设,以供将来参考。
6. 测试驱动开发
BDD 起源于测试驱动开发(TDD),但这并不意味着只能二选一。二者可以互为补充。
如果先执行 BDD,可以用最终的验收标准来编写较低级别的集成测试,然后编写单元测试。
通常来说,严格的 TDD 拥护者,会从集成测试开始,创建许多场景来测试系统的预期行为。
接下来,着手进行单元测试。首先,从能想到的最简单的测试用例开始,执行该测试并得到失败的结果,然后再编写代码实现使该测试能够执行成功。
然后,继续下一个测试,并重复此过程,增量地构建代码,使其能够完全满足验收标准。
最后,当编写完所有单元测试和代码实现时,预期的结果是集成测试也能够顺利通过 (尽管可能需要对代码实现进行一些调整才能达成这一目标)。
作为一名过程工程师,有时我会参考危害控制的行业分级标准。该分级标准将危害控制从最有效到最无效进行排序,如下所示:
- 危害消除
- 危害替代
- 危害隔离
- 改变工作方式
- 保护个人免受危险 (通常通过个人防护设备)
如果先编写软件测试,那么就有希望直接跳到最有效的管理失效可能性的方法: 危害消除。也就是说,在问题进入到系统之前,在设计阶段就将其拒之门外。
7. 有交互能力的代码
依我所见,仅仅编写测试是不够的。还要致力于编写易于理解的代码。
毫无疑问,计算机代码是一组告诉计算机应该做什么的指令。然而,某种程度上更重要的是,它是你与其他人交流关于在某个时点如何解决某一问题的一种手段。即使这个人只是将来的你。
在软件的整个生命周期中,阅读代码通常都要比编写代码花费更多的时间。出于这个原因,编写尽可能易于理解的代码是至关重要的。这能节省在代码上所花费的时间,进而可以减少缺陷的发生,让程序员心情更加愉悦。
正因如此,我一直努力尝试编写具有交互能力的测试,并将其作为自文档化的代码。完成这项工作的首选工具就是恰当的测试方法名和测试功能摘要。
8. 测试方法名
我编写测试方法名称的首选方法是包含如下三个元素:
- 正在测试的方法的名称
- 前置条件
- 预期的输出
以下面的测试方法为例:
1 |
public void processToStream_givenFileIsEmpty_thenSkipsFile() |
我们可以将测试方法名分解为:
processToStream
: 测试的对象是 processToStream 方法。givenFileIsEmpty
: 为该方法提供一个空的文件。thenSkipsFile
: 该方法应该直接跳过该文件。
以这样的方式命名测试方法,无需阅读方法定义,只需要快速浏览一遍测试方法名称就可以大致了解该方法测试的内容。
9. 测试功能摘要
当我想快速理解某段代码的行为时,我通常会直接跳到对应的测试方法。
测试可以作为一种形式的动态文档,因为如果不更新测试,代码就不会更新。此外,平文本文档甚至 (也许特别是!) 代码注释可能很快就会过时,而且不管怎样,它们的实用价值可能会千差万别。
将功能抽象到命名良好的方法中,有助于让测试更加易于理解。
例如,我们来看一下之前的示例方法的完整定义:
1 2 3 4 5 6 7 8 |
@Test public void processToStream_givenFileIsEmpty_thenSkipsFile() throws IOException { givenFileIsEmpty(); whenProcessToStreamIsCalled(); thenDoesNotWriteToOutputStream(); } |
如果读者想要了解上述步骤中某一步骤的更多信息,可以查找对应的方法定义即可。
我们先来看一下。
第一个方法模拟了一个 Reader 对象,读取一行数据,返回空值。
1 2 3 |
private void givenFileIsEmpty() throws IOException { when(sourceReader.readLine()).thenReturn(null); } |
第二个方法调用了我们要测试的方法。
1 2 3 |
private void whenProcessToStreamIsCalled() throws IOException { subjectUnderTest.processToStream(); } |
第三个方法验证预期的输出结果,在这个示例中是一个尚未写入数据的输出流。
1 2 3 |
private void thenDoesNotWriteToOutputStream() { verify(outputStream, times(0)).write(NEW_LINE); } |
在此我有意选择了一个简单的例子——一个可能不需要这种级别的抽象的例子——希望能够易于读者理解。当测试更加复杂时,这种方法带来的好处更多。如果你想看到具体的示例,可以查看我提取这段示例所用的代码。
10. 总结
精心设计的测试是必不可少的,在我看来,这也是一个工程师经验丰富的标志之一。
好的工程要求你:
- 考虑边界情况以及代码是否容易遭受故障的攻击。
- 编写对代码的未来读者和维护者来说可理解并有价值的测试。
- 构建测试,以确保在每个重要的系统层级上都有战略性的、明确的验收标准集合。
- 在设计代码时就测试并考虑故障模式能够有助于编写更健壮从而更易维护的软件。同时也会让重构和修改代码更容易。
编撰良好的测试可以充分地记录代码,使读者更容易地投入并熟悉代码。这对于协作开发的代码尤其重要。无论是内部团队还是开源项目,都需要降低代码贡献的门槛。
在许多情况下,测试套件比代码实现更有价值,因为它详细说明了代码实现应该做什么。可以这样理解这一观点,如果由于某种原因,代码实现不慎丢失,从测试中能够如实地还原代码实现。
英文原文:https://crate.io/a/on-writing-beautiful-tests/
[resource]如何编写优美的测试