Skip to content

Latest commit

 

History

History
693 lines (541 loc) · 19.8 KB

test-driven-development.adoc

File metadata and controls

693 lines (541 loc) · 19.8 KB

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 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.

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
$ 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"):

app/models/user.rb
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:

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:

test/fixtures/users.yml
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):

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 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.

Functional Tests

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

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.

Unit Tests

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:

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 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:

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

Fixtures

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.

Static Fixtures

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:

test/fixtures/users.yml
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.

Fixtures with ERB

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

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

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:

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_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

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.

More on Testing

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.