Code Reuse in RSpec

dorner

Daniel Orner

Posted on November 20, 2020

Code Reuse in RSpec

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, and around hooks
  • let and let! 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# /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
Enter fullscreen mode Exit fullscreen mode

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:

RSpec Rubric

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
Enter fullscreen mode Exit fullscreen mode
# /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
Enter fullscreen mode Exit fullscreen mode

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!

đź’– đź’Ş đź™… đźš©
dorner
Daniel Orner

Posted on November 20, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related