Daniel Orner
Posted on November 20, 2020
When writing your tests with RSpec, you have a plethora of ways to help DRY up your code and keep it clean and reusable. So many ways, in fact, that it can be pretty confusing to keep track of when you should do what. Forthwith, a quick reference for you!
Contexts
First, an explanation of what I mean by "context". In RSpec-world, a context basically refers to setup code and methods that are available to the current example. This includes:
-
before
,after
, andaround
hooks -
let
andlet!
declarations -
subject
declarations - helper methods
Nested Groups
The simplest way of reusing code is when your tests all belong to the same file. You can reuse your context simply by nesting your tests within the same group. Remember that you declare an example group with the describe
or context
method.
Here's an example.
RSpec.describe Taxi do
let(:driver) { Driver.new(name: 'John' }
let(:taxi) { Taxi.new }
before(:each) do
taxi.driver = driver
end
it 'should take a fare' do
taxi.put_fare(50)
expect(driver.income).to eq(50)
end
it "should add a drive to the driver's history" do
taxi.start_drive("123 Main St.")
expect(driver.history.last).to eq("123 Main St.")
end
it 'should get an address to drive to' do
taxi.address = "123 Main St."
expect(taxi.next_drive.address).to eq("123 Main St.")
end
end
You'll notice that all three examples in the group can make use of the taxi
and driver
declarations, since it's declared at the group level.
But note that one of those examples doesn't actually need a reference to the driver, and doesn't need the setup code that assigns it! This is a contrived example, but you can easily spend hundreds of lines setting up data in your tests, and you want that data to be targeted so you're not re-creating data where you don't need it. This is where you can nest your tests a little deeper.
RSpec.describe Taxi do
let(:taxi) { Taxi.new }
it 'should get an address to drive to' do
taxi.address = "123 Main St."
expect(taxi.next_drive.address).to eq("123 Main St.")
end
RSpec.context 'with driver' do
let(:driver) { Driver.create!(name: 'John' }
before(:each) do
taxi.driver = driver
end
it 'should take a fare' do
taxi.put_fare(50)
expect(driver.income).to eq(50)
end
end
end
Here, only the tests that need a driver have access to one, but all tests still have access to the taxi.
Shared Context
This is all well and good when testing a single class or feature. But what about when you're testing a category of related features? This is when shared contexts can be useful.
Shared contexts are a way to define a reusable context that can be used across files, including declarations, hooks and even helper methods. Let's say that we not only care about taxis, but about all kinds of vehicles.
First you define your shared context in a file that's included by all tests. If you don't have many of these contexts, you can put them inside your main spec_helper
file. If you have more, you can separate them out into its own file and manually include it from your test files.
# file: /spec/vehicles/shared_setup.rb
RSpec.shared_context("with driver") do
def drive_vehicle(vehicle)
vehicle.driver = driver
vehicle.drive!
end
let(:driver) { Driver.new("John") }
before(:each) do
driver.wake_up!
end
end
# file: /spec/vehicles/car_spec.rb
require_relative "./shared_setup.rb"
RSpec.describe(Car) do
include_context("with driver")
let(:car) { Car.new(driver) }
it "should drive" do
# this is true because wake_up! was called in the before hook
expect(driver).to be_awake
drive_vehicle(car)
expect(driver.is_driving).to eq(true)
end
end
Here, you've got access to the driver and your helper methods from your shared context in this file, and you can include that context in any code that needs a driver.
Shared Examples
Shared contexts help to create data or behavior for your tests. Shared examples actually are tests which you can run in multiple places.
Why would you do this? A classic reason is inheritance (or composition, mixins, or any other way you have to reuse your own code). If you have a number of classes or modules that all behave a certain way, you can write a single set of examples that should apply to all of them and ensure that they all pass.
Here's an example:
# /spec/vehicles/shared_setup.rb
RSpec.shared_examples("a vehicle") do
it 'should be able to move' do
subject.move("123 Main St.")
expect(subject.address).to eq("123 Main St.")
end
end
# /spec/vehicles/car_spec.rb
require_relative "./shared_setup.rb"
RSpec.describe(Car) do
it_should_behave_like "a vehicle"
it ... # more tests only for cars
end
When running the suite, all the shared examples will run along with the specific examples. Shared examples can make use of the current context, so for example you can use let
or subject
declarations to act as parameters, or you can pass parameters directly to shared examples.
When?
Here's a rubric as to when to use these three features:
Factories
This is more specific to ActiveRecord, but factories are a way to DRY up your data creation! Factories have an advantage over fixtures in that you aren't limited to specific, hand-crafted records but can define how to create data.
The most common way to use factories is to include the FactoryBot gem. Once set up, you can use it to manage your data creation.
As an example, let's say you can create taxis that are yellow, or that are New York taxis, or that are out of service. FactoryBot allows you to create traits to reuse this behavior:
# /spec/factories/taxis.rb
FactoryBot.define(:taxi) do
trait :yellow do
color { "yellow" }
end
trait :new_york do
city { 'New York' }
end
trait :out_of_service do
state { 'OUT_OF_SERVICE' }
end
end
# /spec/taxi_spec.rb
RSpec.describe(Taxi) do
it 'should have the right attributes'
taxi = FactoryBot.create(:taxi, :new_york,
:yellow, :out_of_service)
expect(taxi.color).to eq('yellow')
expect(taxi.city).to eq('New York')
expect(taxi.state).to eq('OUT_OF_SERVICE')
end
end
When should you create a factory vs. just creating your data manually? Some good rules of thumb are:
- When you have tests that don't need to understand the underlying data model but need to create the data
- When you've identified a pattern of data creation that's called from multiple places that you can simplify
- When you want to encapsulate how your data looks in a simpler, centralized way
RSpec is powerful but can be confusing, so hopefully this explains the different features in an organized way. Happy coding!
Posted on November 20, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.