消除测试中的非确定性
自动化回归测试套件在软件项目中可以发挥至关重要的作用,它不仅可以减少生产环境中的缺陷,而且对于演进式设计也至关重要。在与开发团队的交谈中,我经常听到关于非确定性测试的问题——测试有时通过,有时失败。如果不加以控制,非确定性测试会完全破坏自动化回归测试套件的价值。在本文中,我将概述如何处理非确定性测试。首先,隔离有助于减少它们对其他测试的损害,但您仍然需要尽快修复它们。因此,我将讨论非确定性的常见原因的处理方法:缺乏隔离性、异步行为、远程服务、时间和资源泄漏。
2011年4月14日
我很高兴看到 Thoughtworks 解决了许多困难的企业应用程序,为许多很少看到成功的客户带来了成功的交付。我们的经验很好地证明了敏捷方法是可行的,而这种方法在我们十年前撰写宣言时还备受争议和质疑。
敏捷开发有很多流派,但在我们所做的工作中,自动化测试起着核心作用。自动化测试从一开始就是极限编程的核心方法,这种理念一直是我们敏捷工作的最大灵感来源。因此,我们在将自动化测试作为软件开发的核心部分方面积累了丰富的经验。
在教科书中,自动化测试看起来很容易。事实上,其基本思想确实非常简单。但在交付项目的压力之下,会出现一些在教材中往往没有得到太多关注的考验。我非常清楚,作者习惯于略过许多细节,以便阐明核心观点。在我与交付团队的交谈中,我们遇到的一个反复出现的问题是测试变得不可靠,以至于人们不再关注它们是通过还是失败。造成这种不可靠性的一个主要原因是,一些测试变得不确定。
当一个测试有时通过,有时失败,而代码、测试或环境没有任何明显变化时,这个测试就是不确定的。这样的测试失败了,然后你重新运行它们,它们就通过了。这种测试的失败似乎是随机的。
任何类型的测试都可能受到非确定性的困扰,但它尤其容易影响范围广泛的测试,例如验收测试或功能测试。
为什么非确定性测试是一个问题
非确定性测试有两个问题,首先它们是无用的,其次它们是一种恶性感染,会彻底毁掉你的整个测试套件。因此,你需要尽快处理它们,在你的整个部署管道受到损害之前。
我将首先详细说明它们的无用性。进行自动化测试的主要好处是,它们通过充当回归测试[1]来提供错误检测机制。当回归测试变红时,你就知道你遇到了一个直接的问题,通常是因为系统中悄悄地出现了一个错误,而你没有意识到。
拥有这样一个错误检测器有巨大的好处。最明显的是,这意味着你可以在错误被引入后立即发现并修复它们。这不仅会让你感到温暖和模糊,因为你很快地杀死了错误,而且还会让你更容易地消除它们,因为你知道错误是随着你脑海中记忆犹新的最后一组更改而出现的。因此,你知道在哪里寻找错误,这在消除错误的过程中已经成功了一半以上。
第二个好处是,当你对错误检测器有了信心后,你就有勇气进行大的更改,因为你知道当你出错时,错误检测器会发出警报,你可以快速修复错误。[2]如果没有这一点,团队就会害怕对代码进行必要的更改以保持代码的整洁,这会导致代码库的腐烂和开发速度的直线下降。
非确定性测试的问题在于,当它们变红时,你不知道是由于错误,还是仅仅是非确定性行为的一部分。通常情况下,对于这些测试来说,非确定性失败是比较常见的,所以当你遇到这些测试变红时,你最终会耸耸肩。一旦你开始忽略回归测试的失败,那么这个测试就毫无用处,你也可以把它扔掉了。[3]
事实上,你真的应该把非确定性测试扔掉,因为如果你不这样做,它就会具有传染性。如果你有一个包含 100 个测试的测试套件,其中有 10 个非确定性测试,那么这个套件通常会失败。最初,人们会查看失败报告,并注意到失败出现在非确定性测试中,但很快他们就会失去这样做的纪律。一旦失去了这种纪律,那么健康确定性测试中的失败也会被忽略。到那时,你就输掉了整个游戏,还不如把所有的测试都扔掉。
隔离
我在本文中的主要目的是概述非确定性测试的常见情况以及如何消除非确定性。但在我开始之前,我提供一条基本建议:隔离你的非确定性测试。如果你有非确定性测试,请将它们与你的健康测试放在不同的测试套件中。这样,你就可以继续关注你的健康测试的情况,并从它们那里得到良好的反馈。
将任何非确定性测试放在隔离区域。(但要快速修复隔离的测试。)
接下来的问题是如何处理隔离的测试套件。它们作为回归测试毫无用处,但它们确实可以作为清理工作项的未来。你不应该放弃这些测试,因为你在隔离区中的任何测试都无助于你的回归覆盖率。
这里的一个危险是,测试不断被扔进隔离区并被遗忘,这意味着你的错误检测系统正在被侵蚀。因此,有必要建立一种机制,确保测试不会在隔离区停留太久。我遇到过各种各样的方法来做到这一点。一种是简单的数字限制:例如,只允许在隔离区中进行 8 次测试。一旦你达到了限制,你就必须花时间清除所有的测试。如果你喜欢批量处理测试清理工作,那么这种方法的优点是可以批量处理你的测试清理工作。另一种方法是对测试在隔离区停留的时间设置限制,例如不超过一周。
隔离的一般方法是将隔离的测试从主部署管道中移除,这样你仍然可以进行常规的构建过程。然而,一个好的团队可以更加积极主动。我们的 Mingle 团队将其隔离套件放在其健康测试之后的一个阶段的部署管道中。这样,它可以从健康测试中获得反馈,但也必须确保快速解决隔离的测试。[4]
缺乏隔离性
为了使测试能够可靠地运行,你必须清楚地控制它们运行的环境,这样你在测试开始时就有一个众所周知的状态。如果一个测试在数据库中创建了一些数据并将其留在那里,它可能会破坏另一个依赖于不同数据库状态的测试的运行。
因此,我发现专注于保持测试的隔离性非常重要。正确隔离的测试可以以任何顺序运行。当你进入功能测试更大的操作范围时,保持测试的隔离性会变得越来越难。当你追踪非确定性时,缺乏隔离性是一个常见且令人沮丧的原因。
保持测试彼此隔离,这样执行一个测试就不会影响任何其他测试。
有两种方法可以实现隔离——要么始终从头开始重建你的初始状态,要么确保每个测试在结束后都能正确清理。总的来说,我更喜欢前者,因为它通常更容易——尤其是更容易找到问题的根源。如果一个测试失败是因为它没有正确构建初始状态,那么很容易看出是哪个测试包含错误。然而,使用清理方法时,一个测试将包含错误,但另一个测试将失败——因此很难找到真正的问题所在。
从空白状态开始,单元测试通常很容易,但功能测试[5]可能会困难得多,特别是当你的数据库中有很多数据需要存在时。每次都重建数据库会增加大量的测试运行时间,因此这支持切换到清理策略。[6]
当你使用数据库时,有一个技巧很方便,那就是在事务中进行测试,然后在测试结束时回滚事务。这样,事务管理器就会为你进行清理,减少出错的几率[7]。
另一种方法是在运行一组测试之前,对一个基本不可变的初始夹具进行一次构建。然后确保测试不会改变该初始状态(或者如果它们改变了,则在拆卸时撤消更改)。这种策略比为每个测试重建夹具更容易出错,但如果每次构建夹具都需要很长时间,那么这种策略可能是值得的。
尽管数据库是隔离性问题的常见原因,但你也可以在内存中多次遇到这些问题。尤其要注意静态数据和单例。这类问题的一个很好的例子是上下文环境,例如当前登录的用户。
如果你在测试中有明确的拆卸,请注意拆卸过程中发生的异常。如果发生这种情况,测试可能会通过,但会导致后续测试的隔离性失败。因此,请确保如果你在拆卸过程中确实遇到了问题,它会发出很大的噪音。
有些人更喜欢减少对隔离性的强调,而更多地强调定义清晰的依赖关系,以强制测试按指定的顺序运行。我更喜欢隔离性,因为它让你在运行测试子集和并行化测试方面有更大的灵活性。
异步行为
异步性是一种优势,它允许你在承担长期任务的同时保持软件的响应能力。Ajax 调用允许浏览器在返回服务器获取更多数据时保持响应,异步消息允许服务器进程与其他系统通信,而不会受其延迟的影响。
但在测试中,异步性可能是一种诅咒。这里常见的错误是插入一个 sleep
//pseudo-code makeAsyncCall; sleep(aWhile); readResponse;
这可能会让你付出双倍的代价。首先,你会希望将睡眠时间设置得足够长,以便有足够的时间获得响应。但这意味着你将花费大量时间无所事事地等待响应,从而减慢测试速度。第二个代价是,无论你睡多久,有时都不够。环境中会有一些变化导致你超过睡眠时间,你会得到错误的失败。因此,我强烈建议你永远不要像这样使用裸睡眠。
永远不要使用裸睡眠来等待异步响应:使用回调或轮询。
测试异步响应基本上有两种策略。第一种是异步服务采用回调的方式,在完成后进行回调。这是最好的方法,因为它意味着你永远不需要等待超过你需要的时间[8]。这种方法最大的问题是环境需要能够做到这一点,然后服务提供者也需要做到这一点。这也是开发团队与测试团队整合的优势之一——如果他们能够提供回调,那么他们就会提供。
第二种选择是轮询答案。这不仅仅是查看一次,而是定期查看,就像这样
//pseudo-code makeAsyncCall startTime = Time.now; while(! responseReceived) { if (Time.now - startTime > waitLimit) throw new TestTimeoutException; sleep (pollingInterval); } readResponse
这种方法的要点是,您可以将 pollingInterval
设置为一个非常小的值,并且知道这是您等待响应所损失的最大死区时间。这意味着您可以将 waitLimit
设置得非常高,这将最大限度地减少除非出现严重错误,否则不会达到该限制的可能性。 [9]
确保您使用一个清晰的异常类,该类指示这是一个测试超时失败。这将有助于明确说明如果发生这种情况,出了什么问题,并且可能允许更复杂的测试工具在其显示中考虑此信息。
时间值,特别是 waitLimit
,永远不应该是文字值。确保它们始终是可以通过使用常量或通过运行时环境设置来轻松批量设置的值。这样,如果您需要调整它们(并且您将会调整它们),您可以快速调整所有这些值。
所有这些建议对于您期望从提供者那里得到响应的异步调用来说都很方便,但是那些没有响应的调用呢?这些调用是我们对某个东西调用一个命令,并期望它在没有任何确认的情况下发生。这是最棘手的情况,因为您可以测试预期的响应,但除了超时之外,没有任何方法可以检测到故障。如果提供者是您正在构建的东西,您可以通过确保提供者实现某种方式来指示它已经完成来处理这个问题——本质上是某种形式的回调。即使只有测试代码使用它,也是值得的——尽管您经常会发现这种功能对其他目的也很有价值[10]。如果提供者是别人的工作,您可以尝试说服,否则可能会陷入困境。虽然这种情况也是使用测试替身来代替远程服务是值得的(我将在下一节中详细讨论)。
如果异步操作出现一般性故障,例如它根本没有响应,那么您将始终处于等待超时状态,并且您的测试套件将需要很长时间才能失败。为了解决这个问题,最好使用冒烟测试来检查异步服务是否正在响应,如果它没有响应,则立即停止测试运行。
您通常也可以完全避开异步。Gerard Meszaros 的Humble Object 模式指出,每当您有一些难以测试的环境中的逻辑时,您应该将需要测试的逻辑与该环境隔离开来。在这种情况下,这意味着将您需要测试的大部分逻辑放在一个可以同步测试它们的地方。异步行为应该尽可能少(谦虚),这样您就不需要对其进行太多测试。
远程服务
有时有人问我 Thoughtworks 是否进行任何集成工作,我觉得这有点好笑,因为我们几乎没有任何项目不涉及相当多的集成。从本质上讲,企业应用程序涉及大量来自不同系统的数据组合。这些系统由其他团队按照自己的时间表维护,这些团队通常使用与我们高度测试驱动的敏捷方法截然不同的软件理念。
使用此类远程系统进行测试会带来许多问题,非确定性是其中最主要的问题。远程系统通常没有我们可以调用的测试系统,这意味着要访问实时系统。如果有测试系统,它可能不够稳定,无法提供确定性响应。
在这种情况下,确保确定性至关重要,因此是时候使用测试替身了——一个看起来像远程服务的组件,但实际上只是一个模拟远程系统行为的假版本。需要对替身进行设置,以便它以我们控制的方式在与我们系统的交互中提供正确的响应类型。通过这种方式,我们可以确保确定性。
使用替身有一个缺点,尤其是在我们进行广泛范围的测试时。我们如何确定替身的行为方式与远程系统相同?我们可以再次使用测试来解决这个问题,这是一种我称之为契约测试的测试形式。这些测试使用远程系统和替身运行相同的交互,并检查两者是否匹配。在这种情况下,“匹配”可能并不意味着得到相同的结果(由于非确定性),而是结果共享相同的基本结构。集成契约测试需要频繁运行,但不是我们系统部署管道的一部分。通常最好根据远程系统的更改速率定期运行。
为了编写这类测试替身,我非常喜欢自初始化伪造——因为它们非常易于管理。
有些人坚决反对在功能测试中使用测试替身,他们认为您必须使用真实的连接进行测试,以确保端到端行为。虽然我赞同他们的观点,但如果自动化测试是非确定性的,那么它们就毫无用处。因此,您通过与真实系统对话获得的任何优势都会被消除非确定性的需求所淹没[11]。
时间
很少有事情比调用系统时钟更不确定。每次调用它时,您都会得到一个新的结果,并且任何依赖它的测试都可能会因此而改变。要求获取下一个小时内到期的所有待办事项,您会定期得到不同的答案[12]。
这里最重要的是确保始终使用例程包装系统时钟,这些例程可以用种子值替换以进行测试。可以将时钟存根设置为特定时间并冻结在该时间,从而允许您的测试完全控制其移动。这样,您可以将测试数据与种子时钟中的值同步。[13][14]
始终包装系统时钟,以便可以轻松地将其替换以进行测试。
需要注意的一件事是,最终您的测试数据可能会开始出现问题,因为它太旧了,并且您与应用程序中其他基于时间的因素发生冲突。在这种情况下,您可以将数据和时钟种子移动到新值。执行此操作时,请确保这是您唯一要做的操作。这样,您就可以确定任何失败的测试都是由于测试数据中的时间移动造成的。
另一个时间可能成为问题的地方是,当您依赖于时钟的其他行为时。我曾经见过一个系统,它根据时钟值生成随机密钥。当该系统被移动到一台速度更快的机器上时,它开始出现故障,该机器可以在一个时钟周期内分配多个 ID。[15]
我已经听说了很多由于直接调用系统时钟而导致的问题,我认为应该找到一种方法来使用代码分析来检测对系统时钟的任何直接调用,并在那里立即失败构建。即使是简单的正则表达式检查也可能会为您在半夜接到电话后进行令人沮丧的调试工作节省时间。
资源泄漏
如果您的应用程序存在某种资源泄漏,这将导致随机测试失败,因为这仅仅是哪个测试导致资源泄漏超过限制才会导致失败。这种情况很尴尬,因为任何测试都可能由于此问题而间歇性地失败。如果这不是一个测试不确定的情况,那么资源泄漏就是一个很好的调查对象。
我所说的资源泄漏是指应用程序必须通过获取和释放来管理的任何资源。在非内存管理环境中,最明显的例子是内存。内存管理在很大程度上解决了这个问题,但其他资源仍然需要管理,例如数据库连接。
通常,处理这类资源的最佳方法是通过资源池。如果您这样做,那么一个好的策略是将池的大小配置为 1,并在它没有剩余资源可供时收到资源请求时抛出异常。这样,在泄漏后第一个请求资源的测试将失败——这使得查找问题测试变得容易得多。
这种限制资源池大小的想法是关于增加约束,使错误更有可能在测试中出现。这是件好事,因为我们希望错误在测试中显示出来,这样我们就可以在它们在生产中显现出来之前修复它们。这个原则也可以用在其他方面。我听到的一个故事是,一个系统生成随机命名的临时文件,没有正确清理它们,并在发生冲突时崩溃。这种错误很难找到,但有一种方法可以将其暴露出来,那就是对随机数生成器进行存根测试,使其始终返回相同的值。这样,您就可以更快地发现问题。
脚注
1: 是的,我知道许多 TDD 的倡导者认为测试的一个主要优点是它驱动需求和设计的方式。我同意这是一个很大的好处,但我认为回归套件是自动化测试给我们带来的最大的好处。即使没有 TDD,测试也是值得的。
2: 当然,有时测试失败是由于代码应该执行的操作发生了变化,但测试还没有更新以反映新的行为。这本质上是测试中的一个错误,但如果立即发现,也同样容易修复。
3: 非确定性测试有一个有用的作用。从随机数生成器播种的测试可以帮助找出边缘情况。性能测试总是会返回不同的值。但这类测试与自动化回归测试有很大不同,后者是我在这里的重点。
4: 这对 Mingle 团队来说很有效,因为他们有足够的技巧快速找到并修复非确定性测试,并且有足够的纪律性来确保他们快速完成。如果您的构建由于隔离测试失败而长时间保持中断状态,您将失去持续集成的价值。因此,对于大多数团队,我建议将隔离测试排除在主管道之外。
5: 这里没有硬性规定,但我使用的是早期极限编程的术语,即使用“单元测试”来表示细粒度的测试,使用“功能测试”来表示更端到端和与功能相关的测试。
6: 一个技巧是在每次测试运行之前创建初始数据库并使用文件系统命令复制它。文件系统复制通常比使用数据库命令加载数据更快。
7: 当然,这种技巧只有在您可以在不提交任何事务的情况下进行测试时才有效。
8: 尽管您仍然需要一个超时,以防您从未收到回复——而且当您移动到不同的环境时,该超时也会面临同样的危险。幸运的是,您可以将该超时设置为相当高,这将最大限度地减少它咬住您的机会。
9: 但是,在这种情况下,测试将运行得非常缓慢。如果达到等待限制,您可能需要考虑中止整个测试套件。
10: 如果你的异步行为是由用户界面触发的,那么在用户界面中显示异步操作正在进行的指示符通常是一个不错的选择。 将其作为用户界面的一部分也有助于测试,因为停止此指示符所需的钩子可以与检测何时推进测试逻辑的钩子相同。
11: 在这些情况下使用测试替身还有其他优点,即使远程系统是确定性的。 通常,响应时间太慢,无法使用远程系统。 如果你只能与实时系统通信,那么你的测试可能会对该系统造成巨大的、不受欢迎的负载。
12: 你可以根据当前时间为每个测试重新设定数据存储的种子。 但这是一项繁重的工作,而且充满了潜在的计时错误。
13: 在这种情况下,时钟存根是破坏隔离的常见方式,每个使用它的测试都应确保它已正确重新初始化。
14: 我的一位同事喜欢在午夜前后强制运行测试,以捕捉那些使用当前时间并假设它在一两个小时后是同一天的测试。 这在月末的时候尤其有效。
15: 当然,这并不总是一个非确定性错误,而是一个由于环境变化而产生的错误。 根据时钟滴答与 ID 分配的接近程度,它可能会导致非确定性行为。
致谢
像往常一样,我需要感谢许多 Thoughtworks 的同事分享他们的经验,从而为撰写本文提供了素材。
Michael Dietz、Danilo Sato、Badrinath Janakiraman、Matt Savage、Krystan Vingrys 和 Brandon Byers 阅读了这篇文章,并向我提供了一些进一步的反馈。
Ed Sykes 提醒我使用数据库文件的文件系统副本为每个测试创建初始数据库的方法。
重大修订
2011 年 4 月 14 日:首次发布
2011 年 3 月 24 日:发布草案以供 Thoughtworks 内部审查
2011 年 2 月 16 日:开始撰写文章