# In your Gemfile
gem "rspec_test_data"
Note: Nothing is required when you do this. You must configure things. See below.
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.
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.
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.
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.
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.
Since your test data class is just a class, you can eject from all of this like so:
-
Remove this Gem
-
Keep a copy of
RspecTestData::BaseTestData
in your app, e.g. inlib/rspec_test_data/base_test_data.rb
-
In your RSpec tests, add this:
require_relative "./appointments.test_data.rb" # Then, in a test... let(:test_data) { RspecTestData::Services::Appointments.new }
Would love feedback on the implementation and how it might be unit tested.