Repetitive setup code in tests makes it difficult to refactor the tests. It also distracts and shifts our focus from the business logic that we’re trying to test with the setup details. This post explains three ways to reduce the setup duplication in your tests.
Here’s a simple Rails model I was trying to test.
class Task < ApplicationRecord
def toggle(task_completed:)
update(completed: task_completed)
end
def complete
update(completed: true)
end
end
I added the two tests corresponding to two methods.
RSpec.describe Task, type: :model do
it "toggles a task" do
task = Task.create(description: "Do Something", completed: false)
expect(task.completed).to be false
task.toggle(task_completed: true)
expect(task.completed).to be true
end
it "completes a task" do
task = Task.create(description: "Do Something", completed: false)
expect(task.completed).to be false
task.complete
expect(task.completed).to be true
end
end
These tests run fine, but we’re repeating the task creation code in each method. We don’t want to create a task manually for each test. That would be tedious. This duplication makes it difficult to refactor the tests. It also distracts and shifts our focus from the business logic that we’re trying to test with the setup details.
Now, I found three different ways to reduce this duplication. Use a hook provided by RSpec, use a helper method, or use the let
construct. Let’s inspect them one by one.
Use the before hook
before { @task = Task.create(description: "Do Something", completed: false) }
There are a few drawbacks of using instance variables:
- Ruby creates instance variables any time you reference them and initialize them to
nil
unless you explicitly initialize them. Thus, if you misspell@task
to@tasky
, Ruby returns a new instance variable@tasky
which isnil
, instead of failing right away. Hence the test fails with a confusing error message, or it can even lead to subtle bugs. - RSpec calls this hook before each spec. This means specs that don’t need to use the instance variable still end up creating it. Typically, this is not a big problem, but if the setup takes a long time, it slows down your tests.
- To refactor your tests to use instance variables, you need to change all
task
occurrences with@task
.
Use a helper method
def task
@task ||= Task.create(description: "Do Something", completed: false)
end
Here, we only create the @task
the first time a spec calls the task
method. From then onwards, all specs use the instance variable. This approach solves all the above drawbacks. However, the caching doesn’t work if the right-hand operation returns a falsy value. The method will call that operation each time the method is invoked.
Use the let construct
let(:task) { Task.create(description: "Do Something", completed: false) }
let
defines a memoized helper method. In plain English, it means that let
is lazy-evaluated. RSpec only runs the let
block the first time a test tries to call the task
method. There’s no chance of misspelling task
, as Ruby will throw a NameError
.
However, it’s important to keep in mind that let
won’t cache the result across all specs. Each spec gets its value from the execution of the block. For example, notice that we are starting from an incomplete task in the second spec, even if the first spec marked it complete.
RSpec.describe Task, type: :model do
let(:task) { Task.create(description: "Do Something", completed: false) }
it "toggles a task" do
expect(tasky.completed).to be false
task.toggle(task_completed: true)
expect(task.completed).to be true # complete the task
end
it "completes a task" do
expect(task.completed).to be false # task is incomplete, as it's a new task
task.complete
expect(task.completed).to be true
end
end
I hope this post helped you understand different ways in which you can reduce the duplicated setup code in your tests. If you’re aware of any other approaches, or know any other pros and cons of the above ones, do let me know.