Tests

Introduction

I have been programming for over 30 years and most of the time I have managed quite well without test-driven development (TDD). I am not going to be mad at you if you decide to just skip this chapter. You can create Rails applications without tests and are not likely to get any bad karma as a result (at least I hope not, but you can never be entirely sure with the whole karma thing).

But if you do decide to go for TDD then I can promise you that it is an enlightenment. The basic idea of TDD is that you write a test for each programming function to verify that function. In the pure TDD teaching this test is written before the actual programming. Yes, you will have a lot more to do initially. But later you can run all the tests and see that the application works exactly as you wanted it to. The real advantage only becomes apparent after a few weeks or months when you look at the project again and write an extension or new variation. Then you can safely change the code and check it still works properly by running the tests. This avoids a situation where you find yourself saying "oops, that went a bit wrong, I just didn’t think of that particular case".

Often the advantage of TDD already becomes evident while writing a program. Tests can reveal many careless mistakes that you would otherwise only have stumbled across much later on.

This chapter is a brief overview of the topic of test-driven development with Rails. If you want to find out more you can dive into the official Rails testing guide at https://guides.rubyonrails.org/testing.html

TDD is just like driving a car. The only way to learn it is by doing it.
Rails ships two test runners out of the box: Minitest (the default) and a thin wrapper that lets you use RSpec via the rspec-rails gem if you prefer. This chapter uses the default — Minitest — because a fresh Rails application already has it wired up.
Agentic Coding Tip: Require the Agent to Close the Loop with bin/rails test

The single highest-leverage rule you can add to a Rails project’s CLAUDE.md:

After every code change, run `bin/rails test` and paste the
tail of the output into the conversation. Do not describe a
task as "done" until I can see a green run on my terminal.
If a test fails, fix it or explain why the expected
behaviour changed — never delete a failing test or stub it
out to make the suite green.

Agents love declaring victory. Without this rule they will edit the code, reason about what should happen, and report "the change is complete." Sometimes it really is. Often it isn’t, and you discover the regression three commits later when you happen to run the suite yourself.

Two specific failure modes to watch for in the diff review:

  • A test that used to assert X == 5 now asserts X == whatever the code produces. The test was updated to match the code, not the code to match the intent.

  • A test that was red is now skip "refactor later". Agents sometimes reach for skip when they can’t immediately see why a test fails.

Both patterns pass CI but leave the safety net shredded. A quick git diff test/ before approving the commit catches them in seconds.

The same rule extends naturally to other automated checks in the project, bin/rubocop, bin/brakeman, bin/rails zeitwerk:check. Whatever the project runs in CI, the agent should run locally before claiming the work is complete.

Example for a User in a Web Shop

Let’s start with a user scaffold in an imaginary web shop:

$ rails new webshop
  [...]
$ cd webshop
$ bin/rails generate scaffold user login_name first_name last_name birthday:date
    [...]
    invoke    test_unit
    create      test/models/user_test.rb
    create      test/fixtures/users.yml
    [...]
    invoke    test_unit
    create      test/controllers/users_controller_test.rb
    create      test/system/users_test.rb
    invoke    helper
    create      app/helpers/users_helper.rb
    invoke      test_unit
    [...]
$ bin/rails db:migrate
    [...]

You already know all about scaffolds (if not, please go and read the chapter "Scaffolding and REST" first) so you know what the application we just created does. The scaffold created a few tests (they are easy to recognise because the word test is in the file name).

The complete test suite of a Rails project is processed with the command bin/rails test. Let’s have a go and see what a test produces at this stage of development:

$ bin/rails test
Running 7 tests in a single process (parallelization threshold is 50)
Run options: --seed 11137

# Running:

.......

Finished in 0.114609s, 61.0772 runs/s, 95.9785 assertions/s.
7 runs, 11 assertions, 0 failures, 0 errors, 0 skips

The output 7 runs, 11 assertions, 0 failures, 0 errors, 0 skips looks good. By default a test will pass in a standard scaffold.

Rails 7+ runs tests in parallel by default once the number of tests in a single file crosses the parallelization threshold (50 by default). Below that threshold tests run in a single process, as you can see in the output.

Let’s edit app/models/user.rb and insert a few validations (if these are not entirely clear to you, please read the section "Validation"):

app/models/user.rb
class User < ApplicationRecord
  validates :login_name,
            presence: true,
            length: { minimum: 10 }

  validates :last_name,
            presence: true
end

Then we execute bin/rails test again:

$ bin/rails test
Running 7 tests in a single process (parallelization threshold is 50)
Run options: --seed 40163

# Running:

....F

Failure:
UsersControllerTest#test_should_update_user [/.../webshop/test/controllers/users_controller_test.rb:38]:
Expected response to be a <3XX: redirect>, but was a <422: Unprocessable Entity>

F

Failure:
UsersControllerTest#test_should_create_user [/.../webshop/test/controllers/users_controller_test.rb:19]:
"User.count" didn't change by 1.
Expected: 3
  Actual: 2

.

Finished in 0.262099s, 26.7075 runs/s, 30.5228 assertions/s.

7 runs, 8 assertions, 2 failures, 0 errors, 0 skips

Boom! This time we have 2 failures. The errors happen in UsersControllerTest#test_should_update_user and UsersControllerTest#test_should_create_user. The explanation for this is in our validation. The example data created by the scaffold generator passed the first bin/rails test (without validation). The errors only occurred the second time (with validation).

This example data is created as fixtures in YAML format in the directory test/fixtures/. Let’s have a look at the example data for User in test/fixtures/users.yml:

test/fixtures/users.yml
one:
  login_name: MyString
  first_name: MyString
  last_name: MyString
  birthday: 2026-01-25

two:
  login_name: MyString
  first_name: MyString
  last_name: MyString
  birthday: 2026-01-25

There are two example records that do not fulfill the requirements of our validation. The login_name should have a length of at least 10. Let’s change it in test/fixtures/users.yml:

test/fixtures/users.yml
one:
  login_name: MyString12
  first_name: MyString
  last_name: MyString
  birthday: 2026-01-25

two:
  login_name: MyString12
  first_name: MyString
  last_name: MyString
  birthday: 2026-01-25

Now a bin/rails test completes without any errors again:

$ bin/rails test
Running 7 tests in a single process (parallelization threshold is 50)
Run options: --seed 50152

# Running:

.......

Finished in 0.271182s, 25.8129 runs/s, 33.1880 assertions/s.

7 runs, 9 assertions, 0 failures, 0 errors, 0 skips

Now we know that valid data has to be contained in test/fixtures/users.yml so that the standard test created via scaffold will succeed. But nothing more. Next step is to trim the test/fixtures/users.yml to a minimum (we do not need a first_name):

test/fixtures/users.yml
one:
  login_name: MyString12
  last_name: Mulder

two:
  login_name: MyString12
  last_name: Scully

To be on the safe side let’s do another bin/rails test after making our changes (you really can’t do that often enough):

$ bin/rails test
Running 7 tests in a single process (parallelization threshold is 50)
Run options: --seed 40198

# Running:

.......

Finished in 0.255256s, 27.4234 runs/s, 35.2587 assertions/s.

7 runs, 9 assertions, 0 failures, 0 errors, 0 skips
All fixtures are loaded into the database when a test starts. You need to keep this in mind for your tests, especially if you use uniqueness in your validations.

Functional (Controller) Tests

Let’s take a closer look at the point where the original errors occurred:

Failure:
UsersControllerTest#test_should_create_user
[/.../webshop/test/controllers/users_controller_test.rb:19]:
"User.count" didn't change by 1.
Expected: 3
  Actual: 2

In the UsersControllerTest the User could not be created. The controller tests are located in test/controllers/. Let’s take a good look at test/controllers/users_controller_test.rb:

test/controllers/users_controller_test.rb
require "test_helper"

class UsersControllerTest < ActionDispatch::IntegrationTest
  setup do
    @user = users(:one)
  end

  test "should get index" do
    get users_url
    assert_response :success
  end

  test "should get new" do
    get new_user_url
    assert_response :success
  end

  test "should create user" do
    assert_difference("User.count") do
      post users_url, params: { user: { birthday: @user.birthday, first_name: @user.first_name, last_name: @user.last_name, login_name: @user.login_name } }
    end

    assert_redirected_to user_url(User.last)
  end

  test "should show user" do
    get user_url(@user)
    assert_response :success
  end

  test "should get edit" do
    get edit_user_url(@user)
    assert_response :success
  end

  test "should update user" do
    patch user_url(@user), params: { user: { birthday: @user.birthday, first_name: @user.first_name, last_name: @user.last_name, login_name: @user.login_name } }
    assert_redirected_to user_url(@user)
  end

  test "should destroy user" do
    assert_difference("User.count", -1) do
      delete user_url(@user)
    end

    assert_redirected_to users_url
  end
end

At the beginning we find a setup instruction:

setup do
  @user = users(:one)
end

These three lines of code mean that for the start of each individual test an instance @user with the data of the item one from test/fixtures/users.yml is created. setup is a predefined callback that (if present) is started by Rails before each test. The opposite of setup is teardown. A teardown (if present) is called automatically after each test.

For every test run (in other words, every time you run bin/rails test) a fresh and therefore empty test database is created automatically. This is a different database than the one that you access by default via bin/rails console (that is the development database). The databases are defined in the configuration file config/database.yml. If you want to debug, you can access the test database with bin/rails console -e test.

This controller test exercises various web page functions. First, accessing the index page:

test "should get index" do
  get users_url
  assert_response :success
end

The command get users_url accesses the page /users. assert_response :success means that the page was delivered.

Let’s look more closely at the should create user problem from earlier:

test "should create user" do
  assert_difference("User.count") do
    post users_url, params: { user: { birthday: @user.birthday, first_name: @user.first_name, last_name: @user.last_name, login_name: @user.login_name } }
  end

  assert_redirected_to user_url(User.last)
end

The block assert_difference("User.count") do …​ end expects a change by the code contained within it: User.count after should result in +1.

The last line assert_redirected_to user_url(User.last) checks that after the newly-created record a redirect to the corresponding show view occurs.

Without describing each individual controller test line by line, it’s becoming clear what these tests do: they make real requests to the web interface (through the controllers) and can therefore be used to test controllers.

Model (Unit) Tests

For testing the validations we have entered in app/models/user.rb, unit tests are more suitable. Unlike the controller tests, they test only the model, not the controller’s work.

The model tests are located in test/models/. But a look into test/models/user_test.rb is rather sobering:

test/models/user_test.rb
require "test_helper"

class UserTest < ActiveSupport::TestCase
  # test "the truth" do
  #   assert true
  # end
end

By default scaffold only writes a commented-out dummy test.

A unit test always consists of the following structure:

test "an assertion" do
  assert something_is_true
end

The word assert already indicates that we are dealing with an assertion in this context. If this assertion is true the test will complete and all is well. If it is false the test fails and we have an error in the program (you can specify the output of the error as a string at the end of the assert line).

If you have a look at https://guides.rubyonrails.org/testing.html you’ll see that there are many assert variations. Here are a few examples:

  • assert( boolean, [msg] )

  • assert_equal( obj1, obj2, [msg] )

  • assert_not_equal( obj1, obj2, [msg] )

  • assert_same( obj1, obj2, [msg] )

  • assert_not_same( obj1, obj2, [msg] )

  • assert_nil( obj, [msg] )

  • assert_not_nil( obj, [msg] )

  • assert_match( regexp, string , [msg] )

  • assert_no_match( regexp, string , [msg] )

  • assert_changes( expression, [from:], [to:], [msg] )

  • assert_no_changes( expression, [msg] )

Let’s breathe some life into test/models/user_test.rb:

test/models/user_test.rb
require "test_helper"

class UserTest < ActiveSupport::TestCase
  test "a user with no attributes is not valid" do
    user = User.new
    assert_not user.save, "Saved a user with no attributes."
  end
end

This test checks if a newly-created User that does not contain any data is valid (it shouldn’t be).

We can run a bin/rails test for the complete test suite:

$ bin/rails test
Running 8 tests in a single process (parallelization threshold is 50)
Run options: --seed 8014

# Running:

........

Finished in 0.248883s, 32.1436 runs/s, 40.1795 assertions/s.

8 runs, 10 assertions, 0 failures, 0 errors, 0 skips

Now we integrate two asserts into a test to check if the two fixture entries in test/fixtures/users.yml are really valid:

require "test_helper"

class UserTest < ActiveSupport::TestCase
  test "an empty user is not valid" do
    assert_not User.new.valid?, "Saved an empty user."
  end

  test "the two fixture users are valid" do
    assert User.new(last_name: users(:one).last_name,
                    login_name: users(:one).login_name).valid?,
           "First fixture is not valid."
    assert User.new(last_name: users(:two).last_name,
                    login_name: users(:two).login_name).valid?,
           "Second fixture is not valid."
  end
end

Then once more bin/rails test:

$ bin/rails test
Running 9 tests in a single process (parallelization threshold is 50)
Run options: --seed 57493

# Running:

.........

Finished in 0.256179s, 35.1317 runs/s, 46.8422 assertions/s.

9 runs, 12 assertions, 0 failures, 0 errors, 0 skips

System Tests

System tests are new-ish: they were introduced in Rails 5.1. Unlike controller or model tests they open a real browser (headless by default) and click through your application the way a human user would, verifying the rendered HTML and even JavaScript behaviour.

A fresh Rails 8 scaffold already creates a system test at test/system/users_test.rb:

test/system/users_test.rb
require "application_system_test_case"

class UsersTest < ApplicationSystemTestCase
  setup do
    @user = users(:one)
  end

  test "visiting the index" do
    visit users_url
    assert_selector "h1", text: "Users"
  end

  test "should create user" do
    visit users_url
    click_on "New user"

    fill_in "Birthday", with: @user.birthday
    fill_in "First name", with: @user.first_name
    fill_in "Last name", with: @user.last_name
    fill_in "Login name", with: @user.login_name
    click_on "Create User"

    assert_text "User was successfully created"
    click_on "Back to users"
  end
  # [...]
end

System tests are driven by Capybara and run by default against headless Chrome via Selenium. The base class is defined in test/application_system_test_case.rb:

test/application_system_test_case.rb
require "test_helper"

class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
  driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]
end

Run them with bin/rails test:system (or as part of the complete suite with bin/rails test:all). When a system test fails Rails automatically saves a PNG screenshot to tmp/screenshots/ so you can see what the browser saw at the point of failure.

If you prefer a lighter-weight headless browser, swap Selenium for Cuprite: driven_by :cuprite. Cuprite talks directly to Chrome via CDP, boots faster, and avoids the Selenium driver.

Fixtures

With fixtures you can generate example data for tests. The default format is YAML. The files can be found in test/fixtures/ and are automatically created by bin/rails generate scaffold. Of course you can also define your own files. All fixtures are loaded anew into the test database by default on every test run.

Examples for alternative formats (e.g. CSV) are at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html

Static Fixtures

The simplest variant is static data. The fixture for User used in "Example for a User in a Web Shop" statically should look as follows (please change the content of the file accordingly):

test/fixtures/users.yml
one:
  login_name: fox.mulder
  last_name: Mulder

two:
  login_name: dana.scully
  last_name: Scully

You simply write the data in YAML format into the corresponding file.

Fixtures with ERB

Static YAML fixtures are sometimes too limited. In those cases you can use ERB.

If we want to dynamically enter today’s date 20 years ago for the birthdays, we can simply do it with ERB in test/fixtures/users.yml:

test/fixtures/users.yml
one:
  login_name: fox.mulder
  last_name: Mulder
  birthday: <%= 20.years.ago.to_fs(:db) %>

two:
  login_name: dana.scully
  last_name: Scully
  birthday: <%= 20.years.ago.to_fs(:db) %>
Older editions used to_s(:db). That short form was deprecated in Rails 7 and removed later. The current call is to_fs(:db) — the fs stands for "formatted string". For plain to_s without a format argument the old call still works.

Integration Tests

Integration tests work like controller tests but can span several controllers and additionally analyse the content of a generated view. So you can use them to recreate complex workflows within the Rails application. As an example we will write an integration test that tries to create a new user via the web GUI but omits the login_name and consequently gets corresponding error messages.

bin/rails generate scaffold generates model, controller and system tests, but not standalone integration tests. You can either do this manually in the directory test/integration/ or more comfortably with bin/rails generate integration_test:

$ bin/rails generate integration_test invalid_new_user_workflow
      invoke  test_unit
      create    test/integration/invalid_new_user_workflow_test.rb

We populate the file test/integration/invalid_new_user_workflow_test.rb with the following test:

test/integration/invalid_new_user_workflow_test.rb
require "test_helper"

class InvalidNewUserWorkflowTest < ActionDispatch::IntegrationTest
  fixtures :all

  test "try to create a new user without a login" do
    @user = users(:one)

    get "/users/new"
    assert_response :success

    post users_url, params: { user: { last_name: @user.last_name } }
    assert_response :unprocessable_entity
    assert_select "li", "Login name can't be blank"
    assert_select "li", "Login name is too short (minimum is 10 characters)"
  end
end
In Rails 5.2 (and the previous edition of this book) the expectation above was assert_equal "/users", path because a failed create used to render a 200 OK and let the request reuse the /users URL. Rails 7+ instead returns 422 Unprocessable Entity so that Turbo can recognise the form-with-errors response. The assertion assert_response :unprocessable_entity is the modern check.

Let’s run all tests:

$ bin/rails test
Running 10 tests in a single process (parallelization threshold is 50)
Run options: --seed 4153

# Running:

..........

Finished in 0.277714s, 36.0083 runs/s, 57.6132 assertions/s.

10 runs, 16 assertions, 0 failures, 0 errors, 0 skips

The example clearly shows that you can write a lot of application code without manually using a web browser to try it out. Once you have written a test for the corresponding workflow, you can rely in the future on the fact that it still passes, and you don’t have to click through the browser every time.

bin/rails stats

With bin/rails stats you get an overview of your Rails project. For example, it looks like this:

$ bin/rails stats
+----------------------+--------+--------+---------+---------+-----+-------+
| Name                 |  Lines |    LOC | Classes | Methods | M/C | LOC/M |
+----------------------+--------+--------+---------+---------+-----+-------+
| Controllers          |     77 |     53 |       2 |       9 |   4 |     3 |
| Helpers              |      4 |      4 |       0 |       0 |   0 |     0 |
| Jobs                 |      2 |      2 |       1 |       0 |   0 |     0 |
| Models               |     11 |     10 |       2 |       0 |   0 |     0 |
| Mailers              |      4 |      4 |       1 |       0 |   0 |     0 |
| Channels             |      8 |      8 |       2 |       0 |   0 |     0 |
| Stylesheets          |      0 |      0 |       0 |       0 |   0 |     0 |
| JavaScripts          |     31 |      4 |       0 |       1 |   0 |     2 |
| Controller tests     |     48 |     38 |       1 |       7 |   7 |     3 |
| Model tests          |     14 |     12 |       1 |       2 |   2 |     4 |
| Integration tests    |     17 |     13 |       1 |       1 |   1 |    11 |
| System tests         |      9 |      3 |       1 |       0 |   0 |     0 |
+----------------------+--------+--------+---------+---------+-----+-------+
| Total                |    225 |    151 |      12 |      20 |   1 |     5 |
+----------------------+--------+--------+---------+---------+-----+-------+
  Code LOC: 88     Test LOC: 63     Code to Test Ratio: 1:0.7

In this project we have a total of 88 LOC (Lines Of Code) in the controllers, helpers and models. We have a total of 63 LOC for tests. This gives us a test ratio of 1 to 0.7. Of course this does not say anything about the quality of the tests.

Prompt pattern: TDD with an AI agent

The red-green-refactor loop is exactly the loop you want an agent to drive. It’s self-verifying: every step either makes a failing test pass or breaks nothing that worked before.

A typical Claude Code session on a feature I haven’t built yet:

  1. Write a failing test in test/models/post_test.rb that asserts a Post.popular scope returns posts with more than 10 comments.

  2. Run bin/rails test test/models/post_test.rb to confirm it’s red.

  3. Implement the scope in app/models/post.rb.

  4. Run the same test again and show me it’s green.

  5. Don’t commit yet — I’ll review the diff first.

Note the explicit "don’t commit yet". Agents that are good at committing are also good at committing things you haven’t reviewed. Keep the human in the loop for step 6. See Coding Rails With an AI Agent for the end-to-end story.

More on Testing

We just scratched the surface of TDD in Rails. Have a look at https://guides.rubyonrails.org/testing.html for more information. There you will also find several good examples.

For tools beyond the built-in Minitest set-up, explore:

  • RSpec — the other popular Ruby test framework; add via the rspec-rails gem.

  • FactoryBot — a more flexible alternative to YAML fixtures.

  • Shoulda Matchers — one-liners for common ActiveRecord and controller assertions.

  • Capybara — the engine Rails system tests already use under the hood, also useful standalone.