最后测试开发:遗留代码单元测试入门
许多团队尚未完全拥抱 TDD。本文档适用于需要测试遗留代码的团队或事后编写代码的开发人员。
引言
测试驱动开发(TDD)是一种经过验证且有效的软件开发方式,但它假设您从零开始。关于 TDD 的一个误解是,在测试优先开发(Test Last Development)或 TLD 中,一些针对遗留代码实施的方法可能会损害您的代码库。
如果您曾经针对遗留代码进行开发,那么您无疑遇到过一些应用程序,它们耦合得如此紧密,以至于对原始代码进行重构完全被排除在外。本文档将尝试为您提供创建遗留代码单元测试的最佳实践,目标是让您的团队能够朝着获得所需的代码覆盖率迈进,从而对您的遗留代码库充满信心。
开始之前
虽然为遗留代码创建测试对于未来的开发很重要,但为已投入生产环境的遗留代码创建测试时,格外小心更为重要。在编写任何新测试之前,应考虑以下几点。
存在哪些未知依赖项或先决条件?
方法通常会假设存在一些未被发现的依赖项或先决条件,您需要仔细跟踪这些。最明显且最容易发现的是对第三方代码库的引用,如果这些库不存在,代码将无法编译。但依赖项还可能包括在测试发生之前必须设置的配置设置。您应该了解为使测试正常工作而必须具备的配置设置。
是否需要特定的环境变量和仅限本地的模拟?
您可能需要模拟生产环境才能运行遗留代码。这可能涉及更改连接字符串和更新配置文件,以便您可以模拟您的生产环境。您需要仔细记录和跟踪这些依赖项,因为您不希望它们被签入代码库。您还希望确保在测试中为它们添加注释。
您可以更改代码吗?
在 TDD 中,您会一边编写代码一边创建单元测试,这意味着可测试性是内在的考虑因素。而在测试优先开发(Test Last Development)中,可测试性很少被考虑。在创建单元测试时,有必要了解并就是否可以更改代码达成一致。例如,如果您在构建之间创建测试用例,可能无法更改代码,您需要在此框架内工作。但是,如果您在版本之间,应该就进行小幅代码更改达成共识。
代码在当前状态下是可测试的吗?
代码是否包含执行许多复杂任务的大方法,还是存在大量不易于测试的子例程?通常,使代码更易于测试需要将大型复杂方法分解为许多更小的、更“零碎”的方法。有时,您会看到充斥着返回 void
且根据传统 TDD 标准基本上不可测试的子例程的代码。您需要找到方法来确保代码兼容性,一种方法是保持原始方法签名不变。
关注可能实现的,而不是不可能实现的
不要纠结于无法测试每一行代码的事实。实际上,即使在实践测试驱动开发时,也并非总是能实现 100% 的代码覆盖率。只需从您可以合理测试的内容开始,然后以此为基础。一旦您开始创建第一个测试,就会发现自己能够识别并优先处理以前认为不可测试的代码。
创建您的测试
选择一个组件进行测试
对于遗留代码,您应该首先确定最大、最全面的测试,这将为您带来最大的“性价比”。这可能意味着识别一个执行组件中最关键任务的单个方法,但也可能意味着识别应用程序最关键或最常用的方法或对象。如果您正在测试一个独立的应用程序,您可能想从 Main
方法开始,因为失败的 Main
将导致整个应用程序崩溃。如果您正在测试一个库,您将希望首先识别该库执行的主要功能并测试该功能。这可能是定义库用途的工厂方法或转换方法。
创建夹具(Fixtures)
一旦您创建了第一个测试,您将开始识别创建其他测试的通用框架,并希望识别出通用依赖项。夹具是在单元测试之前和之后在测试工具中运行的设置和拆卸方法。设置方法为您的测试准备环境,拆卸方法执行清理。
尽可能多地覆盖测试
TDD 方法要求您只编写足够的代码来使测试通过、失败,然后再次通过,并在过程中测试方法边界。然而,当您处理成千上万行遗留代码时,TLD 方法要求您在最短的时间内获得尽可能多的覆盖率。在 TLD 中,您的方法已经写好,并且很多时候已经投入生产,所以最初您只想专注于通过测试。在此阶段,边界测试应该等到您准备好重构遗留代码时再进行。
调试和重构您的代码
如果遗留系统经过验证,大多数测试最初都会通过。然而,任何时候为以前未经测试的代码库创建测试,您很可能会发现代码中隐藏着一些错误。发生这种情况时,您需要决定是否修复这些错误。TDD 的“边做边清理”方法建议重构直到测试通过,但对于遗留代码来说,这不是一个好的实践。您可能会在代码库的其他部分引入未被注意到的错误,因为缺乏覆盖率。很多时候,您没有预算或时间来重构遗留代码,并且很多时候的任务是保持遗留代码原样。每当您重构遗留代码时,您都会希望在测试创建过程中尽可能避免它,或者将其降至最低。
自顶向下测试功能
TDD 编程要求您使用自顶向下的问题分解来分析当前任务,并根据这些场景列出测试列表。遗留代码具有定义好的任务集,因此您可以立即从最高级别开始编写测试,而不是查看单个方法。您可能会发现编写了过多的测试,需要稍后将其中一些测试移至夹具中,但这没关系。最初,请尝试记下应用程序正在做什么,并为它所做的每件事编写测试,然后将共享测试移至夹具中。
考虑代码的替代路径
尝试将您的测试组织成内聚的组。考虑测试相关功能,并按库或包、类、方法,最后按代码行进行划分。换句话说,您从一个执行某种功能的单个库开始编写关键功能的测试。从该库中的一个关键类开始,目标是为该库中的每个类、库中每个类中的每个方法编写测试,并最终实现覆盖每个方法中每一行代码的最终目标。
针对数据存储进行测试
很多时候,您会发现大量的子例程返回 void
。虽然这可能看似适得其反,但您仍然需要找到创造性的方法来测试这些子例程是否如其承诺那样工作。有时,这些子例程会改变对象的状态,有时它们与数据操作相关,例如执行存储过程或将数据插入数据存储。虽然 TDD 方法坚持要模拟对象或将数据存储完全与测试分离,但在遗留代码中,如果不进行大规模重构,这并不总是可能或实用的。如前所述,我们希望在拥有足够的代码覆盖率以确保成功之前避免重构。这可能意味着您需要直接针对数据存储进行测试,直到您拥有足够多的代码覆盖率以开始重构或模拟数据。这可能会使测试变得更复杂,但忽略返回 void
且依赖于预先存在数据的代码不是一个选项。
不要依赖预先存在的数据
依赖数据存储中的预先存在数据是不理想的,并且可能导致虚假结果。如果您依赖于预先存在的数据,而这些数据被删除或改变了预期状态,那么所有依赖于这些数据的测试都将产生虚假结果。如果您需要针对数据存储进行测试,可以使用夹具来插入或准备正确状态的数据,并在测试完成后删除或重置数据。首先准备好您的数据将确保您始终获得预期的结果。
测试死方法(Dead Methods)
在测试过程中,您会发现自己遇到了死方法,即功能不再需要的方法。您可能会惊讶地发现有多少代码不再需要了。如果您遇到可疑的代码,可以使用 TFS 通过搜索该代码的所有引用来轻松验证。一旦您验证了代码是死的,就可以安全地删除它。您维护的代码越少,您和所有负责代码的人就越好。
结论
不要担心实现 100% 的代码覆盖率,将覆盖率百分比视为一个动态目标。毕竟,每次组件更改或添加方法时,您的百分比也会改变。选择一个数字,让您能够自信地重构遗留代码库,并逐步推进,确保您已经覆盖了每个组件中的关键功能。一旦达到目标数字,您对代码的信心就会增加,并且花费在错误地方寻找 bug 的时间将大大减少。
历史
- 2011 年 4 月 14 日:初始发布