Ruby on Rails

My sane approach to test fixtures in Rails


10 minute read

I’ve been an RSpec/FactoryBot fan for a long time. Getting my Rails career started at Pivotal Labs we spent a lot of time TDDing with behavior-focused tooling.

But since staking out on my own 2+ years ago I started to work with clients using minitest and test fixtures. I’ll admit, at first I hated the change. Minitest felt so limiting; and I couldn’t wrap my head around having all of my test permutations predefined.

Fast forward a few years and I’ve built up a sustainable business powered by an open source Rails app. Being open source, my goal is to use as many of the Rails defaults as possible - that means minitest and fixtures.

So, as projects tend to go, things grew. And test fixtures exploded. At one point there were 20+ different fixtures just for the User model. It had gotten to the point where you had to understand the entire fixture dependency graph just to test something. Not good!

So I set off to reduce the number of fixtures used in the app. But, as these things tend to do, this snowballed into a 1000+ line diff focused entirely on refactoring the test suite.

That refactor taught me a lot on how to manage my Rails test fixtures. Here’s my approach on how I keep my fixtures manageable, sane, and obvious. These aren’t hard and fast rules, but guidelines to help you implement the same in your app.

  1. 1-2 default fixtures per model
  2. Customize bespoke fixtures in the test suite
  3. Extract helpers for common customizations
  4. Add additional fixtures only when dependencies are complex

#1: 1-2 default fixtures per model

Perhaps the most obvious of the guidelines: the fewer fixtures you have to manage the easier it is to write tests. My approach here is to have a single fixture with boring, sane, default attributes. Don’t try and tease out different permutations - stick with fixtures that are valid and have as few dependencies as possible.

I reach for a second fixture when I need to compare two models of the same class. The second fixture also has boring, sane default attributes but with slightly different values to distinguish it from the first.

I name these :one and :two, respectively. They don’t have anything “special” about them. They are serving as two instances, nothing more.

Here’s an example of a Developer fixture from my app. The values are obvious and make it clear this is developers(:one) when running the tests. And the only relationship is to the User, which is a validation requirement.

# test/fixtures/developers.yml

one:
  user: developer
  name: Developer One
  available_on: <%= Date.new(2021, 1, 1) %>
  hero: Developer number one
  bio: I am the first developer.

#2: Customize bespoke fixtures in the test suite

But what happens when I need to test something that developers(:one) can’t handle? Say, a query for developers with specific available_on dates?

Before I would add a new fixture with a different attribute. But this is exactly what got me into the problem in the first place! Instead, we modify the existing fixtures in the tests. (A developer is available if their available_on date is today or earlier.)

# test/models/developer_test.rb

class DeveloperTest < ActiveSupport::TestCase
  test "available developers" do
    available = developers(:one)
    unavailable = developers(:two)

    available.update!(available_on: Date.yesterday)
    unavailable.update!(available_on: Date.tomorrow)

    assert_includes Developer.available, available
    refute_includes Developer.available, unavailable
  end
end

This might feel weird - it did to me at first. Aren’t two UPDATE queries slower than inserting a second fixture at boot time? Maybe! But the amount of time required to understand these tests is minimal.

We keep all of the relevant information in the test, not the fixture. The only field we care about is available_on, so we set it explicitly.

If we are modifying a fixture then why not use FactoryBot? Because most of the time you won’t be touching the fixture. There are a lot of test cases where developers(:one) needs a developer, that’s it. Not special in any way. This is where fixtures shine - they are already loaded into the test database!

#3: Extract helpers for common customizations

For more complex tests you might need a handful of objects in the database. A common example is a query object that chains a couple of Active Record scopes. In my app we have a DeveloperQuery that powers the search interface. As you can imagine, a lot of different permutations of records are required.

Side note, but in hindsight I think this model is what originally blew up the number of fixtures in the app. As we added new filters it required more permutations to query by.

Similar to #2, I want to keep as much context in the test itself as possible. Not buried in the fixture data. For this I create a few helpers to let me generate as many records as possible for each test.

Here’s a stripped down version of what my helper looks like. Nothing fancy here - a Ruby module gets us exactly what we need.

module DevelopersHelper
  extend ActiveSupport::Concern

  included do
    def create_developer(options = {})
      Developer.create!(developer_attributes.merge(options))
    end

    private

    def developer_attributes
      {
        user: users(:empty),
        name: "Name",
        hero: "Hero",
        bio: "Bio"
      }
    end
  end
end

The “magic” is in merging the method parameters into the default attributes. We can overwrite or customize the developer exactly as we want for each test.

Note in this test we are setting the available_on explicitly, like we did before when updating the fixture. But this time we are creating new records.

class DeveloperQueryTest < ActiveSupport::TestCase
  include DevelopersHelper

  test "sorting by availability excludes blank values" do
    present = create_developer(available_on: Date.yesterday)
    blank = create_developer(available_on: nil)

    records = DeveloperQuery.new(sort: :availability).records

    assert_includes records, present
    refute_includes records, blank
  end
end

This works well when you need to build up a few persisted objects in the database. But remember that you are still inserting records. So if there are chained dependencies or complex callbacks you risk taking a performance hit.

“Hold on a second Joe, this looks a lot like you’re recreating FactoryBot!” - You

I don’t deny that. If you’d rather use FactoryBot and fixtures then go for it! But for my use case this single helper was enough - and one less gem to worry about.

Guideline #4: Add additional fixtures only when dependencies are complex

Finally, my last guideline is to ignore the first guideline 😃.

The 1-2 existing fixtures don’t have anything special about them. That’s by design. But what if you need to create a record that has a few levels of relationships?

In my app a Business belongs to a User which belongs to a Customer which has many Subscription objects. (Phew!) I really don’t want to create this manually every time I need to test something for a paying business.

Instead, I break guideline #1 and create a specialized fixture. The key here is that the others are named :one and :two respectively. Naming this new one something specific, :paying_customer, makes it immediately obvious that this is a special case.

I try to use this guidelines as sparingly as possible. Adding more fixtures will just get us back to the original problem! But I think having 1 maybe 2 of these per model is a good tradeoff.

Wrapping up

I felt the pain of having too many fixtures. To write a new test I had to dig through multiple files to figure out if there was the perfect candidate for my test.

My refactor dropped the number of fixtures in the app from 89 to 43. That’s close to half - gone!

These guidelines have made it easier to write new tests and modify existing ones. And when a goal of the app is high test coverage, that means it’s easier to add new features.


If you’re interested, the product I’ve been talking about is railsdevs. It’s a reverse job board for Ruby on Rails developers. Instead of companies posting their job, you add your profile and they reach out to you. If you’re looking for your next gig (freelance or full-time) you should add your profile.

Have an opinion to share about this article? Mention or DM me on Twitter, I’d love to hear your thoughts! I’m @joemasilotti and I tweet about Ruby on Rails, iOS, automated testing, and the intersection between them all.

Sign up for the ⚡️ Hotwire dev newsletter

A monthly newsletter on Turbo (Native), Stimulus, and Strada articles, code, courses, and more. Plus, exclusive insights into how I build Turbo Native apps.

By subscribing, you agree with Revue’s Terms of Service and Privacy Policy.