告别不稳定测试:彻底解决CI重试烦恼的终极方案

本文详细介绍了Evil Martians团队帮助ClickFunnels解决不稳定测试问题的完整方案,涵盖隔离策略、全局状态管理、数据库隔离、外部依赖模拟和浏览器测试优化等核心技术要点,提供可立即实施的代码示例。

不稳定测试,再见:彻底解决慢性CI重试烦恼!

每个开发者都经历过这种痛苦:测试套件在本地通过,但在CI上失败。你点击"重试"并屏住呼吸。它通过了!但这是真正的修复还是只是运气?现在,不再需要运气!我们已经帮助领先的销售漏斗平台ClickFunnels的数十名开发者,将其大规模测试套件(9000+单元测试,1000+功能测试)从不稳定测试的约80%成功率提升到接近100%的可靠性。我们的Evil Martians配方也能解决你的慢性CI重试烦恼!

零容忍不稳定测试

在深入修复不稳定测试之前,第一步是建立零容忍政策。我们需要一个隔离系统,立即隔离不稳定测试,而不是让它们污染CI流水线。

为什么隔离有效:隔离迫使团队承认不稳定测试存在,而不是点击重试按钮并希望最好。这防止了让不稳定性扩散的"没关系"心态。

1
2
3
4
5
6
7
8
9
RSpec.configure do |config|
  config.filter_run_excluding :flaky if ENV["CI"]
end

RSpec.describe "flaky test", :flaky do
  it "works unreliably" do
    # 这个测试不稳定,在CI上被隔离直到修复
  end
end

测试不能无限期隔离。目标不是永久隔离测试,而是要治疗它们。

以下是如何配置最佳实践并在隐藏的不稳定性增长并长期困扰你之前暴露它:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
RSpec.configure do |config|
  # 推荐设置 = 随机排序 + 初始种子用于可重现的随机
  config.order = :random
  Kernel.srand config.seed

  # 多次运行测试以暴露隐藏的不稳定性
  if ENV["DR_TEST_MODE"]
    config.around(:each) do |example|
      3.times { example.run }
    end
  end
end

单元测试不稳定性来源

全局状态

当测试单独通过但在组中失败时,你正在处理在测试之间持续存在并创建隐藏依赖关系的状态。

全局变量、类变量和单例变量

在ClickFunnels,他们遇到了从设置阶段到测试的棘手情况:状态持久存在于污染测试的种子内部。

问题在RequestStore内部,这是一个在线程本地预设置公共变量(如当前用户、站点设置等)的gem。在种子加载期间,它用常见数据预填充RequestStore,这些数据渗入测试,需要双重清理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
RSpec.configure do |config|
  config.before(:each) do
    load_seeds

    # 在种子加载后、每个测试前清除状态
    RequestStore.clear!
  end

  config.after(:each) do
    # 在每个测试后清理状态
    RequestStore.clear!
  end
end

这种双重清除策略消除了整个测试套件中频繁的全局状态停滞。

全局配置和环境变量

配置和环境变量是隐秘的状态泄漏者,使测试根据之前运行的内容通过或失败。ClickFunnels在consider_all_requests_local在测试之间泄漏时遇到了这个问题。

我们需要在每个测试后重置配置和环境以防止污染。解决方案是使用around块进行战略恢复:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
RSpec.shared_context "config helper" do
  def with_config(config)
    original_config = config.map do |key, _|
      [key, Rails.application.config.public_send(key)]
    end
    config.each do |key, value|
      Rails.application.config.public_send("#{key}=", value)
    end
    yield
  ensure
    original_config.each do |key, value|
      Rails.application.config.public_send("#{key}=", value)
    end
  end
end

时间问题

时间相关测试是不稳定性的经典来源:在下午2点通过的测试可能在凌晨2点失败,或在周一通过但在周日失败。

1
2
3
4
5
6
7
8
9
# 不稳定:依赖当前时间
it "fails due to time dependency" do
  expect(report.last_day_of_year?).to be(true)
end

# 更好:使用ActiveSupport时间助手或Timecop
around do |example|
  travel_to Date.parse("2025-12-31") { example.run }
end

数据库状态

事务外修改

Rails通过将每个测试包装在数据库事务中并自动回滚来帮助数据库清理。这种事务方法无缝处理大多数数据清理。

1
2
3
4
# 确保启用事务测试
RSpec.configure do |config|
  config.use_transactional_tests = true
end

然而,before(:all)块在此事务之外创建数据,导致它在测试之间持续存在。

数据库排序

没有显式排序的数据库查询以未定义的顺序返回结果,该顺序可能在运行之间变化。依赖隐式排序的测试有时会通过,有时会失败。

1
2
3
4
5
6
7
8
9
# 不稳定:依赖插入顺序和查询规划器
it "fails due to implicit ordering" do
  expect(User.doctors.map(&:surname)).to eq(["Pasteur", "Fleming"])
end

# 更好:使用contain_exactly进行顺序无关断言
it "uses order-independent assertions" do
  expect(User.doctors.map(&:surname)).to contain_exactly("Pasteur", "Fleming")
end

外部系统依赖

功能标志:ClickFunnels的LaunchDarkly挑战

ClickFunnels广泛依赖LaunchDarkly,有数百个标志控制从UI实验到关键业务逻辑的所有内容。他们的测试由于环境之间未同步的大量标志而偏离生产现实。

由于这种复杂性,我们基于生产转储制作了一个广泛的模拟器系统:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
RSpec.configure do |config|
  config.before(:each) do
    @original_client = Rails.configuration.x.launch_darkly.client
    
    stubbed_client = ClickFunnels::FeatureGates::StubbedClient.new(
      Rails.configuration.x.launch_darkly.client
    )
    Rails.configuration.x.launch_darkly.client = stubbed_client
  end

  config.after(:each) do
    Rails.configuration.x.launch_darkly.client = @original_client
  end
end

外部数据存储

像Redis、Elasticsearch、RabbitMQ和其他消息队列这样的外部数据存储,如果使用真实集成而不是模拟的,会在测试之间维护状态。

1
2
3
4
5
6
7
8
9
RSpec.configure do |config|
  config.after(:each, :with_cache) do
    MyApp.redis.flushdb
  end

  config.after(:each, :with_search) do
    Searchkick.client.indices.delete(index: "*")
  end
end

HTTP请求和外部依赖

这包括API调用、文件存储和在门面后面隐藏HTTP请求的gem。许多gem在你甚至没有意识到的情况下进行网络调用。

使用WebMock防止任何外部HTTP调用并确保测试隔离:

1
2
3
4
5
6
7
gem "webmock"

RSpec.configure do |config|
  config.before(:suite) do
    WebMock.disable_net_connect!(allow_localhost: true)
  end
end

功能测试稳定性

功能测试本质上更容易出现不稳定性:它们在具有JavaScript的真实浏览器中运行,发出HTTP请求,并在完整应用程序栈上断言多个期望。

关键是接受这一现实并建立防御措施。

仅对浏览器测试重试

浏览器测试可能由于临时浏览器环境不稳定而合法失败。谨慎使用自动重试,仅用于浏览器测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
gem "rspec-retry", require: "rspec/retry"

RSpec.configure do |config|
  config.verbose_retry = true
  config.default_retry_count = 0

  config.around(:each, type: :system) do |example|
    example.run_with_retry(retry: 2)
  end
end

基本浏览器配置

由于我们使用实时浏览器环境,我们需要正确准备它。

第一件重要的事是视口一致性,以消除不同计算机上的所有可能多样性,破坏布局相关测试。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
TEST_DEVICES = {
  desktop_full_hd: [1920, 1080],
  mobile_hd: [720, 1280]
}
CAPYBARA_DEFAULT_WINDOW_SIZE = TEST_DEVICES.fetch(:desktop_full_hd)

Capybara.register_driver(:cuprite) do |app|
  options = {window_size: CAPYBARA_DEFAULT_WINDOW_SIZE}
  Capybara::Cuprite::Driver.new(app, options)
end

第二步是通过禁用浏览器中的花哨功能(如干扰元素交互的动画和过渡)来最小化随机性。

可靠的选择器和JS同步

使用测试选择器

可靠浏览器测试的基础始于在UI更改中存活的稳定选择器。

1
2
3
4
5
6
7
8
9
Capybara.configure do |config|
  config.test_id = "data-testid"
end

Capybara.add_selector(:test_id) do
  xpath do |locator|
    XPath.descendant[XPath.attr(Capybara.test_id) == locator]
  end
end

始终使用等待选择器

常见的误解是等待时间是测试将等待的最大时间。实际上,Capybara在等待期间多次重复搜索,因此成功的匹配通常比超时完成得快得多。

1
2
3
4
5
6
7
8
9
# 更好:等待匹配器,重试直到找到或超时
it "uses proper waiting selectors" do
  page.first(".navigation-menu").click
end

# 更好:等待动态内容加载
it "waits for dynamic content" do
  expect(page).to have_css(".dynamic-content", text: "Loaded")
end

capybara-lockstep用于JS同步

现代Web应用程序是重度异步的,具有Hotwire、React、Inertia和大量AJAX请求。

使用capybara-lockstep,Capybara在执行下一个匹配器之前等待所有JavaScript异步交互完成,保证页面就绪:

1
2
3
4
5
6
7
gem "capybara-lockstep"

# 每个布局入口点需要这行魔法代码
<%= capybara_lockstep if defined?(Capybara::Lockstep) %>

# 当Rails繁忙时阻止Capybara
config.middleware.insert_before 0, Capybara::Lockstep::Middleware

卡住测试运行缓解

有时测试不仅不稳定失败,它们完全冻结。没有输出,没有进展,只有沉默直到CI超时。

让我们通过sigdump的所有线程回溯转储为冻结测试运行添加一些可见性:

1
2
3
4
5
6
gem "sigdump"

ENV["SIGDUMP_PATH"] = "+"
ENV["SIGDUMP_SIGNAL"] = "TERM"

require "sigdump/setup" if ENV["CI"]

ClickFunnels遇到了两个特定的死锁场景。第一个涉及多个数据库和查询缓存,Rails在Capybara运行期间可以死锁自身。第二个更棘手:一个自定义Capybara匹配器,其循环缺乏超时保护。

TLDR:Evil Martians配方,或你的测试套件值得更好

不稳定测试通常不被认为那么重要,但考虑真实成本!一个开发者每天花费精力处理它们和重试。然后,在你的团队中乘以这个,加上部署延迟,对CI失去信心,“这个测试失败是真的吗?“的压力,你正在看着巨大的生产力流失。

Evil Martians配方完全消除了这种时间浪费!

以下是消除不稳定测试的完整成分,供你使用:

  • 零容忍政策 - 立即测试隔离,系统审查过程
  • 单元测试不稳定性来源 - 全局状态清理,数据库隔离,外部依赖消除和模拟,可靠测试设计
  • 功能测试稳定性 - 战略重试,浏览器环境设置,稳定和等待选择器,JS同步
  • 卡住测试运行缓解 - 卡住测试sigdumping,死锁检测
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计