---
title: A journey towards better Ruby on Rails testing practices
teaser: Testing Ruby on Rails and avoiding RSpec and FactoryBot anti-patterns.
tags: testing,ruby,development,factory,ruby on rails,rspec,factory_bot
author: Rémy Hannequin
published_on: 2023-03-14
---

Tests have always been part of my journey with Ruby and Rails. We benefit from a
great ecosystem and community, implicated in writing maintainable software.
Along the way, I have learned good practices, anti-patterns and trade-offs, and I keep learning new practices and tips every day.

When I peek at old code snippets, such as the ones I wrote a few years back, I
can't help but notice how much my tests have evolved, noticing practices I now try to
keep away from and inspiring new goals I work to reach.

Join me in a small journey from anti-patterns to good practices.

## Moving away from RSpec DSL

I used to try to use as many RSpec features as possible, thinking these features
must be useful to my work if they existed. From my first coding lessons, I
learned that we should refactor our code and keep it
<abbr title="Don't Repeat Yourself">DRY</abbr> to avoid hidden bugs.

So I abstracted everything. Any repeated test had to be a `shared_example`. Any
shared context had to define common objects and instructions into `let` and
`before` blocks. The test subject had to fit into `subject` so that I could
write [one-liner expectations].

The following spec is an example of what I might have written a few years back:

```rb
RSpec.describe Rocket do
  subject { described_class.new }

  shared_example "it has the right agency" do
    it "indicates the agency collaborates in the ISS" do
      expect(subject.agency_collaborates_in_iss).to be(collaborates?)
    end
  end

  describe "agency" do
    let(:collaborates?) { true }

    include_examples "it has the right agency"

    context "when the rocket has an explicit name" do
      subject { described_class.new(name: name) }

      context "when the rocket is named Ariane 5" do
        let(:name) { "Ariane 5" }
        let(:collaborates?) { true }

        include_examples "it has the right agency"
      end
    end
  end
end
```

This spec is very hard to read and to update. Developers reading this code, which might be
ourselves three months from now, will have to jump from top to bottom and back
again to understand what is being tested, and how. Any new scenario that cannot comply
with this heavily abstracted code will require a significant refactoring. But at
least, so I thought, I was taking advantage of the full power of RSpec by
using as much of the DSL as possible. After all, if all these methods and tools
existed, this had to be for a good reason, and using them had to be good
practice.

However, over the years at thoughtbot, I have learned that a much better approach is to explicitly define
the [setup, exercise and verification]. We can understand immediately what is going on by reading the test, and the results is a much more maintainable test suite.

The biggest improvement I made was to get rid of
nested scenarios. It not only complicates the setup, but it also encourages shared
code within `let` and `before` blocks. The only shared code that remained was
`shared_examples`, but they can be removed easily once we understand we don't
have to factor out specs, and it is okay to repeat a similar setup multiple times
to have [self-contained] tests.

I am not saying these DSL methods are not useful. However, they can easily bring
anti-patterns into our tests and require us to learn a RSpec-specific syntax that is
not universal in Ruby.

I still use a lot of RSpec DSL, such as [test doubles] and [mocks].

[one-liner expectations]: https://rspec.info/features/3-12/rspec-core/subject/one-liner-syntax/
[setup, exercise and verification]: https://thoughtbot.com/blog/four-phase-test
[self-contained]: https://thoughtbot.com/blog/the-self-contained-test
[test doubles]: https://rspec.info/features/3-13/rspec-mocks/basics/test-doubles/
[mocks]: https://rspec.info/features/3-13/rspec-mocks/

## Lightweight factories

I used to build exhaustive [factories]. Large factories are useful when working
with tightly-coupled domain objects, when we rarely use an object without
associated ones. In feature specs, request specs, or when testing some service
objects, it feels safe to immediately have the whole picture.

Let's take the example of a crewed space launch. A lot of the scenarios we test happen while the rocket is in space, carrying crew,
needing an engine to lift off, so I used to like building such factories for
their comprehensiveness:

```rb
factory :flight do
  launched_on { Date.new(1963, 6, 16) }
  succeeded { true }
  rocket

  after(:create) do |flight|
    flight.passengers << create(:passenger)
  end
end

factory :rocket do
  name { "Vostok-3KA" }
  engine
end

factory :engine do
  name { "S5.4" }
end

factory :passenger do
  full_name { "Valentina Tereshkova" }
end
```

I slowly discovered that this came with drawbacks.

First, speed: Each time we instantiate a `flight`, both a `rocket` and its `engine`
instance will be added to it. If we `create` it, `flight`, `rocket` and `engine`
records will be created, alongside a `passenger` record, associated to the
`flight`. These dependencies are unnecessary when we use the `flight` object in
an isolated use case, like model specs. Creating them slows down the whole test
suite.

The benefit of having all these dependencies available right away is counterbalanced by the fact that we often want to customize these dependencies. So, in the end, we often have to update or
even get rid of the factory-generated dependencies:

```rb
standalone_rocket = create(:rocket, engine: nil)
standalone_rocket.passengers.destroy_all
```

Instead, what we can do is define light-weight factories that contain
only the necessary attributes to be created, but which include traits that provide dependencies only when needed. In other words, to pass validations, without sacrificing the convenience of easily creating instances
with default associations.

```rb
factory :rocket do
  name { "Vostok-3KA" }

  trait :with_engine do
    engine
  end

  trait :with_passengers do
    after(:create) do |rocket|
      rocket.passengers << create(:passenger)
    end
  end
end

standalone_rocket = create(:rocket)
rocket_with_an_engine = create(:rocket, :with_engine)
rocket_with_passengers = create(:rocket, :with_passengers)
```

Of course, getting there required updating some tests that were implicitly
carrying a bunch of dependencies, but this burden is worth it to have a
maintainable test suite.

[factories]: https://github.com/thoughtbot/factory_bot

## Avoiding randomness

I used to have an unjustified fear of explicitness. In
factories, I was afraid of defining fixed strings, thinking they wouldn't
represent the diversity of possible inputs.

For several years, I used the [faker gem] to replace user names, addresses, phone
numbers or even numbers. My initial goal was that I would be able to discover
new unsupported inputs from failing tests and have to adapt my code to deal
with such data.

```rb
factory :user do
  name { Faker::Name.name }
  sequence(:email) { |n| Faker::Internet.email("user_#{n}") }
end
```

That was a terrible assumption, as I was only introducing a delicious cocktail of
[flaky tests]. By definition, some tests would randomly fail depending on the
generated data. The failing tests were not even always linked to the data
itself, but failed as a side effect.

Instead, we should favor explicit inputs and test our code over data we
understand. I believe there are two solutions against fearing user input:

- Spend time thinking of unusual inputs and testing them
- Fail gracefully when we don't support the input

I still use randomness and the `faker` gem a lot, but not for the same reasons.
For example, it is useful when creating development fixtures (and not testing
fixtures), to easily have a diversified data set that enables multiple features
to be used. But I tend to favor explicitness when writing tests.

[faker gem]: https://github.com/faker-ruby/faker
[flaky tests]: https://thoughtbot.com/blog/dealing-with-flaky-tests

## Testing what the user sees in feature specs

I used to rely a lot on HTML elements, especially classes and ids, when coding
[feature specs]. DOM attributes look convenient to use as they are often unique
element identifiers.

One main reason for this was that I misunderstood what I now believe is the
real purpose of feature specs. Feature specs differ from other tests in the way
we test the big picture, from a user's point of view. As a user, I am not
interacting with HTML tags or CSS classes, I am interacting with
[what the browsing software describes to me].

Let's have a look at the following spec to ensure users logged in from their
GitHub accounts are not offered to edit their password:

```rb
describe "when user signed in with their GitHub account" do
  it "doesn't show the credentials edition button" do
    user = create(:user, :from_github)

    visit root_path(as: user)
    click_on(css: "edit-account-link")

    expect(page).not_to have_css("form#edit_password")
  end
end
```

Relying on DOM attributes makes our tests fragile. These elements can and should
be easy to change by developers, designers and anyone else on the team, without
breaking any tests. They don't even represent what we really want to test in
feature specs, which is to assert elements and events.

We should prefer relying on what the user really sees, such as text, with a bonus point when using internationalization to reduce text coupling.

```rb
describe "when user signed in with their GitHub account" do
  it "doesn't show the credentials edition button" do
    user = create(:user, :from_github)

    visit root_path(as: user)
    click_on I18n.t("menu.edit_account")

    expect(page).not_to(
      have_content I18n.t("edit_account.password")
    )
  end
end
```

This is not always easy, or even possible. It is a good practice. The goal is to
tend towards it. By doing so, we stay close to a readable, maintainable and
efficient test suite, and we avoid anti-patterns that could
ruin the party. And that's what it's all about.

[feature specs]: https://thoughtbot.com/blog/how-we-test-rails-applications#feature-specs
[what the browsing software describes to me]: https://thoughtbot.com/blog/improving-the-usability-and-accessibility-of-a-healthcare-website-by-being-mindful-of-reading-level

## Keep learning and trying

In this article, we learned a few tips to avoid anti-patterns in tests by minimising the use of
fancy DSL, keeping tests clear and explicit, and avoiding loading of
unnecessary dependencies.

Here at thoughtbot, we are always learning new things
and looking for ways to improve the quality of our tests. Our blog is filled with
[great content on testing], and we even wrote a
[book on how to test Rails applications]. If you have a Ruby on Rails test suite that is beginning to creak, then [get in touch today] to see how our expert Rails developers can help elevate your software and your team.

[betterspecs.org]: https://www.betterspecs.org
[great content on testing]: https://thoughtbot.com/blog/tags/testing
[book on how to test Rails applications]: https://books.thoughtbot.com/books/testing-rails.html
[get in touch today]: https://thoughtbot.com/services/ruby-on-rails-development
