Preface

Testing in Rails represents more than a decade of learning how to test Rails applications, including multiple times where I've sworn off writing tests due to bad experiences with poorly-structured test suites.

This book is meant to guide Rails developers down the path of both the "why" and "how" of testing Rails applications, as well as highlight potential pitfalls and advanced patterns to make working in test suites delightful.

What to Expect when Learning How to Test

If you have no experience doing test-driven development or writing tests more broadly, learning to test Rails applications will very likely be frustrating.

Rails has the keen ability of making developers familiar with the framework quite efficient when following Rails' conventions; introducing test-driven development into that mix, especially early on, can feel like everything crawls to a halt. Developers experienced with "the Rails way" will find writing tests cumbersome as they develop these skills.

Expectations

The trick here is to set your (and your team's) expectations properly. Testing will reduce development velocity. As with any new skill, you will be bad before you get good, and the way to get good is to practice.

How you practice is just as important as what you practice.

This book will cover both.

What is Automated Testing?

Automated testing is software used to ensure a software product behaves as expected by executing code to exercise the behavior of the product.

This differs from manual testing, which is a process where a human interacts with the software product by executing code (for web applications, very often through a web browser).

With automated tests, a developer can ensure the application "works" (as defined by the tests) without interacting with the program herself. As software (and the teams developing them) grows, automated tests provide a safety net and build confidence that the application continues to work as expected without behavior regressions or bugs. This is especially important when working in Ruby, since the language is interpreted and dynamically typed.

Why Write Automated Tests?

Now that we know what automated testing is, it's worth discussing why we would spend time writing (and maintaining) a test suite.

Money

At the end of the day, everything boils down to money.

Customers expect a working product, and given enough frustrating experiences, may switch to a different piece of software if what we've built doesn't behave correctly.

Our teammates expect to work in a codebase that operates as intended, with the confidence that what they (and their teammates) have developed can be modified or extended as needs change without breaking what exists.

Stakeholders expect development velocity to remain high with a nimble team, even as the surface area of the application grows.

Confidence

Automated tests provide the confidence that the application will continue to work, because if it doesn't in certain situations, the test suite should fail and notify the team.

End-to-end Tests

End-to-end tests are tests that ensure the application is behaving as expected through the lens of the consumer.

For a web application, this would be tests that interact via a web browser (either simulated or through a web "driver"); for an API, this would be tests that interact with application endpoints by using the appropriate HTTP verb and request headers and body.

In both of these cases, tests will assert against what a user would be able to interact with: often the HTML available on the page for a web application, or the response of an API call. Depending on the test path through the application, the test may assert that email delivery occurred, a third-party API was hit, etc.

These tests are often the most time-intensive, as they're testing multiple pieces of the application stack in concert.

Example

require "rails_helper"

RSpec.describe "Member manages their profile data" do
  it "by updating their email" do
    sign_in

    click_on "Manage Profile"

    new_email = generate(:email)

    fill_in "Email", with: new_email
    click_on "Update Profile"

    expect(page).to have_pending_email_notification

    open_email_for(new_email)

    expect(current_email).to have_subject("Confirm your updated email address")
    click_first_link_in_email(current_email)

    expect(page).to have_thanks_for_confirming_notification
  end
end

Integration Tests

Integration tests are tests that ensure a subset of application layers (but not in entirety) is behaving as expected.

Integration tests may test application behavior against data persisted in a database, or against a particular set of interactions against a third-party API.

Example

require "rails_helper"

RSpec.describe UpcomingEventsQuery do
  it "calculates count" do
    create_list(:event, 5, :upcoming)
    create_list(:event, 5, :past)

    expect(UpcomingEventsQuery.new.count).to eq(5)
  end

  it "returns the right values" do
    upcoming = create(:event, :upcoming)
    create(:event, :past)

    expect(UpcomingEventsQuery.new).to match_array([upcoming])
  end
end

Unit Tests

Unit tests assert against behavior of an individual component within the application (often, an individual method or function).

These tests are fast and don't interact with the "outside world".

Example

require "rails_helper"

RSpec.describe Calculator do
  describe ".add" do
    it "adds positive integers" do
      expect(Calculator.add(1, 2)).to eq(3)
    end

    it "adds negative integers" do
      expect(Calculator.add(-1, -2)).to eq(-3)
    end
  end
end

Test-Driven Development

Test-driven development (TDD) is a development practice where the software developer writes one or more tests before writing any implementation code.

TDD allows developers to write tests before virtually all implementation if desired.

This is easier said than done, however; writing tests well is a skill just as writing application code well is a skill. Additionally, the impact to velocity and change in workflow can be quite discouraging to teammates who haven't developed software in this fashion before because it requires a different approach to solving problems.

With time, practice, and mentorship, developers can write code in a test-driven fashion with less of a velocity impact.

Outside-In Development

Outside-in development is a methodology that focuses on customer needs by solving for core business flows in the fashion expected for use. In the case of a web application, for example, development would first begin with solving for the set of interactions with a webpage and work backwards into domain models and other abstractions.

Within the realm of testing, we follow this pattern by:

  1. Writing an end-to-end test asserting behavior through a web browser
  2. Writing implementation to get that test to pass
  3. Along the way, writing integration or unit tests to replicate the failure at the level above
  4. Once the integration or unit test passes, re-run the end-to-end test
  5. Continue this process until the end-to-end test passes

Example

Imagine a scenario where we display a birthday message for a member of the site.

We might first write an end-to-end test as:

require "rails_helper"

RSpec.describe "Wishing the user a happy birthday" do
  it "with their age" do
    travel_to Date.new(2023, 1, 1) do
      member = create(:member, born_on: Date.new(1983, 1, 1))
      sign_in_as(member)

      expect(page).to have_notification("Happy 40th birthday!")
    end
  end
end

With an empty ActiveRecord model:

class Member < ApplicationRecord
end

And the view:

<% if current_member.birthday? %>
  <p class="notice">Happy <%= current_member.age.ordinalize %> birthday!</p>
<% end %>

We'd see NoMethodErrors for both Member#birthday? and Member#age. Until this point, we've been working in the "outside" portion of outside-in development. Because we can now write tests at the unit level, we can implement a test (and implementation) for Member#birthday?:

require "rails_helper"

RSpec.describe Member do
  describe "#birthday?" do
    it "returns true if the month and day are the same as today" do
      travel_to Date.new(2023, 1, 1) do
        member = Member.new(born_on: Date.new(2020, 1, 1))

        expect(member.birthday?).to be_true
      end
    end

    it "returns false if the month and day are different than today" do
      travel_to Date.new(2023, 1, 1) do
        member = Member.new(born_on: Date.new(2020, 1, 2))

        expect(member.birthday?).to be_false
      end
    end
  end
end

The implementation:

class Member < ApplicationRecord
  def birthday?
    Date.current.month == born_on.month && Date.current.day == born_on.day
  end
end

Next, we'd re-run the end-to-end test and see a NoMethodError for Member#age, write the unit test and implementation (the "in" of outside-in development), see the unit test pass, and finally see the end-to-end test pass.

While this process might sound tedious, it helps drive implementation and correct behavior for only what's necessary for the code to work as expected.

Red, Green, Refactor

Red, green, refactor is an approach of test-driven development where the developer writes a failing test (red), writes the implementation to make the test pass (green), and finally adjusts the implementation of the application or test code to be well-factored (refactor).

The Four-Phase Test

Well-structured tests often follow a specific ordering of work:

  1. Setup
  2. Exercise
  3. Verify
  4. Teardown

Setup

Test setup may include object or state instantiation, including initializing variables, persisting data to the database, or writing files to disk.

Exercise

With state in place, the test will then exercise the system under test by executing necessary behavior of the particular area of the code.

Verify

In the verify step, we then make assertions against the outcome of the behavior exercised to ensure the results are expected.

Teardown

During the teardown step, we clean up any global state (e.g. rolling back database transactions or cleaning up created files) and un-mock any objects.