The Whop Chop:我们如何将Rails测试套件和CI时间减半
第一部分:性能分析与隐藏的日志问题
在修复缓慢的测试套件之前,必须了解时间实际消耗在哪里。猜测是浪费时间,性能分析至关重要。我们本次调查的主要工具是TestProf和强大的StackProf采样分析器。
调查
为了初步了解测试运行情况,我们执行了单个命令来分析200个随机测试样本:
|
|
采样在这里至关重要;尝试分析由数千个测试组成的完整套件会产生大量无意义的数据。
上述命令在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
中的修复很简单:
|
|
在调查过程中,我们注意到另一个较小的日志噪音来源:自定义Sentry记录器(app/services/sentry_log.rb
)仍然处于活动状态,即使主gem已在测试环境中禁用。
为了消除这个次要开销,我们借鉴了Sidekiq::Testing.fake!
的思路,并实现了一个方法以在测试期间完全禁用Sentry:
|
|
实现2倍速度提升
这些更改的影响是立竿见影且惊人的。它们共同将总测试时间减少了一半:
单进程运行:
- 之前:约25分钟
- 之后:约12分钟
CI运行(16个并行进程):
- 之前:约4分钟
- 之后:约2分钟
禁用详细查询日志完成了繁重的工作,Sentry优化提供了额外的增益。主要通过消除ActiveRecord自身的嘈杂日志记录,我们将测试速度提高了一倍。
随着最重要的瓶颈消失,是时候寻找较小的胜利了。
第二部分:驯服工厂野兽
随着日志记录怪物被消灭,我们的分析器指向了一个新的、更可预测的罪魁祸首:对象创建。在大多数Rails测试套件中,这意味着FactoryBot。我们的目标是减少创建的昂贵数据库记录数量,一次一个测试。
使用TagProf获得鸟瞰图
首先,我们需要确认我们的怀疑,TestProf的TagProf非常适合这类任务。它按"标签"分组分析数据,使我们能够看到哪些类型的事件最昂贵。
我们配置它来监视ActiveRecord查询、工厂创建和Sidekiq作业:
|
|
生成的HTML报告非常清晰:factory.create
事件现在负责超过总测试执行时间的一半。
策略1:let_it_be
常见的测试模式是为组中的每个示例创建相同的基本数据。
TestProf的let_it_be
就是这里的解决方案。与RSpec的标准let
不同,它为整个describe
块创建一次记录,在所有示例中重复使用。
这是一个强大的工具,但它不是银弹。你必须小心不要将其用于被一个测试修改状态的对象,因为该状态可能"泄漏"到下一个测试中,导致不稳定的结果。
策略2:FactoryDefault
第二个更微妙的问题是冗余关联创建。
通常,一个模型的工厂会触发其他几个关联模型的创建(一个体验创建一个机器人,机器人创建一个用户,依此类推)。
为了查找此类情况,我们在之前确定的特定重型测试文件上使用了FactoryDefault的分析器:
|
|
此命令为我们提供了两个非常有用的表格。
第一个"Factory associations usage"显示哪些对象作为其他工厂的副作用被创建。
在我们的案例中,我们可以看到user
、app
和bot
各自被创建超过20次,仅用作关联,每个花费超过10秒的测试时间。
第二个表格显示整体工厂使用情况,区分"顶级”(显式)创建和包括关联的"总"计数。
这里的数据很有启发性。我们看到用户工厂被显式创建(顶级)19次。然而,创建的总数是46。差异(27个隐式创建)与关联使用表中的计数完全匹配。
修复方法是使用create_default
。这会创建一个常用关联的实例,并告诉该测试中的所有后续工厂重复使用它,而不是每次都创建新的:
|
|
通过首先识别最慢的测试文件(使用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个测试失败。错误很奇怪且看似随机,与我们的更改没有明显联系。
例如:
|
|
和:
|
|
单独运行时测试正常,但在test-queue
真正随机、高并发环境中崩溃了。
我们不仅切换了运行器;我们还将测试套件置于显微镜下,无情地暴露了每个隐藏的依赖关系和测试之间的每个"泄漏"状态。
因此,我们的性能追求转变为调试奥德赛。所以,为了使我们的测试更快,我们首先必须使它们完全隔离。
是时候一个一个地追捕这些不稳定的野兽了。
第四部分:隔离指南
如果不可靠,快速的测试套件是无用的。我们的新test-queue
设置暴露了多年的隐藏状态泄漏。我们的工作是追捕每一个"不稳定的野兽"并强制执行完美的测试隔离。
这是那次追捕的故事。
案例研究#1:时间旅行和悖论
对我们来说,最常见和最令人困惑的不稳定问题都与时间有关。
全局泄漏
起初,我们看到任何带有日期过滤器的测试都普遍失败。原因很简单:测试会使用Timecop.freeze
但从不调用Timecop.return
。这个"冻结"的时间然后会泄漏到该工作进程运行的下一个测试中。修复方法是在rails_helper.rb
中建立一个强大的全局安全网:
|
|
Doorkeeper之谜
这个安全网修复了大多数问题,但最具挑战性的谜团仍然存在:在使用Doorkeeper的控制器规范中随机出现403 Forbidden错误。
为了找到这个状态泄漏的源头,我们转向了RSpec的终极工具:--bisect
。通过输入来自失败CI运行的种子,我们可以自动追捕重现错误的最小测试序列:
|
|
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
钩子,创建一个临时的、安全的"沙箱"。
|
|
关键是ensure
块,它保证我们的测试无论如何都会自行清理。它不只是重置测试模式;它会精确移除unique!
添加到客户端配置的特定中间件。
这使我们能够自信地测试关键的基础设施逻辑,同时确保它对测试套件其余部分的稳定性零影响。
更快、更强、更好
我们加速Whop测试套件的旅程始于一个简单的目标:使测试更快。
除了更快的测试套件,我们还获得了一个更健壮、可靠和有纪律的套件。
修复隐藏日志记录器带来的初始2倍速度提升是一个巨大的胜利,但真正的奖品来自于对抗不稳定测试。通过切换到test-queue
,我们迫使自己面对每个泄漏的抽象和有状态的依赖,套件因此变得更好。
让我们为您留下这个案例的一些关键经验:
- 您最大的性能瓶颈很少在您认为的地方。像StackProf这样的工具是您最好的朋友,在更改任何代码之前应该使用它们。
- 工厂使用通常是下一个最大的瓶颈,像
let_it_be
和create_default
这样的工具可以显著减少冗余工作。 - 当心隐藏的I/O。日志记录是 notorious 的性能杀手。不仅要仔细检查Rails的默认值,还要检查任何可能写入磁盘或网络的第三方gem,如Sentry。
- 采用像
test-queue
这样更公平的并行运行器是一个强大的优化,但它也是您套件的终极压力测试。它将无情地暴露您测试隔离中的每个缺陷。 - 每个测试必须完全自行清理。所以,掌握像
rspec --bisect
这样的工具来追捕泄漏,并使用像around
钩子这样的健壮模式来包含修改全局状态的测试。做一个好公民!