我是如何将 RSpec 测试套件的运行时间缩短 15% 的
由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!
前段时间我参与了一个项目,目标是提升 Ruby on Rails 应用的自动化测试速度。在这篇文章中,我想分享我的经验以及一些技巧,希望能帮助你提升项目测试套件的运行速度。就我而言,测试套件的运行时间缩短了 15%。
注意:本文中的所有代码示例均与 Ruby on Rails、RSpec 和 FactoryBot 相关,但一些一般原则可以推广到其他测试框架和编程语言。
第一步:找出测试套件中最慢的测试。
我首先列出了测试套件中最慢的测试,并逐一分析了它们。要查找测试套件中最慢的 10 个测试,请在项目根目录下运行以下命令(将 10 替换为您希望获取的慢测试的数量):
rspec --profile 10
步骤 2:分析每个慢速测试
对于每个运行速度明显慢于其他测试的测试,重要的是要确定其慢的原因究竟是合理的,还是由于底层代码存在缺陷或效率低下。以下是我用来分析慢速测试的标准。
测试速度较慢,但如果符合以下条件,则可以接受:
-
它不会对外部服务进行不必要的调用,即所有必要的外部请求都被存根。
-
所有未进行测试的对象都已替换为模拟对象或存根对象。
-
它会将对象写入数据库,但这对于测试的正常运行是必要的,即
:create不能用:build_stubbed其他方式代替:build。 -
用于创建对象的工厂不会创建不必要的 Active Record 关联。
-
它不测试私有方法。
-
它不会测试属于其他类的逻辑。
-
它不会测试其他人(外部库或 gem)已经测试过的逻辑。
-
如果这是请求/集成规范,则不会测试边界情况或已经在单元级别测试过的情况。
-
更广泛地说,该测试测试的是行为,而不是实现。
我分析的慢速测试在上述所有方面都表现良好,唯独测试数据生成这一项例外,因为如果不进一步调查,很难评估测试结果。所以我假设,某些测试之所以运行缓慢,是因为它们生成了过多不必要的测试数据。通常来说,不必要的测试数据生成是导致测试套件运行缓慢的最常见原因之一:写入数据库是测试中最慢的操作之一(调用外部 API 也是如此)。
步骤 3:检查测试运行期间创建的测试对象数量
✅ 将此代码片段添加到spec_helper.rb:
# spec/spec_helper.rb
config.before(:each, :monitor_database_record_creation) do |example|
ActiveSupport::Notifications.subscribe("factory_girl.run_factory") do |name, start, finish, id, payload|
$stderr.puts "FactoryGirl: #{payload[:strategy]}(:#{payload[:name]})"
end
end
:monitor_database_record_creation✅如果您怀疑某个测试示例或测试组创建了过多的对象,请为其添加一个元标签:
describe '#recipe_complete?' do
it 'returns true if a recipe is complete', :monitor_database_record_creation do
# test body
end
end
✅ 运行测试。
控制台输出将显示本次测试创建了多少个对象,以及使用了哪种创建策略:
FactoryGirl: create(:recipe) FactoryGirl: create(:step)
FactoryGirl: create(:step)
FactoryGirl: create(:ingredient)
FactoryGirl: create(:step)
❓ 此时,您可能会疑惑为什么这个测试示例会创建这么多对象,或者更具体地说,step为了让这个测试示例正常运行,是否应该创建两次对象。通常,这些重复的对象只是“神秘访客”,也就是那些被写入数据库但测试并未用到的、不必要且难以发现的对象。
在许多情况下,你应该能够重构你的测试,并消除这些拖慢测试套件速度的“访客”。
接下来,我将介绍导致不必要的测试数据创建的主要原因,并说明如何处理这些问题。
罪魁祸首一:使用:createwhere:build就能完成这项工作
你的测试用例很可能主要依赖于:create策略。在很多这样的测试用例中,你可以安全地用 `.` 替换:create` :build.`。这些测试用例并不假设对象实际上已被写入数据库,这在模型测试中很常见。
就我而言,我会在最慢的测试中尽可能地进行替换:create,:build并且还会搜索多个单元测试,找出应用程序中最常用的模型。这类模型往往会承担过多的责任,积累很多方法,进而产生大量的测试。
友情提示:有些博客文章建议find and replace在整个项目中进行全局更改,并将所有实例替换:create为 `<type> :build`。我不建议这样做:你很可能会遇到大量测试失败的情况🤯。建议逐个修复测试用例。虽然会花费更多时间,但你会对最终结果更有信心。
:create罪魁祸首二:依赖工厂中创建关联对象的默认策略
你很可能拥有多家工厂,并且你还为这些工厂提供协会服务,而这正是事情可能会变得棘手的地方。
默认情况下,即使您使用 `@Purface` 调用父工厂:build,子工厂仍然会使用 `@Subscriber` 调用:create。这意味着在您的测试中,即使测试本身不需要,您也始终会将关联对象写入数据库。
factory :step do
association :recipe
end
FactoryBot.build(:step)
(0.1ms) begin transation
Recipe Create (0.5ms) INSERT INTO "recipes" DEFAULT VALUES
(0.6ms) commit transaction
为了避免这种情况,您可以:build尽可能在工厂中明确使用该策略。
factory :step do
association :recipe, strategy: :build
end
罪魁祸首 3:未在工厂回调中显式提供关联对象
假设我们有一个工厂特性,用于创建recipe具有两个steps:
ruby
# spec/factories/recipe_factory.rb
FactoryBot.define do
factory :recipe do
# more code
trait(:with_two_steps) do
after(:create) do |record|
record.steps << create_pair(:step,
account_id: record.account_id,
body: Faker::Food.description
)
end
end
end
end
trait的问题with_two_steps在于after(:create)回调函数没有明确指定要创建的对象。当recipe从这个回调函数调用工厂方法时,总会创建一个新的对象,而其他人对此毫不知情。为什么会这样?stepsteprecipe
我们来看看step工厂的设计。对于每一项新的生产活动,都会创建step一个关联的对象:recipe
# spec/factories/step_factory.rb
FactoryBot.define do
factory :step do
account_id: Faker::Number.number
# more code
association(:recipe) # this always creates a recipe
end
end
这就是导致recipe上述示例中创建额外数据的原因。通过在特性定义recipe中显式设置对象:with_two_steps,可以避免recipe向数据库中写入不必要的额外数据:
# spec/factories/recipe_factory.rb
FactoryBot.define do
factory :recipe do
# more code
trait(:with_two_steps) do
after(:create) do |record|
record.steps << create_pair(:step,
account_id: record.account_id,
body: Faker::Food.description,
recipe: record # set the recipe explicitly
)
end
end
end
end
这看似微不足道,但如果多个测试都调用了这个特性,这样的修复就能避免数十次不必要的数据库写入。如果多个工厂都存在类似的缺陷,那么不必要的写入次数可能高达数百次。
这看起来可能像是一个复杂的教程示例,但遗憾的是,与工厂中的关联相关的错误非常常见,极其难以发现,而且在实际项目中可能更加复杂。
这里有个好的经验法则:尽可能避免在工厂定义中定义关联关系。根据需要,逐个测试创建关联对象。这样最终会得到更易于管理的测试数据。如果无法避免,请确保创建的数据量不超过绝对必要范围。
罪魁祸首之四:使用let!不当
在测试文件的顶部定义测试示例中需要的所有测试数据似乎很方便,如下所示:
let!(recipe_1) { ... }
let!(recipe_2) { ... }
let!(step_1) { ... }
let!(step_2) { ... }
it 'test example that uses recipe_1 and recipe_2 objects' do
end
it 'test example that uses just recipe_1 object' do
end
it 'test example that uses step_1 and step_2 objects' do
end
这段代码看起来简洁易读。然而,由于其let! 工作机制的特性,每次运行测试用例之前,都会创建所有这些测试对象的新实例,即使有些测试用例并不需要所有这些对象(甚至任何一个!)存在。在大型测试组中,这种看似无心的错误可能会导致数十次不必要的数据库写入。
为了解决这个问题,看看是否可以为相关的文本示例创建单独的上下文:
context 'tests that use recipe_1 and recipe_2 objects' do
let!(recipe_1) { ... }
let!(recipe_2) { ... }
it 'test example that uses recipe_1 and recipe_2 objects' do
end
# more test examples
end
context 'tests that use step_1 and step_2 objects' do
let!(step_1) { ... }
let!(step_2) { ... }
it 'test example that uses step_1 and step_2 objects' do
end
# more test examples
end
所有这些技巧都归结为一个简单的理念:要时刻关注测试生成的对象,并且永远只创建测试正常运行所需的最低限度数据。这种思维方式的转变可能需要一段时间才能适应,但将来很可能会带来回报。毕竟,缓慢的测试套件会严重影响团队的生产力,并使某些编程方法(例如测试驱动开发)变得至关重要。
不可能的极其痛苦。
希望这篇教程能帮助你加快项目测试速度。如果你知道其他可能导致测试套件运行缓慢的常见问题,请在下方评论区分享。
测试愉快!
文章来源:https://dev.to/beetlehope/how-i-reduced-the-runtime-of-an-rspec-test-suite-by-15-5ed1
