[翻译] 时而失败的测试

0.418字数 5383阅读 360

原文链接:Tests that sometimes fail

撒谎者即使说真话,也不会被人相信 —— 伊索

软件项目一旦有些年头又有很大的自动化测试集,有种丑陋的模式就会出现。

有些本来工作的测试,变得“有时”工作。开始影响甚微,“哦,那个测试啊,有时候就是会挂掉,再点一次build就好了”。但放任不管,它会很快滚雪球式的使整个测试集瘫痪。

大部分开发人员都见过这个问题。他们把这种测试叫做“不确定测试”,“易碎的测试”,“随机的测试”,“不稳定的测试”,“脆弱的测试”,“闪烁的测试”,甚至“海森堡测试” (见不确定性原理

命名是件难事,看起来这种有毒的模式并没有唯一标准的名称。多年来在Discourse我们用各种不同的名字称呼它,为了统一起见,本文用易碎测试这个最常见的说法。

有很多探讨易碎测试问题的文章。

早在2011年,Martin Fowler写道:

非确定性测试有两个问题,首先它没啥用处,其次它会像病毒一样传染彻底毁坏整个测试集。

对此我想补充说,易碎的测试对企业是不可承受的成本。它们的维修成本极高,需要数小时甚至数天的调试,而且它们阻塞持续部署流水线,让发布功能变慢。

有一点我与Martin看法不同。我发现有时易碎的测试也是有用的,可以发现应用中潜在的缺陷。有些时候本来是修正易碎测试,结果修复的是应用代码而非测试代码。

本文我想探讨在Discourse 观察到的模式,以及我们采用的缓解策略。

Discourse中出现的模式

几个月前我们制定了一个游戏。

在我们的Discourse开发实例上我们创建了一个主题。每次由于易碎测试导致测试集挂掉我们就会把这个主题分配给当初写这个测试的开发人。当开发搞清楚原因修复后就会发一个跟帖。

这有助于我们了解可以采取哪方法来修复易碎测试,并且增加了易碎测试问题的可见性。这是非常重要的第一步。

接下来我开始根据https://review.discourse.org/tags/heisentest的修正对易碎测试进行归类。

最近,我们构建了一个系统持续的在digital ocean上跑测试集并标记易碎测试(我们会临时禁用它)

由此发现了一些有趣的导致易碎测试的模式,值得在这里分享。

硬编码的ID

有时在测试中为了省事我们假造。

user.avatar_id = 1
user.save!

# 然后修改 avatar
user.upload_custom_avatar!

# 这里错了,  #1号上传从来都不存在, 
# 因而我们新创建的avatar 的ID 是 1. 
assert(user.avatar_id != 1)  

差不多就是这个例子的样子。

Postgres经常使用 sequences 来产生新record的id。从一开始递增。

大部分测试框架运行测试后会回滚数据库事务,然而回滚不会重置sequence。

ActiveRecord::.transaction do
   puts User.create!.id
   # 1
   raise ActiveRecord::Rollback
puts 

puts User.create!.id
# 2

这会造成大量的易碎测试。

在理想的世界中“起始状态”应该是原始的并且100%可预测。然而Postgres以及许多其他数据库的这个特性意味着我们需要考虑每次都稍有不同的起始状态。

这就是为什么当涉及数据库时几乎很少看到这样的测试:

t = Topic.create!
assert(t.id == 1)

另一个很棒的简单例子

随机数据

偶尔易碎测试会凸显应用中的缺陷。这里有个这种测试的例子

data = SecureRandom.hex
explode if data[0] == "0"

当然现实中没人会这么写代码。然而,有些罕见的情况下bug可能就深藏在生产代码中的奇特条件下。

如果测试集使用随机的数据就有可能暴露这个缺陷。

对数据库顺序的糟糕假定

create table test(a int)
insert test values(1)
insert test values(2)

多年来我多次看到开发人员(也包括我)错误地假定如果从上面的样例数据中select第一行,一定会得到1

select a from test limit 1

上面的SQL结果可能是1也可能是2,具体取决于一系列因素。如果要保证顺序,需要:

select a from test order by a limit 1

这种有问题的假设可能产生易碎测试。有些情况下,测试本身是“好的”但是它所测试的代码大部分时间是靠侥幸工作的。

这里有一个例子,以及另外一个例子

一个很好的演示:

[8] pry(main)> User.order('id desc').find_by(name: 'sam').id
  User Load (7.6ms)  SELECT  "users".* FROM "users" WHERE "users"."name" = 'sam' ORDER BY id desc LIMIT 1
=> 25527
[9] pry(main)> User.order('id').find_by(name: 'sam').id
  User Load (1.0ms)  SELECT  "users".* FROM "users" WHERE "users"."name" = 'sam' ORDER BY id LIMIT 1
=> 2498
[10] pry(main)> User.find_by(name: 'sam').id
  User Load (0.6ms)  SELECT  "users".* FROM "users" WHERE "users"."name" = 'sam' LIMIT 1
=> 9931

即使聚合索引的主键是id,也不能保证结果以id排序,除非明确指定。

关于时间的错误假设

我的测试集不脆弱,除了11AM UTC到1PM UTC这段时间。

在我们的一些特殊的测试里曾经发生过很有趣的事。

如果我在9:50 am左右提交代码,测试集有时会失败。问题在于悉尼时间10 am是UTC时间的12 am(取决于是否夏令时)。这个时间正是一些报表系统的切换时点来把数据归入“今天”或“昨天”集合。

这意味着如果我们把数据插入数据库并从报表获取集合,在一天里的一个特殊时段测试会返回错误的数字。这对于澳大利亚人来说真是不爽而且很不公平。

这里是一个示例 (尽管同样的代码在以前的迭代中一直可以工作)

一般来说我们用假造时间的方法解决这类问题。测试设定时间为1 pm UTC 2018,然后做些操作,在让时间向前一点,再做其它操作,诸如此类。在Ruby中我们使用我们自己的freeze time ,JavaScript中使用Sinon.JS。还有很多其它的解决方案包括timecop,迷人的libfaketime 等。

我还见过由于 sleep造成的问题:

sleep 0.001
assert(elapsed < 1) 

看起来很显然sleep 1毫秒时间流逝肯定少于1秒。但是这个显而易见的假定有时候是不正确的。在极端高负载的情况下CPU的调度可能滞后。

另一个我们遇到过的时间相关问题是超时时间不够,曾经困扰过我们的JS测试集。我们有很多集成测试需要一系列的事件,点击按钮,然后检查屏幕上的元素。我们会设定超时作为安全措施,以防止有些bug发生后JS测试停止执行,无休止的等待某个元素被渲染。但是设定正确的超时时间非常棘手。在super taxed AWS实例上Travis CI需要设置长很多的超时。有时还会与其它问题交织在一起,比如资源泄露导致JS测试变慢结果需要越来越久的超时。

泄露的全局状态

测试通常需要崭新的初始状态以保证始行为终如一。
如果测试修改了全局变量但是没有把它重置回原始状态,就会造成易碎测试。
这里有个例子

class Frog
   cattr_accessor :total_jumps
   attr_accessor :jumps

   def jump
     Frog.total_jumps = (Frog.total_jumps || 0) + 1
     self.jumps = (self.jumps || 0) + 1
   end
end

# 只有作为第一个测试执行时正确
def test_global_tracking
   assert(Frog.total_jumps.nil?)
end

def test_jumpy
   frog = Frog.new
   frog.jump
   assert(frog.jumps == 1)
end 

先运行test_jumpy 然后运行 test_global_tracking 会失败。换个顺序就不会。

由于使用了分布式缓存以及各种其它全局注册表,我们往往会碰到这类问题。这是个取舍的艺术,一方面我们缓存很多状态来加速应用,另一方面我们不希望有不稳定的测试集或者无法捕获回归问题的测试集。

为了缓解这个问题,我们总是以随机顺序执行测试(这样更容易抓到测试中的顺序依赖问题)。我们还有很多清理状态的公用代码以避免这种程序员常常遭遇的状况。这也是个取舍的艺术,我们的清理代码不能做的太多以至于造成测试集大幅减速。

关于环境的糟糕假设

你的测试集里大概不会有这样的测试。

def test_disk_space
   assert(free_space_on('/') > 1.gigabyte)  //根目录可用空间大于1G
end

这个测试是在说,在实现代码的深处藏着一些因为机器状态略有区别的程序。

这里有个我们遇到过的例子

我们有个测试用来检查从远端源下载图片的内部实现。然而在我们的实现里有个安全措施,确保只有机器上有足够空间时才会发生这种行为。这种限制意味着如果你在一台硬盘资源紧张的机器上运行测试集,有些测试就会失败。

在我们的实现代码中有基于各种环境条件的安全措施,在写测试时我们要确保对其进行声明。

并发

Discourse 有一些依赖于多线程的子系统。MessageBus 使用后台线程监听Redis channel,来支持网站的实时更新,缓存同步等。“defer” 是我们的短暂延迟队列,用来支持非常短暂的非关键任务,这些任务可能长时间等待IO,造成对请求处理的劫持(在我们的设置中有时请求时间达到10秒乃至100秒)。 我们用background scheduler处理循环任务。

这里有个例子.

这类问题一般来说都非常难以debug。有些情况下,我们只是在测试模式中禁用一些模块以保证一致性,比如把延迟队列改为内联方式。此外我们还把多线程模块从巨石系统中移出。我发现相对于执行缓慢的巨石系统中的一个子模块,对于一个运行只需要5秒钟的gem,查找并修复测试集中的并发错误要简单得多。

我用过的另一个技巧是模拟事件循环,通过驱动事件脉搏在单线程中模拟多线程。Join工作线程并且等待线程结束以及很多puts调试

资源泄漏

我们的JavaScript集成测试集差不多是最难稳定化的测试。这些测试覆盖了很多的实现代码,需要在Chrome web driver中运行。如果有几个事件handler被忘记正确地清理,几千个测试执行后就会导致资源泄漏,让本来很快的测试逐渐变慢甚至反复无常的失败。

为了解决这些问题,我们考虑在测试执行后进行v8内存堆dump来监控测试集执行后chrome的内存使用情况。

值得注意的是,这个问题造成了让人迷惑的状态,测试在生产CI上总是成功,在Travis CI环境上却总是失败。因为后者的资源更为紧张。

缓解模式

多年来我们学到了一些可用的策略来解决这个问题。这些策略有些涉及编码,有些涉及讨论。可以说最重要的第一步是承认这是个问题,并且作为一个团队一起决定怎么面对它。

与团队开始坦诚的讨论

你应该怎么处理易碎的测试?你可以不停的运行它们直到通过。你可以删除他们。你可以隔离然后修复它们。你也可以当作它没有发生。

在Discourse我们选择隔离并修复。不过老实说,有些情况下我们选择忽略或者干脆删掉。

我不确定是否有完美的解决方案。

🗑️“删除并遗忘” 可以省钱,代价是损失一点测试覆盖率以及修复潜在bug的机会。如果你的测试集处于非常不稳定的状态,这个方案能让你很快回到正常状态。作为开发人员我们常常会不假思索的评判”删除并遗忘“方法糟透了。这的确是剂猛药,有些人可能会评价这是懒惰而危险。然而,如果预算非常紧张这可能是你唯一的选择。我有一个很好的论证,对于相同的代码库,100个测试,100%通过的测试集,要好过200个测试,一半一半成功率的测试集。

🔁“跑到它通过” 是另一种想要鱼与熊掌兼得的方案。你可以让你的构建保持“绿灯”同时还不用去修正易碎测试。同样,这个方案也可以被认为是有些“懒惰”。这种方法的缺点是有可能置有问题的实现代码不顾,并且因为重试使测试集变慢。此外,某些情况下“跑到它通过”可能在CI上总是失败,在本地环境总是成功。那么多少次重试才是你应该选的?2次?10次?

🤷‍♂️“置之不理” 听起来可能会让很多人震惊,其实出人意料的的普遍。人很难下决心放弃精心编写的测试。损失厌恶的本性意味着大多数人无法接受失去一个测试的主意。他们只是说“这次构建不太稳定,有时候就是会失败”然后重新开始构建。我也这样做过。修复易碎测试有可能非常非常困难。有些情况下涉及大量环境和很多的界面,比如全应用的集成测试。要找到罪魁祸首就如同大海捞针。

☣️"隔离并修复" 一般是我最喜欢的方案。你”跳过“测试并让测试集一直提醒有一个被跳过的测试。暂时损伤一些覆盖率直到将测试修正。

并没有放之四海皆准的准则。即使在Discourse我们也是处于“置之不理”和“隔离并修复”之间的地带。

所以说,进行一次你们准备如何对付易碎测试的内部讨论是“至关重要”的。很可能你们正在处理的方式根本不是你们希望的,而是逐步演变而来的行为。
讨论这个问题给了你们争取一搏的机会。

除非构建绿灯否则不能部署

多年以来在Discourse我们已经采用持续部署。这是我们的最后防线。没有这道防线我们的测试集可能早已被污染乃至毫无用处。

总是以随机顺序执行测试

在Discourse的早期我们就选择用随机顺序执行测试,这可以暴露由于顺序依赖造成的易碎测试。通过在日志中记录产生随机顺序的种子,我们可以重现因为顺序依赖造成的失败测试。

悲哀的是 rspec bisect的作用有限

当面对易碎测试时很容易作出假设他们都是由于顺序造成的。顺序相关的易碎测试可以很简单的重现。通过二分查找可以保持顺序的同时减少运行测试的数量,直到找到最小的重现错误的集合。比如随机种子7顺序中#1200号测试失败,经过一系列自动化黑魔法操作,你可以找出来#22,#100,#1200的顺序造成了失败。理论上而言这个方式非常有效,但是其实有两个陷阱需要注意。

  1. 如果二分查找过程中触发了非顺序依赖造成的失败,那么你可能不能找到所有的易碎测试。整个流程会以非常让人困惑的结果失败。
  2. 根据我们对自己代码库的了解,大多数易碎测试都不是顺序依赖造成的。所以这套操作往往是代价高昂的乱枪打鸟。

持续地猎杀易碎测试

最近在Discourse Roman Rizzi 引入了一个新系统来寻找易碎测试。我们在云端一遍又一遍不断运行我们的测试集。每次有测试失败就会对它们标记,在周末我们会把易碎测试标为“跳过”等待修复。

这套机制增加了测试集的稳定性。有些易碎测试1000次执行才会失败一次。如果只是每次提交才执行测试,要经过很久的时间才能找到这些少见易碎测试。

隔离易碎测试

这是你可用的最重要的工具之一。把易碎测试标为“跳过”是非常合理的方法。不过还是有一些问题需要探讨:

  • 是否是环境易碎而非测试本身?也许有个内存泄漏问题而失败的测试仅仅是触发了阈值?
  • 你能否自信的通过某些自动化度量确定一个测试确实是易碎的?

这有点像是“艺术”而且很大程度上取决于你和你的团队的舒适区。我的建议是更积极的隔离。多年来我遇到的情况大都是情愿更早隔离测试以免造成反复的失败。

不断反复地以随机顺序运行易碎测试来排查错误

一般来说处理易碎测试的最大困难是很难重现。为了加速反馈循环我一般会循环执行易碎测试。

100.times do
   it "should not be a flake" do
      yet_it_is_flaky
   end
end

这个简单的技术可以帮助找到各式各样的易碎测试。有些情况下在循环中执行多条测试更合理,有些情况下在循环前删除数据库和Redis从头开始更合理。

致力于快速测试集

多年来在Discourse我们致力于加速测试集。然而这需要权衡,一方面你手头最好的测试是覆盖很多代码的集成测试。你不想牺牲测试集的质量来增加速度,而是要去除大量无意义的重复工作。

快速的测试集意味着

  • 更快的发现易碎测试
  • 更快的调试易碎错误
  • 开发人员更倾向于执行全套的测试集并可能触发某些易碎测试

目前Discourse有11000个Ruby测试,在我的PC上单线程执行需要5分40秒,并行执行需要1分15秒。

要达到这样的速度需要定期的“速度维护”。最近我们做过的有趣的事有:

  • Daniel Waterworth 向测试集中引入了test-prof并且对把很多测试改用let_it_be我们叫它fab!因为它配置功能棒极了)来提速。预配置可以很大地提高速度来获得预置测试数据而不必被预置脚本的局限所困。
  • David Taylor引入了parallel tests gem可以并行的执行测试集,这样我每次跑全量测试都能省下4分钟时间。Rail 6即将原生支持并行测试,多亏Eileen M. Uchitelle 和Rails core team的努力。

除此之外整个团队进行了大量的改进来为测试提速。这始终是我们的重点。

为无法重现的易碎测试添加专门的诊断代码

我用来调试易碎测试的杀手锏就是增加调试代码。

比如这个例子.

有时候不论我怎么努力都无法在本地重现。诊断代码意味着下次易碎测试再被触发时,我有机会搞清楚是什么状态导致了失败。

def test_something
   make_happy(user)
   if !user.happy
      STDERR.puts "#{user.inspect}"
   end
    assert(user.happy)
end

继续探讨

你有什么有趣的关于易碎测试的故事?你的团队用什么方法来对付它?希望能在文章评论区听到你的见解。

延伸阅读

推荐阅读更多精彩内容