发布于 2026-01-06 2 阅读
0

我是如何将 RSpec 测试套件的运行时间缩短 15% 的?DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

我是如何将 RSpec 测试套件的运行时间缩短 15% 的

由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!

前段时间我参与了一个项目,目标是提升 Ruby on Rails 应用的自动化测试速度。在这篇文章中,我想分享我的经验以及一些技巧,希望能帮助你提升项目测试套件的运行速度。就我而言,测试套件的运行时间缩短了 15%。

注意:本文中的所有代码示例均与 Ruby on Rails、RSpec 和 FactoryBot 相关,但一些一般原则可以推广到其他测试框架和编程语言。

第一步:找出测试套件中最慢的测试。

我首先列出了测试套件中最慢的测试,并逐一分析了它们。要查找测试套件中最慢的 10 个测试,请在项目根目录下运行以下命令(将 10 替换为您希望获取的慢测试的数量):



rspec --profile 10


Enter fullscreen mode Exit fullscreen mode

步骤 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


Enter fullscreen mode Exit fullscreen mode

: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


Enter fullscreen mode Exit fullscreen mode

✅ 运行测试。
控制台输出将显示本次测试创建了多少个对象,以及使用了哪种创建策略:



FactoryGirl: create(:recipe)                                                                                                                                                                                        FactoryGirl: create(:step)
FactoryGirl: create(:step)
FactoryGirl: create(:ingredient)
FactoryGirl: create(:step)


Enter fullscreen mode Exit fullscreen mode

❓ 此时,您可能会疑惑为什么这个测试示例会创建这么多对象,或者更具体地说,step为了让这个测试示例正常运行,是否应该创建两次对象。通常,这些重复的对象只是“神秘访客”,也就是那些被写入数据库但测试并未用到的、不必要且难以发现的对象。

在许多情况下,你应该能够重构你的测试,并消除这些拖慢测试套件速度的“访客”。


接下来,我将介绍导致不必要的测试数据创建的主要原因,并说明如何处理这些问题。

罪魁祸首一:使用:createwhere:build就能完成这项工作

屏幕截图 2021-01-10 14.59.43

你的测试用例很可能主要依赖于: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


Enter fullscreen mode Exit fullscreen mode

为了避免这种情况,您可以:build尽可能在工厂中明确使用该策略。



factory :step do
  association :recipe, strategy: :build
end


Enter fullscreen mode Exit fullscreen mode

罪魁祸首 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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

这就是导致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


Enter fullscreen mode Exit fullscreen mode

这看似微不足道,但如果多个测试都调用了这个特性,这样的修复就能避免数十次不必要的数据库写入。如果多个工厂都存在类似的缺陷,那么不必要的写入次数可能高达数百次。

这看起来可能像是一个复杂的教程示例,但遗憾的是,与工厂中的关联相关的错误非常常见,极其难以发现,而且在实际项目中可能更加复杂。

这里有个好的经验法则:尽可能避免在工厂定义中定义关联关系。根据需要,逐个测试创建关联对象。这样最终会得到更易于管理的测试数据。如果无法避免,请确保创建的数据量不超过绝对必要范围。

罪魁祸首之四:使用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



Enter fullscreen mode Exit fullscreen mode

这段代码看起来简洁易读。然而,由于其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



Enter fullscreen mode Exit fullscreen mode

所有这些技巧都归结为一个简单的理念:要时刻关注测试生成的对象,并且永远只创建测试正常运行所需的最低限度数据。这种思维方式的转变可能需要一段时间才能适应,但将来很可能会带来回报。毕竟,缓慢的测试套件会严重影响团队的生产力,并使某些编程方法(例如测试驱动开发)变得至关重要。 不可能的极其痛苦。

希望这篇教程能帮助你加快项目测试速度。如果你知道其他可能导致测试套件运行缓慢的常见问题,请在下方评论区分享。

测试愉快!

文章来源:https://dev.to/beetlehope/how-i-reduced-the-runtime-of-an-rspec-test-suite-by-15-5ed1