Rails测试套件与CI时间减半优化实战

本文详细记录了如何通过性能分析、工厂优化、并行化改造和修复不稳定测试,将Rails测试套件运行时间从25分钟减少到12分钟,CI时间从4分钟减少到2分钟的全过程优化实践。

The Whop Chop:我们如何将Rails测试套件和CI时间减半

第一部分:性能分析与隐藏的日志问题

在修复缓慢的测试套件之前,必须了解时间实际消耗在哪里。猜测是浪费时间,性能分析至关重要。我们本次调查的主要工具是TestProf和强大的StackProf采样分析器。

调查

为了初步了解测试运行情况,我们执行了单个命令来分析200个随机测试样本:

1
TEST_STACK_PROF=1 SAMPLE=200 bundle exec rspec spec

采样在这里至关重要;尝试分析由数千个测试组成的完整套件会产生大量无意义的数据。

上述命令在tmp/test-prof/目录内生成stack-prof-report-wall-raw-total.json文件。然后我们可以使用免费的speedscope.app在浏览器中打开此文件。

在Speedscope内部,“Sandwich"视图通常最具洞察力。它显示方法列表,按在它们内部(以及它们调用的方法)花费的总时间排序。

初始分析快速指出了几个可疑点:

  • 繁重的数据库活动(Trilogy#query),表明存在大量工厂或查询
  • 调用应被模拟的外部服务,如Stripe和OpenAI
  • 最奇怪的是,大量时间花费在日志记录上

隐藏的日志记录器

测试环境中的日志记录几乎总是纯粹的开销。我们的StackProf报告直接指向了与日志记录相关的活动,而主要罪魁祸首竟然隐藏在显而易见的地方:Rails自身的详细SQL日志记录。

结果发现,每个数据库查询都被细致地写入磁盘,并带有性能标签。这对于开发中的调试很有用,但在涉及数千次查询的测试运行期间会造成巨大的I/O瓶颈。

禁用此功能为我们带来了整个项目中最大的性能提升。在config/environments/test.rb中的修复很简单:

1
2
3
4
5
6
7
8
# config/enments/test.rb

# 这两行是主要减速的根源
config.active_record.verbose_query_logs = false
config.active_record.query_log_tags_enabled = false

# 我们还提高了日志级别以进一步减少I/O
config.log_level = :fatal

在调查过程中,我们注意到另一个较小的日志噪音来源:自定义Sentry记录器(app/services/sentry_log.rb)仍然处于活动状态,即使主gem已在测试环境中禁用。

为了消除这个次要开销,我们借鉴了Sidekiq::Testing.fake!的思路,并实现了一个方法以在测试期间完全禁用Sentry:

1
2
3
4
5
6
7
8
# spec/rails_helper.rb

RSpec.configure do |config|
  config.before(:suite) do
    # ... 其他设置
    Sentry::Testing.fake!
  end
end

实现2倍速度提升

这些更改的影响是立竿见影且惊人的。它们共同将总测试时间减少了一半:

单进程运行:

  • 之前:约25分钟
  • 之后:约12分钟

CI运行(16个并行进程):

  • 之前:约4分钟
  • 之后:约2分钟

禁用详细查询日志完成了繁重的工作,Sentry优化提供了额外的增益。主要通过消除ActiveRecord自身的嘈杂日志记录,我们将测试速度提高了一倍。

随着最重要的瓶颈消失,是时候寻找较小的胜利了。

第二部分:驯服工厂野兽

随着日志记录怪物被消灭,我们的分析器指向了一个新的、更可预测的罪魁祸首:对象创建。在大多数Rails测试套件中,这意味着FactoryBot。我们的目标是减少创建的昂贵数据库记录数量,一次一个测试。

使用TagProf获得鸟瞰图

首先,我们需要确认我们的怀疑,TestProf的TagProf非常适合这类任务。它按"标签"分组分析数据,使我们能够看到哪些类型的事件最昂贵。

我们配置它来监视ActiveRecord查询、工厂创建和Sidekiq作业:

1
2
3
TAG_PROF_FORMAT=html TAG_PROF=type \
TAG_PROF_EVENT=sql.active_record,factory.create,sidekiq.inline \
bundle exec rspec

生成的HTML报告非常清晰:factory.create事件现在负责超过总测试执行时间的一半。

策略1:let_it_be

常见的测试模式是为组中的每个示例创建相同的基本数据。

TestProf的let_it_be就是这里的解决方案。与RSpec的标准let不同,它为整个describe块创建一次记录,在所有示例中重复使用。

这是一个强大的工具,但它不是银弹。你必须小心不要将其用于被一个测试修改状态的对象,因为该状态可能"泄漏"到下一个测试中,导致不稳定的结果。

策略2:FactoryDefault

第二个更微妙的问题是冗余关联创建。

通常,一个模型的工厂会触发其他几个关联模型的创建(一个体验创建一个机器人,机器人创建一个用户,依此类推)。

为了查找此类情况,我们在之前确定的特定重型测试文件上使用了FactoryDefault的分析器:

1
FPROF=1 FACTORY_DEFAULT_PROF=1 bundle exec rspec spec/services/some_heavy_spec.rb

此命令为我们提供了两个非常有用的表格。

第一个"Factory associations usage"显示哪些对象作为其他工厂的副作用被创建。

在我们的案例中,我们可以看到userappbot各自被创建超过20次,仅用作关联,每个花费超过10秒的测试时间。

第二个表格显示整体工厂使用情况,区分"顶级”(显式)创建和包括关联的"总"计数。

这里的数据很有启发性。我们看到用户工厂被显式创建(顶级)19次。然而,创建的总数是46。差异(27个隐式创建)与关联使用表中的计数完全匹配。

修复方法是使用create_default。这会创建一个常用关联的实例,并告诉该测试中的所有后续工厂重复使用它,而不是每次都创建新的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# spec/services/some_service_spec.rb
describe "Some Service" do
  # 任何需要:user的工厂现在将重复使用这个
  # 而不是每次都创建一个新的。
  let!(:user) { create_default(:user) }

  it "does something with a widget" do
    widget = create(:widget) # 隐式使用默认用户
    # ...
  end

  it "does something with a gadget" do
    gadget = create(:gadget) # 也隐式使用默认用户
    # ...
  end
end

通过首先识别最慢的测试文件(使用rspec --profile或TestProf的Time-per-example分析器(TPSProf)),然后在其上运行FactoryDefault分析器,我们能够精确优化最严重的违规者。

我们正在取得稳步进展,但也接近了收益递减点。为了获得下一个大的性能飞跃,我们必须超越使单个测试更快。我们需要更有效地同时运行它们。

第三部分:进入并行宇宙

我们通过分析、修补和优化的方式实现了2倍的速度提升。套件更快了,但我们知道我们可以进一步推动它。

最后的边界是真正的并行化。

parallel_tests的限制

与许多Rails项目一样,Whop使用的是值得尊敬的parallel_tests gem。它是一个主力,但有一个基本限制:它通过在一组进程之间拆分测试文件来工作。

因此,如果一个工作进程恰好得到一组缓慢、繁重的功能规范,它将在其他工作进程完成快速单元测试并空闲坐着后长时间工作。而总构建时间总是由最慢的工作进程决定。

更公平的模型:test-queue

为了解决这个问题,我们决定切换到test-queue,它有一个更高效的模型。

test-queue不是预先分配文件,而是创建一个单个测试示例的中央队列。工作进程完成测试后,立即从队列中获取下一个。这确保所有工作进程保持忙碌直到最后,承诺减少20-40秒的CI时间。

一片红色

我们实施了切换,推送了更改,并观察了CI管道的运行。结果?混乱!

超过100个测试失败。错误很奇怪且看似随机,与我们的更改没有明显联系。

例如:

1
2
3
6) Types::Output::CreatorDashboardTableType ... returns the correct traffic sources 
Failure/Error: expect(subject["..."]["trafficSource"]).to eq("direct") 
Expected "whop" to eq "direct".

和:

1
2
3
7) Discovery::FetchContentRewardsCampaigns returns the campaigns ordered newest correctly 
Failure/Error: expect(result[0].id).to eq(low_paid_campaign.id) 
Expected 691 to eq 693.

单独运行时测试正常,但在test-queue真正随机、高并发环境中崩溃了。

我们不仅切换了运行器;我们还将测试套件置于显微镜下,无情地暴露了每个隐藏的依赖关系和测试之间的每个"泄漏"状态。

因此,我们的性能追求转变为调试奥德赛。所以,为了使我们的测试更快,我们首先必须使它们完全隔离。

是时候一个一个地追捕这些不稳定的野兽了。

第四部分:隔离指南

如果不可靠,快速的测试套件是无用的。我们的新test-queue设置暴露了多年的隐藏状态泄漏。我们的工作是追捕每一个"不稳定的野兽"并强制执行完美的测试隔离。

这是那次追捕的故事。

案例研究#1:时间旅行和悖论

对我们来说,最常见和最令人困惑的不稳定问题都与时间有关。

全局泄漏

起初,我们看到任何带有日期过滤器的测试都普遍失败。原因很简单:测试会使用Timecop.freeze但从不调用Timecop.return。这个"冻结"的时间然后会泄漏到该工作进程运行的下一个测试中。修复方法是在rails_helper.rb中建立一个强大的全局安全网:

1
2
3
4
5
6
# spec/rails_helper.rb
RSpec.configure do |config|
  config.after(:each) do
    Timecop.return
  end
end

Doorkeeper之谜

这个安全网修复了大多数问题,但最具挑战性的谜团仍然存在:在使用Doorkeeper的控制器规范中随机出现403 Forbidden错误。

为了找到这个状态泄漏的源头,我们转向了RSpec的终极工具:--bisect。通过输入来自失败CI运行的种子,我们可以自动追捕重现错误的最小测试序列:

1
bundle exec rspec spec/controllers --bisect --seed 12345

RSpec指向events_controller_spec.rb中的一个测试。根本原因很微妙,但在并行世界中是致命的。测试会将时间冻结到过去的日期。当并行工作进程上的另一个测试创建有效的Doorkeeper令牌时,它是在当前时间创建的。但是当验证该令牌时,来自其他测试的"冻结"过去时间使其看起来已过期,导致403。

修复方法非常简单:Timecop.freeze调用对于测试的断言完全是不必要的。删除那一行修复了整个级联故障。

案例研究#2:清理全局污染

其他几个问题遵循类似的模式:测试会改变全局状态,但未能自行清理。

常量冲突:

我们看到NoMethodError,因为两个不同的测试文件定义了具有完全相同名称但不同值的常量。由于常量是全局的,最后运行的会获胜。

修复:我们将常量重命名为每个文件唯一。

泄漏的缓存和配置:

PermissionsManager在内存中全局缓存角色,导致陈旧数据在测试之间泄漏。

修复:在rails_helper.rb中添加清理钩子,在每次运行前调用PermissionsManager::SystemRoles.clear_cache!

一个规范初始化了一个全局gRPC客户端但从未重置它。

修复:添加了一个after钩子将配置重置为nil。

Sidekiq Enterprise unique!模式泄漏:

这是一个特别有趣的案例。

问题是特定测试需要验证Sidekiq Enterprise的unique!模式的行为。然后这个全局更改会"泄漏"出去,导致connected_account_spec.rb中一个完全不相关的测试失败,因为它期望排队的作业被唯一性逻辑静默过滤掉。

我们在这里也有一个困境:官方Sidekiq Enterprise文档明确警告这一点,建议开发者在测试中禁用唯一性以避免"很多头疼"。然而,对于Whop的这个功能,测试唯一性逻辑本身至关重要。我们不能简单地忽略它。

修复:挑战是在完美的隔离中测试这个"不可测试"的功能。解决方案是一个精心制作的around钩子,创建一个临时的、安全的"沙箱"。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# 官方文档建议不要这样做,所以我们建立了一个堡垒。
# 这个钩子为测试Sidekiq Enterprise的唯一作业功能
# 创建一个完美的沙箱,而不会泄漏到其他测试中。
around do |example|
  # 1. 开启全局唯一作业中间件
  Sidekiq::Enterprise.unique!

  # 2. 在这个启用唯一性的状态下运行测试
  example.run
ensure
  # 3. 保证清理,即使测试失败。
  #    首先,精确移除中间件...
  Sidekiq.configure_client do |config|
    config.client_middleware do |chain|
      chain.remove Sidekiq::Enterprise::Unique::Client
    end
  end

  # 4. ...然后将Sidekiq测试重置回其默认状态。
  Sidekiq::Testing.fake!
end

关键是ensure块,它保证我们的测试无论如何都会自行清理。它不只是重置测试模式;它会精确移除unique!添加到客户端配置的特定中间件。

这使我们能够自信地测试关键的基础设施逻辑,同时确保它对测试套件其余部分的稳定性零影响。

更快、更强、更好

我们加速Whop测试套件的旅程始于一个简单的目标:使测试更快。

除了更快的测试套件,我们还获得了一个更健壮、可靠和有纪律的套件。

修复隐藏日志记录器带来的初始2倍速度提升是一个巨大的胜利,但真正的奖品来自于对抗不稳定测试。通过切换到test-queue,我们迫使自己面对每个泄漏的抽象和有状态的依赖,套件因此变得更好。

让我们为您留下这个案例的一些关键经验:

  • 您最大的性能瓶颈很少在您认为的地方。像StackProf这样的工具是您最好的朋友,在更改任何代码之前应该使用它们。
  • 工厂使用通常是下一个最大的瓶颈,像let_it_becreate_default这样的工具可以显著减少冗余工作。
  • 当心隐藏的I/O。日志记录是 notorious 的性能杀手。不仅要仔细检查Rails的默认值,还要检查任何可能写入磁盘或网络的第三方gem,如Sentry。
  • 采用像test-queue这样更公平的并行运行器是一个强大的优化,但它也是您套件的终极压力测试。它将无情地暴露您测试隔离中的每个缺陷。
  • 每个测试必须完全自行清理。所以,掌握像rspec --bisect这样的工具来追捕泄漏,并使用像around钩子这样的健壮模式来包含修改全局状态的测试。做一个好公民!
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计