Skip to content

Latest commit

 

History

History
301 lines (214 loc) · 9.98 KB

let_it_be.md

File metadata and controls

301 lines (214 loc) · 9.98 KB

Let It Be

Let's bring a little bit of magic and introduce a new way to set up a shared test data.

Suppose you have the following setup:

describe BeatleWeightedSearchQuery do
  let!(:paul) { create(:beatle, name: "Paul") }
  let!(:ringo) { create(:beatle, name: "Ringo") }
  let!(:george) { create(:beatle, name: "George") }
  let!(:john) { create(:beatle, name: "John") }

  specify { expect(subject.call("john")).to contain_exactly(john) }

  # and more examples here
end

We don't need to re-create the Fab Four for every example, do we?

We already have before_all to solve the problem of repeatable data:

describe BeatleWeightedSearchQuery do
  before_all do
    @paul = create(:beatle, name: "Paul")
    # ...
  end

  specify { expect(subject.call("joh")).to contain_exactly(@john) }

  # ...
end

That technique works pretty good but requires us to use instance variables and define everything at once. Thus it's not easy to refactor existing tests which use let/let! instead.

With let_it_be you can do the following:

describe BeatleWeightedSearchQuery do
  let_it_be(:paul) { create(:beatle, name: "Paul") }
  let_it_be(:ringo) { create(:beatle, name: "Ringo") }
  let_it_be(:george) { create(:beatle, name: "George") }
  let_it_be(:john) { create(:beatle, name: "John") }

  specify { expect(subject.call("john")).to contain_exactly(john) }

  # and more examples here
end

That's it! Just replace let! with let_it_be. That's equal to the before_all approach but requires less refactoring.

NOTE: Great superpower that before_all provides comes with a great responsibility. Make sure to check the Caveats section of this document for details.

Instructions

In your rails_helper.rb or spec_helper.rb:

require "test_prof/recipes/rspec/let_it_be"

In your tests:

describe MySuperDryService do
  let_it_be(:user) { create(:user) }

  # ...
end

let_it_be won't automatically bring the database to its previous state between the examples, it only does that between example groups. Use Rails' native use_transactional_tests (use_transactional_fixtures in Rails < 5.1), RSpec Rails' use_transactional_fixtures, DatabaseCleaner, or custom code that begins a transaction before each test and rolls it back after.

Caveats

Database is rolled back to a pristine state, but the objects are not

If you modify objects generated within a let_it_be block in your examples, you maybe have to re-initiate them. We have a built-in modifiers support for that.

Database is not rolled back between tests

Database is not rolled back between RSpec examples, only between example groups. We don't want to reinvent the wheel and encourage you to use other tools that provide this out of the box.

If you're using RSpec Rails, turn on RSpec.configuration.use_transactional_fixtures in your spec/rails_helper.rb:

RSpec.configure do |config|
  config.use_transactional_fixtures = true # RSpec takes care to use `use_transactional_tests` or `use_transactional_fixtures` depending on the Rails version used
end

Make sure to set use_transactional_tests (use_transactional_fixtures in Rails < 5.1) to true if you're using Minitest.

If you're using DatabaseCleaner, make sure it rolls back the database between tests.

Aliases

Naming is hard. Handling edge cases (the ones described above) is also tricky.

To solve this we provide a way to define let_it_be aliases with the predefined options:

# rails_helper.rb
TestProf::LetItBe.configure do |config|
  # define an alias with `refind: true` by default
  config.alias_to :let_it_be_with_refind, refind: true
end

# then use it in your tests
describe "smth" do
  let_it_be_with_refind(:foo) { Foo.create }

  # refind can still be overridden
  let_it_be_with_refind(:bar, refind: false) { Bar.create }
end

Modifiers

If you modify objects generated within a let_it_be block in your examples, you maybe have to re-initiate them to avoid state leakage between the examples. Keep in mind that even though the database is rolled back to its pristine state, models themselves are not.

We have a built-in modifiers support for getting models to their pristine state:

# Use reload: true option to reload user object (assuming it's an instance of ActiveRecord)
# for every example
let_it_be(:user, reload: true) { create(:user) }

# it is almost equal to
before_all { @user = create(:user) }
let(:user) { @user.reload }

# You can also specify refind: true option to hard-reload the record
let_it_be(:user, refind: true) { create(:user) }

# it is almost equal to
before_all { @user = create(:user) }
let(:user) { User.find(@user.id) }

NOTE: make sure that you require let_it_be after active_record is loaded (e.g., in rails_helper.rb after requiring the Rails app); otherwise the refind and reload modifiers are not activated.

You can also use modifiers with array values, e.g. create_list:

let_it_be(:posts, reload: true) { create_list(:post, 3) }

# it's the same as
before_all { @posts = create_list(:post, 3) }
let(:posts) { @posts.map(&:reload) }

Custom Modifiers

If reload and refind is not enough, you can add your custom modifier:

# rails_helper.rb
TestProf::LetItBe.configure do |config|
  # Define a block which will be called when you access a record first within an example.
  # The first argument is the pre-initialized record,
  # the second is the value of the modifier.
  #
  # This is how `reload` modifier is defined
  config.register_modifier :reload do |record, val|
    # ignore when `reload: false`
    next record unless val
    # ignore non-ActiveRecord objects
    next record unless record.is_a?(::ActiveRecord::Base)
    record.reload
  end
end

Default Modifiers

It's possible to configure the default modifiers used for all let_it_be calls:

  • Globally:
TestProf::LetItBe.configure do |config|
  # Make refind activated by default
  config.default_modifiers[:refind] = true
end
  • For specific contexts using tags:
context "with let_it_be reload", let_it_be_modifiers: {reload: true} do
  # examples
end

NOTE: Nested contexts tags are overwritten not merged:

TestProf::LetItBe.configure do |config|
  config.default_modifiers[:freeze] = false
end

context "with reload", let_it_be_modifiers: {reload: true} do
  # uses freeze: false, reload: true here

  context "with freeze", let_it_be_modifiers: {freeze: true} do
    # uses only freeze: true (reload: true is overwritten by new metadata)
  end
end

State Leakage Detection

From rspec-rails docs on transactions and before(:context):

Even though database updates in each example will be rolled back, the object won't know about those rollbacks so the object and its backing data can easily get out of sync.

Since let_it_be initialize objects in before(:context) hooks under the hood, it's affected by this problem: the code might modify models shared between examples (thus causing shared state leaks). That could happen unwillingly: when the underlying code under test modifies models, e.g. modifies updated_at attribute; or deliberately: when models are updated in before hooks or examples themselves instead of creating models in a proper state initially.

This state leakage comes with potentially harmful side effects on the other examples, such as implicit dependencies and execution order dependency.

With many shared models between many examples, it's hard to track down the example and exact place in the code that modifies the model.

To detect modifications, objects that are passed to let_it_be are frozen (with #freeze), and FrozenError is raised:

# use freeze: true modifier to enable this feature
let_it_be(:user, freeze: true) { create(:user) }

# it is almost equal to
before_all { @user = create(:user).freeze }
let(:user) { @user }

To fix the FrozenError:

  • Add reload: true/refind: true, it pacifies leakage detection and prevents leakage itself. Typically it's significantly faster to reload the model than to re-create it from scratch before each example (two or even three orders of magnitude faster in some cases).
  • Rewrite the problematic test code.

This feature is opt-in, since it may find a significant number of leakages in specs that may be a significant burden to fix all at once. It's possible to gradually turn it on for parts of specs (e.g., only models) by using:

# spec/spec_helper.rb
RSpec.configure do |config|
  # ...
  config.define_derived_metadata(let_it_be_frost: true) do |metadata|
    metadata[:let_it_be_modifiers] ||= {freeze: true}
  end
end

And then tag contexts/examples with :let_it_be_frost to enable this feature.

Alternatively, you can specify freeze modifier explicitly (let_it_be(freeze: true)) or configure an alias.

Report duplicates

Although we suggest using let_it_be instead of let!, there is one important difference: you can override let! definition with the same or nested context, so only the latter one is called; let_it_be records could be overridden, but still created. For example:

context "A" do
  let!(:user) { create(:user, name: "a") }
  let_it_be(:post) { create(:post, title: "A") }

  specify { expect(User.all.pluck(:name)).to eq ["a"] }
  specify { expect(Post.all.pluck(:title)).to eq ["A"] }

  context "B" do
    let!(:user) { create(:user, name: "b") }
    let_it_be(:post) { create(:post, title: "B") }

    specify { expect(User.all.pluck(:name)).to eq ["b"] }
    specify { expect(Post.all.pluck(:title)).to eq ["B"] } # fails, because there are two posts
  end
end

So for your convenience, you can configure the behavior when let_it_be is overridden.

TestProf::LetItBe.configure do |config|
  config.report_duplicates = :warn # Rspec.warn_with
  config.report_duplicates = :raise # Kernel.raise
end

By default this parameter is disabled. You can configure the behavior that will generate a warning or raise an exception.