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.
|
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"):
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:
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:
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):
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:
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:
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:
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:
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:
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):
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:
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:
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.
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-railsgem. -
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.