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.
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.
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 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.
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
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) }
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
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
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.
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.