/rspec_test_data

Create complex test data using factories, re-usable across multiple tests and your seed data

Primary LanguageRubyOtherNOASSERTION

rspec_test_data - Create Complex test data with the ability to share with other tests or seed data

<sustainable-rails>

Install

# In your Gemfile
gem "rspec_test_data"

Note: Nothing is required when you do this. You must configure things. See below.

What Problem Does This Solve?

This allows the creation of test data that is more than one factory, but scoped to a test file.

Rails comes with the concept of fixtures which is a global set of data that is available to all of your tests. Many developers, myself included, find this is hard to manage when an app becomes non-trivial, and can get extremely complicated when you use and validate foreign key constraints.

FactoryBot provides an alternative, which are factories to create instances of objects you would use as input to a test. These work great for creating single objects. They do not work as great when you need to create a lot of objects.

Why would you need to create a lot of objects? Glad you asked. A very common reason in my experience is if you are writing some code that needs to perform a query that is complex. For example, show me all the customers who have said they have insurance, but who have not provided the details of their insurance, but filter out everyone that has not scheduled an appointment.

Testing this requires creating several records in all the various states to check your query logic, then running the query and figuring out what came back.

OK, so use factories - for a single test, it is better to just use factories to create a bunch of stuff. But, when you start needing to create them in more than one test, or want to have that data in your seed data for local development, RSpec provides very rudimentary tools for this. Since RSpec uses an internal DSL via let and shared_context and friends, it is hard to manage, compose, and re-use this stuff.

But! We have Object-Oriented Programming! If we could put this stuff in a class, we can use that class, make that class configurable (or not), extend that class, etc. We can use the tools we use every day to manage our complex test data.

OK, so why do I need a gem? This gem facilitates that by providing an implicit test_data object that allows access to a test data class you define.

Using This Gem

Suppose you have spec/services/appointments_spec.rb:

RSpec.describe Appointments do
  describe "#upcoming" do
    context "no restriction by service" do
      it "returns all in the future" do
        a1       = create(:appointment,            date: 4.days.from_now)
        a2       = create(:appointment,            date: 14.days.from_now)
        canceled = create(:appointment, :canceled, date: 3.days.from_now)
        past     = create(:appointment,            date: 3.days.ago)

        upcoming = appointments.upcoming

        expect(upcoming.size).to eq(2)
        aggregate_failures do
          expect(upcoming).to include(a1)
          expect(upcoming).to include(a2)
        end
      end
    end
    context "restrict by service" do
      it "returns those in the future for the given service" do
        s1 = create(:service)
        s2 = create(:service)

        a1       = create(:appointment,            service: s1, date: 4.days.from_now)
        a2       = create(:appointment,            service: s2, date: 14.days.from_now)
        canceled = create(:appointment, :canceled, service: s1, date: 3.days.from_now)
        past     = create(:appointment,            service: s1, date: 3.days.ago)

        upcoming = appointments.upcoming(service: s1)

        expect(upcoming.size).to eq(1)
        expect(upcoming).to      include(a1)
      end
    end
  end
end

The test set up for both tests is pretty similar. The first test does not specify the service, but the service doesn't matter to that test, so it could absolutely use the exact same set of services and appointments that the second test uses.

It also might be nice to use this setup when you are working on the front-end to have some realistic data or as part of a larger set of test data for a system test that involves this code.

We could put that in a before block or a series of let calls, but this doesn't make it easy to use outside this test. Enter rspec_test_data.

Assuming you have configured this gem, you would create the class RspecTestData::Services::Appointments in the file spec/services/appointments.test_data.rb like so:

module RspecTestData::Services
  class Appointments < RspecTestData::BaseTestData

    attr_reader :service, :upcoming_appointment_service, :upcoming_appointment_other_service

    def initialize
      @service      = create(:service)
      other_service = create(:service)

      @upcoming_appointment_service       = create(:appointment,
                                                   service: s1,
                                                   date: 4.days.from_now)
      @upcoming_appointment_other_service = create(:appointment,
                                                   service: s2,
                                                   date: 14.days.from_now)
      canceled                            = create(:appointment, :canceled,
                                                   service: s1,
                                                   date: 3.days.from_now)
      past                                = create(:appointment,
                                                    service: s1,
                                                    date: 3.days.ago)
    end
  end
end

This class creates the test data and exposes only the data the test is going to need. Now, the test looks like so:

RSpec.describe Appointments do
  describe "#upcoming" do
    context "no restriction by service" do
      it "returns all in the future" do
        upcoming = appointments.upcoming

        expect(upcoming.size).to eq(2)

        aggregate_failures do
          expect(upcoming).to include(test_data.upcoming_appointment_service)
          expect(upcoming).to include(test_data.upcoming_appointment_other_service)
        end
      end
    end
    context "restrict by service" do
      it "returns those in the future for the given service" do
        upcoming = appointments.upcoming(service: test_data.service)

        expect(upcoming.size).to eq(1)
        expect(upcoming).to      include(test_data.upcoming_appointment_service)
      end
    end
  end
end

Whoa. Yes, the setup is gone and subsumed into the test data class. This is a trade-off. You make this trade-off in this case because you want access to the test data outside this class. You can achieve this like so, in your db/seeds.rb:

require "rspec_test_data/seeds_helper"

test_data_seeds_helper = RspecTestData::SeedsHelper.for_rails
test_data = test_data_seeds_helper.load("RspecTestData::Services::Appointments")

puts test_data.upcoming_appointment_service.customer.name +
     " has an upcoming appointment with the service"

This can be extremely helpful for aligning your dev environment, where you may want realistic data to work on the UI, with your tests.

Note that since the test data class is just a class, it can accept arguments to the constructor that affect the behavior. Perhaps you want your seed data to be a bit more realistic:

require "rspec_test_data/seeds_helper"

test_data_seeds_helper = RspecTestData::SeedsHelper.for_rails
test_data = test_data_seeds_helper.load("RspecTestData::Services::Appointments",
                                        service_name: "Physical Therapy")

puts test_data.upcoming_appointment_service.customer.name +
     " has an upcoming appointment for Physical Therapy"

The test data class accommodates this using plain ole Ruby:

module RspecTestData::Services
  class Appointments < RspecTestData::BaseTestData

    attr_reader :service, :upcoming_appointment_service, :upcoming_appointment_other_service

    def initialize(service_name: :use_factory)
      @service      = if service_name == :use_factory
                        create(:service)
                      else
                        create(:service, name: service_name)
                      end
      other_service = create(:service)

      # ... as before

    end
  end
end

This class then becomes a sort of "super factory" you can use to create complex test data. Suppose you want to search for upcoming appointments by service name? You'll need to make sure both services are created with distinct names so you can reliably search by name

module RspecTestData::Services
  class Appointments < RspecTestData::BaseTestData

    attr_reader :service, :upcoming_appointment_service, :upcoming_appointment_other_service

    def initialize(service_name:       :use_factory,
                   other_service_name: :use_factory)

      @service      = if service_name == :use_factory
                        create(:service)
                      else
                        create(:service, name: service_name)
                      end
      other_service = if other_service_name == :use_factory
                        create(:service)
                      else
                        create(:service, name: other_service_name)
                      end

      # ... as before

    end
  end
end

Now, in your test you can override the default creation of the test data per test. If you declare a let variable named test_data_override, that will be set to test_data. To create this, you have access to the class via the implicitly defined variable test_data_class.

RSpec.describe Appointments do
  describe "#upcoming" do
    context "no restriction by service" do
      it "returns all in the future" # as before
    end
    context "restrict by service" do
      it "returns those in the future for the given service" # as before
    end
    context "restrict by service name partial match" do
      let(:test_data_override) {
        test_data_class.new(service_name:       "Physical Therapy",
                            other_service_name: "Ortho Exam")
      }
      it "returns those in the future for the given service" do
        upcoming = appointments.upcoming(service_name: "phys")

        expect(upcoming.size).to eq(1)
        expect(upcoming).to      include(test_data.upcoming_appointment_service)
      end
    end
  end
end

Notice how the only magic happening is the definition of test_data and test_data_class based on a convention of a class defined in a file with a specific name. The test data class is just a normal Ruby class. Your test that overrides it just uses Ruby.

You can opt out using RSpec metadata:

RSpec.describe Appointments do
  describe "#upcoming" do
    context "no restriction by service" do
      it "returns all in the future" # as before
    end
    context "restrict by service" do
      it "returns those in the future for the given service" # as before
    end
    context "restrict by service name partial match", test_data: false do
      it "returns those in the future for the given service" do
        # test_data is not defined here - do whatever you want
      end
    end
  end
end

Test Data can also be useful for system tests. Perhaps you want a system test of the appointment search feature.

# spec/system/appointments/search_spec.rb
RSpec.describe "searching for appointments" do
  scenario "show all appointments" do
    login_as test_data.therapist

    click_on "Search Appointments"
    click_on "View All"

    expect(page).to     have_content(test_data.upcoming_appointment_service.description)
    expect(page).to     have_content(test_data.upcoming_appointment_other_service.description)
    expect(page).not_to have_content(test_data.canceled_appointment.description)
  end
end

To make this work, you'll need to define RspecTestData::System::Appointments::Search in the file spec/system/appointments/search.test_data.rb.

To re-use the test data for the Appointments class, all you have to do is use a plain old Ruby concept: inheritance:

require_relative "../services/appointments.test_data.rb"
class RspecTestData::System::Appointments::Search < RspecTestData::Services::Appointments
  attr_reader :therapist, :canceled_appointment
  def initialize(...)
    super(...)

    @therapist = create(:user, type: :therapist)

    @canceled_appointment = create(:appointment, :canceled,
                                   service: @service,
                                   date: 10.days.from_now)
  end
end

This gem isn't really facilitating this re-use - we can do it because this is just a class and Ruby allows it. No new skills or DSL is needed here. You can do whatever makes sense.

Configuration & Setup

In your spec/spec_helper.rb:

require "rspec_test_data/rspec_setup"    # brings in the setup below
require "rspec_test_data/base_test_data" # Avoid having to require this in all test data class files

RSpec.configure do |config|

  # whatever set up you have already

  config.before(:example) do |example|
    RspecTestData::Setup.new(example)
  end
end

Even here, the setup is explicit so you know it's happening. Nothing is done to you automatically.

If you don't create an analogous .test_data.rb file, nothing happens, your test works like normal.

Debugging

Often, libraries with implicit behavior are hard to debug when nothing happens. The library can't tell that you meant to do something but failed - it just thinks you didn't try to do something. To help debug those situations:

DEBUG_TEST_DATA=true bin/rspec spec/services/appointments_spec.rb

This will cause rspec_test_data to output verbose information about what it's doing, what it tried, what worked, what didn't. You can also add the debug_test_data: true metadata to any test or spec to trigger the same behavior.

A Note on Implementation

I have been using this for several months in two Rails apps that I would say are "medium-small". It is working great for me, but if you look at the code for RspecTestData::Setup, there is a bit of wizardry in there. Be careful with how you use this.

Ejecting from the Magic

Since your test data class is just a class, you can eject from all of this like so:

  1. Remove this Gem

  2. Keep a copy of RspecTestData::BaseTestData in your app, e.g. in lib/rspec_test_data/base_test_data.rb

  3. In your RSpec tests, add this:

    require_relative "./appointments.test_data.rb"
    
    # Then, in a test...
    let(:test_data) { RspecTestData::Services::Appointments.new }

Contributing

Would love feedback on the implementation and how it might be unit tested.