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 should 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 check this 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 read 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 this particular problem".
Often, the advantage of TDD already becomes evident when 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 test-driven development with Rails. If you want to find out more, you can dive into the official Rails documentation at http://guides.rubyonrails.org/testing.html
Note
|
TDD is just like driving a car. The only way to learn it is by doing it. |
Let’s start with a user scaffold in an imaginary web shop:
$ rails new webshop
[...]
$ cd webshop
$ 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
invoke helper
create app/helpers/users_helper.rb
invoke test_unit
[...]
invoke test_unit
create test/system/users_test.rb
[...]
$ 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 have 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
rails test
. Let’s have a go and see what a test produces at this stage
of development:
$ rails test
Running via Spring preloader in process 88110
Run options: --seed 54773
# Running:
.......
Finished in 0.994387s, 7.0395 runs/s, 9.0508 assertions/s.
7 runs, 9 assertions, 0 failures, 0 errors, 0 skips
The output 7 runs, 9 assertions, 0 failures, 0 errors, 0 skips
looks
good. By default, a test will run through in a standard scaffold.
Let’s edit the 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 rails test
again:
$ rails test
Running via Spring preloader in process 89164
Run options: --seed 40163
# Running:
....F
Failure:
UsersControllerTest#test_should_update_user [/Users/stefan/tmp/book/webshop/test/controllers/users_controller_test.rb:38]:
Expected response to be a <3XX: redirect>, but was a <200: OK>
bin/rails test test/controllers/users_controller_test.rb:36
F
Failure:
UsersControllerTest#test_should_create_user [/Users/stefan/tmp/book/webshop/test/controllers/users_controller_test.rb:19]:
"User.count" didn't change by 1.
Expected: 3
Actual: 2
bin/rails test test/controllers/users_controller_test.rb:18
.
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 error happens in the
UsersControllerTest#test_should_update_user
and the
UsersControllerTest#test_should_create_user
. The
explanation for this is in our validation. The example data created by
the scaffold generator went through in the first rails test
(without
validation). The errors only occurred the second time (with validation).
This example data is created as _fixtures_tests tests
in YAML format in the directory test/fixtures/
. Let’s have a look at
the example data for User
in the file test/fixtures/users.yml
:
one:
login_name: MyString
first_name: MyString
last_name: MyString
birthday: 2017-03-25
two:
login_name: MyString
first_name: MyString
last_name: MyString
birthday: 2017-03-25
There are two example records there that do not fulfill the requirements
of our validation. The login_name
should have a length of at least 10.
Let’s change the login_name
in test/fixtures/users.yml
accordingly:
one:
login_name: MyString12
first_name: MyString
last_name: MyString
birthday: 2017-03-25
two:
login_name: MyString12
first_name: MyString
last_name: MyString
birthday: 2017-03-25
Now, a rails test
completes without any errors again:
$ rails test
Running via Spring preloader in process 89807
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 the
test/fixtures/users.yml
so that the standard test created via scaffold
will succeed. But nothing more. Next step is to change the
test/fixtures/users.yml
to a minimum (for example, 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 rails test
after making
our changes (you really can’t do that often enough):
$ rails test
Running via Spring preloader in process 89972
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
Important
|
All fixtures are loaded into the database when a test is
started. You need to keep this in mind for your test,
especially if you use uniqueness in your validation.
|
Let’s take a closer look at the point where the original errors occurred:
Failure:
UsersControllerTest#test_should_create_user
[/Users/stefan/tmp/book/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 the directory test/functional/
.
Let’s now take a good look at the file
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 the file
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.
Note
|
For every test (in other words, at each run of 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
rails console (that is the development database). The databases are
defined in the configuration file config/database.yml . If you want to
do debugging, you can access the test database with
rails console test .
|
This functional test then tests 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_path(User.last)
checks if
after the newly created record the redirection to the corresponding view
show
occurs.
Without describing each individual functional test line by line, it’s becoming clear what these tests do: they execute real queries to the Web interface (or actually to the controllers) and so they can be used for testing the controllers.
For testing the validations that we have entered in
app/models/user.rb
, units tests are more suitable. Unlike the
functional tests, these test only the model, not the controller’s work.
The unit tests are located in the directory test/models/
. But a look
into the file 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 this assertion is false
, the test fails
and we have an error in the program (you can specify the output of the
error as string at the end of the assert line).
If you have a look at guides.rubyonrails.org/testing.html
you’ll see that there are some other 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] )
Let’s breathe some life into the first test in the
test/unit/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 rails test
for the complete test suite:
$ rails test
Running via Spring preloader in process 91049
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 in a test to check if the two fixture
entries in the test/fixtures/users.yml
are really valid. The first
one is just a shorter version of the empty user test.
require 'test_helper'
class UserTest < ActiveSupport::TestCase
test 'an empty user is not valid' do
assert !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 a rails test
:
$ rails test
Running via Spring preloader in process 91434
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
With fixtures you can generate example data for tests. The default
format for this is YAML. The files for this can be found in the
directory test/fixtures/
and are automatically created with
rails generate scaffold
. But of course you can also define your own
files. All fixtures are loaded anew into the test database by default
with every test.
Examples for alternative formats (e.g. CSV) can be found at api.rubyonrails.org/classes/ActiveRecord/Fixtures.html.
The simplest variant for fixtures is static data. The fixture for User
used in
"Example for a
User in a Web Shop" statically looks as follows:
one:
login_name: fox.mulder
last_name: Mulder
two:
login_name: dana.scully
last_name: Scully
You simple write the data in YAML format into the corresponding file.
Static YAML fixtures are sometimes too unintelligent. In these cases, you can work with ERB.
If we want to dynamically enter today’s day 20 years ago for the
birthdays, then 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_s(:db) %>
two:
login_name: dana.scully
last_name: Scully
birthday: <%= 20.years.ago.to_s(:db) %>
Integration tests are tests that work like functional tests but can go
over several controllers and additionally analyze 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 flash error messages.
A rails generate scaffold
generates unit and functional tests, but not
integration tests. You can either do this manually in the directory
test/integration/
or more comfortably with
rails generate integration_test
. So let’s create an integration test:
$ rails generate integration_test invalid_new_user_workflow
Running via Spring preloader in process 91538
invoke test_unit
create test/integration/invalid_new_user_workflow_test.rb
We now populate this 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_equal '/users', path
assert_select 'li', "Login name can't be blank"
assert_select 'li', "Login name is too short (minimum is 10 characters)"
end
end
Let’s run all tests:
$ rails test
Running via Spring preloader in process 91837
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 program much without manually using a web browser to try it out. Once you have written a test for the corresponding workflow, you can rely in future on the fact that it will run through and you don’t have to try it out manually in the browser as well.
rails stats With rails stats
you get an overview of your Rails project.
For our example, it looks like this:
$ 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 |
| JavaScripts | 31 | 4 | 0 | 1 | 0 | 2 |
| Libraries | 0 | 0 | 0 | 0 | 0 | 0 |
| Controller tests | 48 | 38 | 1 | 7 | 7 | 3 |
| Helper tests | 0 | 0 | 0 | 0 | 0 | 0 |
| Model tests | 14 | 12 | 1 | 2 | 2 | 4 |
| Mailer tests | 0 | 0 | 0 | 0 | 0 | 0 |
| 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 relation of 1:1.0.7. Logically, this does not say anything about the quality of tests.
We just scratched the surface of the topic TDD in Rails. Have a look at http://guides.rubyonrails.org/testing.html for more information. There you will also find several good examples on this topic.