本文分享一些自动化测试经验以及在不断的试错中获得的教训。
众所周知,编写自动化测试存在优点与缺点。从积极的一面来说,编写自动化测试可以:
1. 提高代码质量:自动化测试可以确保代码的正确性和一致性,从而提高代码质量。
2. 提高开发效率:自动化测试可以减少人工测试的时间,从而提高开发效率。
3. 提高代码可维护性:自动化测试可以帮助我们快速定位和修复代码中的错误,从而提高代码的可维护性。
4. 提高代码可重用性:自动化测试可以帮助我们复用代码,从而提高代码的可重用性。
5. 提高代码可测试性:自动化测试可以帮助我们测试代码的各个方面,从而提高代码的可测试性。
缺点:
1. 增加开发时间和成本:编写自动化测试需要额外的时间和资源,可能会增加开发时间和成本。
2. 增加代码复杂度:自动化测试代码可能会增加代码的复杂度,从而降低代码的可读性和可维护性。
3. 增加代码风险:自动化测试代码可能会引入新的代码风险,从而增加代码的不稳定性。
因此,是否编写自动化测试取决于具体情况和项目需求。在某些情况下,编写自动化测试可能是有益的,而在其他情况下,可能并不必要。
让重构更加容易
避免回归
提供可执行的规范和文档。
缩短创建软件所需的时间。
降低开发软件的成本
当然,你可以说这些理由都是正确的,但我想从另一个角度来阐述所有这些原因:
自动化测试的唯一价值在于,它为开发者做代码变更提供信心。
换句话说:
只有在需要修改代码时,测试才能发挥作用。
让我们来看看支持编写测试的经典论点是如何与这个前提联系在一起的:
使重构更加容易 - 开发者可以放心地更改实现细节,而不会影响公共API。
避免回溯 - 回溯通常在何时发生?通常是在更改代码时。
提供可执行的规格说明和文档 - 你什么时候想要更深入地了解软件的实际工作原理?当你想要修改它时。
缩短了软件开发的时间——如何做到的?通过让你更快地修改代码,并确保你的测试会告诉你何时出现问题。
降低了创建软件的成本——嗯,时间就是金钱。
是的,以上所有原因在某些时候都是正确的,但对我们开发人员来说,自动化测试能让我们对代码进行更改。
需要指出的是,我这里没有包含编写测试时的代码设计反馈,这与TDD(测试驱动开发)有所不同。那 这可能是另外一次对话。一旦试卷写好了,我们就会讨论考试。
那么,编写测试和如何编写测试似乎应该由变化来驱动。
在编写测试时考虑这一事实的简单方法是,始终向你的测试提出以下两个问题:
“如果我更改生产代码,你会失败(或通过)吗?”
“这是你失败(或通过)的理由吗?”
如果你发现你的测试未能(或成功)的糟糕原因,那请修复它。
这样一来,当你以后修改代码时,你的测试只会因为合理的原因而通过或失败,从而比那些因为错误原因而失败的不可靠测试提供更好的回报。
不过,你可能还是会想:“这有什么大不了的?”
我们可以用另一个问题来回答这个问题:当我们更改代码时,为什么测试会失效?
我们一致认为,进行测试的主要目的是使我们能够轻松地更改代码。如果是这样的话,那么那些红色的测试是如何帮助我们的呢?那些失败的测试只是噪音——妨碍我们完成工作的障碍。那么,我们应该如何进行测试才能帮助我们呢?
这取决于我们为什么要更改代码。
改变代码的行为
起点应该是绿色的,也就是说,所有的测试都应该通过。
如果你想改变代码的行为(即改变代码所做的事情),你需要:
找出当前行为的测试或测试,这些测试定义了你想要改变的行为。
修改这些测试,以期望新的期望行为。
运行这些测试,看看是否修改后的测试失败了。
请更新代码,使所有测试再次通过。
在这个过程中的最后,我们又回到了起点——所有的测试都通过了,如果需要的话,我们已经准备好重新开始。
因为你确切地知道哪些测试失败了,以及哪些代码修改使它们通过了测试,因此你确信自己只更改了想要更改的内容。这就是自动化测试帮助我们修改代码的方式——在此情况下,是改变行为。
只要测试失败是因为我们正在更新的行为相关的原因,那么这是可以接受的。
重构:改变代码的实现方式,但保持其行为不变
总之,一切应以绿色为出发点。
如果你只是想改变一段代码的实现方式,使其更简单、更高效、更易于扩展等(即改变代码如何执行某项任务,而不是改变它执行的任务),以下是你需要遵循的详细步骤:
不要修改你的代码,而要完全不触及你的测试代码。
一旦你的代码变得更简单、更快或更灵活,你的测试应该仍然保持原样——绿色。在重构时,测试只会在你犯了错误的情况下才失败,比如改变了代码的外部行为。如果发生这种情况,你应该纠正错误并回到绿色状态。
因为你的测试始终保持绿色(通过)状态,所以你知道自己没有破坏任何东西。这就是自动化测试让我们能够修改代码的方式。
在这种情况下,看到测试失败是不可接受的。这可能意味着:
我们不小心改变了代码的外部行为。太好了,我们的测试正在帮助我们。
我们没有改变代码的外部行为。糟糕,我们得到了假阴性结果。这就是大部分问题的根源所在。
我们希望我们的测试能够帮助完成上述过程。那么,让我们来看看一些具体的建议,以帮助使我们的测试更加协作。
良好实践的原则
在讨论如何编写糟糕的测试之前,我想快速回顾一下一些良好的实践。每个测试都应该遵循以下五个基本规则,才能被视为良好的甚至是有效的测试。这五个规则的首字母缩写为F.I.R.S.T.
测试应该遵循:
快速 - 测试应该能够频繁执行。
隔离 - 单个测试不能依赖于外部因素或另一个测试的结果。
可重现性 - 测试应该每次运行时都产生相同的结果。
自动化验证 - 测试应该包含断言;不需要人工干预。
及时 - 测试应该与生产代码同时编写。
不好的实践
我们如何最大化测试结果?一句话概括:
不要将测试与实现细节耦合。
不要测试私有方法。
私有就是私有,就是这样。如果你觉得有必要测试一个私有方法,那么这个方法在概念上就有问题。通常来说,如果一个方法要做太多事情,那么它就不应该作为私有方法,这同时也违反了单一职责原则。
今天:你的类有一个私有方法。它做了很多事情,因此你决定测试它。你将该方法设置为公共的,仅用于测试目的,尽管单独使用该方法没有任何意义,因为它仅用于在同一类中其他公共方法的内部。你为该私有方法(从技术上讲,现在是公共的)编写了测试用例。
明天:由于需求发生了变化(这很正常),你决定修改这个方法的功能。你发现有同事在使用来自另一个类的这个方法,但目的是完全不同的,因为他认为“它能满足我的需求”。毕竟,它是公开的,对吧?但这个私有方法并不属于公共API。如果你修改这个方法,就会破坏同事的代码。
解决方案:将私有方法提取到一个单独的类中,为该类定义一个明确的契约,并单独对其进行测试。在测试依赖于该新类的代码时,如果需要,可以提供该类的测试替身。
那么,我该如何测试给定类的私有方法呢?通过其类的公共API。始终通过其公共API测试你的代码。你的代码的公共API定义了一个契约,这是关于根据不同输入你的代码将如何行为的明确定义的预期集。私有API(私有方法甚至整个类)并未定义该契约,并且它们可能会在未通知的情况下更改,因此你的测试(或你的同事)不能依赖它们。
通过这种方式测试你的私有方法,你可以自由地更改(真正)私有的代码,并且通过使用较小的仅执行一项任务的类,并对这些类进行适当的测试,你的代码设计将得到改善。
不要隐藏私有方法
遮蔽私有方法用于测试同样存在一些局限性,而且这样做还可能难以调试。通常,遮蔽库依赖于一些技巧来完成这项工作,这使得很难找出测试失败的原因。
此外,当我们调用一个方法时,应该按照其契约进行调用。但是,私有方法并没有明确的契约——这主要是因为它是私有的。由于私有方法的行为可能会随时更改,因此你的桩可能与现实不符,但你的测试仍将通过。太可怕了。让我们看一个例子:
今天:类中的公有方法依赖于同一类中的私有方法。私有方法foo从未返回nil。为了方便测试公有方法,对私有方法foo进行了简化。在简化方法foo时,你从未考虑让foo返回nil, 因为目前这种情况从未发生过。
明天:私有方法发生了更改,现在返回nil。这是私有方法,所以没关系。公共方法的测试从未相应地更新(“我正在更改私有方法,所以为什么应该更新任何测试呢?”)。现在,当私有方法返回nil时,公共方法就无法正常工作了,但测试仍然通过!
解决方案:由于 foo 方法执行了太多操作,请将该方法提取到一个新的类中,并单独对其进行测试。然后,在测试 bar 时,为该新类提供一个测试代理。
不要对外部库进行修改
不应该在测试中直接提及第三方代码。
今天:你的网络代码依赖于著名的HTTP库( LSNetworking )。为了避免实际连接网络(使你的测试快速可靠), 你模拟了该库的 -[LSNetworking makeGETrequest:] 方法, 正确地替换了其行为(它通过调用成功回调函数来传递预先准备好的响应), 但不连接网络。
明天:你需要将 LSNetworking 替换为一个替代品(可能是因为 LSNetworking 不再被维护或者你需要切换到一个更先进的库,因为它具有你需要的功能等等)。这是一个重构,因此你不应该更改你的测试。你替换了库。你的测试失败了,因为网络依赖不再被模拟( -[LSNetworking makeGETrequest:] 不再被实现所调用)。
解决方案:在测试中使用“伞戳”来完全替代该库的功能。
雨伞戳戳(我刚编出来的一个术语)指的是通过声明性API(与任何实现细节无关),将来自现在和将来的所有可能的代码使用方式都“戳掉”(即封装起来),以便完成某个任务。
就像上面的例子一样,你的代码今天可以依赖“HTTP库A”,但是还有其他可能的发送HTTP请求的方式,比如“HTTP库B”。
举个例子,为网络代码提供伞式封装的一个解决方案是我的开源项目Nocilla。使用Nocilla,你可以以声明式的方式对HTTP请求进行封装,无需提及任何HTTP库。Nocilla会处理任何现有的HTTP库的封装,这样你的测试就不会与任何实现细节耦合。这使得你可以在不破坏测试的情况下更换网络堆栈。
另一个例子是删除日期。大多数编程语言都有很多获取当前时间的方法,但像TUDelorean这样的库会处理所有与时间相关的API,以便在测试时模拟不同的系统时间,而不必将测试与那些多个时间API耦合在一起。这让你可以在不破坏测试的情况下将实现更改为不同的时间API。
在HTTP或日期之外的其他领域,可能存在多种API,你可以使用类似的解决方案进行泛化桩处理,或者你可以创建自己的开源解决方案并与社区共享,以便我们其他人可以正确编写测试。
如果你要移除一个依赖项,请务必彻底移除它
这与前面提到的问题密切相关,但这个问题更为常见。我们的生产代码通常依赖于依赖项来完成某些任务。例如,一个依赖项可以帮助我们查询数据库。通常,这些依赖项提供了许多实现相同功能或至少具有相同外部行为的方法;在我们的数据库示例中,你可以使用 find 方法根据ID检索记录,或者使用 where 子句获取相同的记录。问题在于,如果我们只实现了其中一种可能的机制,就会出现问题。如果我们只实现了生产代码使用的 find 方法,但没有实现其他可能性,比如 where 子句,那么当我们决定将实现从使用 find 改为使用 where 时,我们的测试将失败,尽管代码的外部行为没有改变。
今天:UsersController 类依赖于 UsersRepository 类从数据库中获取用户。你正在测试 UsersController ,并且你用桩(stub)替换了 find 方法,以使你的测试运行得更快且具有确定性,这是一件很棒的事情。
明天:你决定将 UsersController 重构为使用 UsersRepository 的新查询语法,这样更易读。因为这是一次重构,你不应该更改你的测试。你更新 UsersController 以使用更易读的方法 where ,以便找到感兴趣的记录。现在你的测试已经失效,因为它们模拟了 find 的方法,但没有模拟 where 的方法。
在某些情况下,使用伞桩可以有所帮助,但在我们使用 UsersController 类的情况下……没有其他库可以从我的数据库中获取用户信息。
解决方案:为测试目的创建一个与原始类具有相同功能的替代实现,并将其用作测试替身。
继续我们的例子,我们应该提供一个 InMemoryUsersRepository 。这个内存中的替代品应该符合由原始 UsersRepository 类建立的合同的每一个方面,除了它将数据存储在内存中以使我们的测试更快。这意味着当你重构 UsersRepository 时,你也需要对它的内存版本进行相同的操作。为了使这一点非常明确:是的,你现在必须维护同一个类的两个不同的实现。
你现在可以将这个轻量级的依赖项作为测试替身提供。好处是它是完整的实现,因此当你决定将实现从一个方法迁移到另一个方法(在我们的示例中,从 find 迁移到 where )时,使用的测试替身已经支持了这个新方法,并且在重构时你的测试不会失败。
维护另一个版本的类是没有问题的。根据我的经验,这实际上需要很少的努力,而且肯定会有所回报。
你也可以将你的类的轻量级版本作为生产代码的一部分提供,就像Core Data将它的内存版本栈作为一部分一样。这样做可能会对某些人有所帮助。
不要测试构造函数
构造函数在定义上就是实现细节的一部分,既然我们同意将测试与实现细节解耦,那么你就不应该测试构造函数。
此外,构造函数不应该有任何行为,因为我们一致认为,我们应该只测试代码的行为,所以没有什么可测试的。
今天:你的类 Car 只有一个构造函数。你测试了这一点,一旦创建了一个 Car 对象,它的 Engine 属性就不会为空(因为你知道构造函数会创建一个新的 Engine 对象并将其赋值给 _engine 变量)。
明天:这个类的构造函数实际上很昂贵,因此你决定在第一次调用 Engine 的getter时懒加载构造它(这是完全可行的做法)。
(b2)类的构造函数测试失败,因为在构造过程中, Car 不再拥有 Engine ,尽管 Car 会完美运行。另一个可能是,你的测试没有失败,因为测试 Car 是否拥有 Engine 触发了 Engine 的懒加载初始化。因此我的问题是:你又在测试什么呢?
做法:测试你的类的公共API在不同构造方式下的行为。一个简单的例子:测试类list中的 count 方法在有无元素的列表构造方式下的行为。请注意,你正在测试的是 count 的行为,而不是构造函数的行为。
如果你的类拥有多个构造函数,那么这可能是一种“气味”。你的类可能承担了太多职责。尝试将它拆分成更小的类。但是,如果你的类确实有合理理由需要拥有多个构造函数,那么只需遵循相同的建议即可。确保测试该类的公共API,以不同的方式构造它。在此情况下,请使用每个构造函数进行测试(即当该类处于这种初始状态时,它会这样行为,而当处于另一种初始状态时,它会那样行为)。