Why write tests?

Testing is an important aspect of software development life cycle. It is a means to ensure the quality of product is maintained. When our application grows in size, we are likely to refactor a lot of code. Writing tests will help us ensure that changes made to the code adheres to the desired functionality. Fortunately, Rails provides us a very easy and efficient way of writing tests.

Test setup

When a new Rails application is created using rails new command, Rails automatically creates a directory called test. This is what test directory looks as of now.

1$ brew install tree
2$ cd test
3$ tree
5├── application_system_test_case.rb
6├── channels
7│   └── application_cable
8│       └── connection_test.rb
9├── controllers
10│   └── tasks_controller_test.rb
11├── fixtures
12│   └── files
13├── helpers
14├── integration
15├── mailers
16├── models
17├── system
18└── test_helper.rb

It is implicit that the directories controllers, helpers, mailers and models hold the test code for controllers, helpers, mailers and models that we added in app directory respectively. Integration tests are responsible to test interactions between controllers while system tests are responsible to test the app as per user's experience on a browser and includes testing JavaScript code. The file test_helper.rb holds the configuration for all the tests. This file will be loaded in all the test files in order to load the test configurations. Similarly, application_system_test_case.rb holds the default configuration for system tests.

ActionView::Helpers::TranslationHelper is a Rails module which provides various helper methods which we are going to use in our tests.

Open 'test/test_helper.rb' and add the following line.

1class ActiveSupport::TestCase
2  include ActionView::Helpers::TranslationHelper
4  # Run tests in parallel with specified workers
5  parallelize(workers: :number_of_processors)
7  # Add more helper methods to be used by all tests here...
10In this chapter we will focus on testing our `Task` model.
12## Fixtures
14By default Rails creates `test/fixtures` directory and recommends us to use fixtures.
15However we at BigBinary think that using fixtures creates various sorts of issues.
17To disable using fixtures open `test/test_helper.rb` and comment out the line shown below.
20# fixtures :all

Open .gitignore and execute the following lines.

1rm -rf test/fixtures
2echo "test/fixtures" >> .gitignore
3git add -A
4git commit -m "Ignore text fixtures"

For simple applications we recommend hand rolling the records. It's perfectly alright to do User.create!(name: "Adam Smith", email: "adam@example.com", role: "admin").

We recommend using factory_bot if one wants to use a tool to manage creation of records.

Testing Task model

1touch test/models/task_test.rb

It's a convention to use _test suffix for all test file names. Now add the following code inside task_test.rb.

1require "test_helper"
3class TaskTest < ActiveSupport::TestCase

Now class TaskTest can be called a test case because it inherits from ActiveSupport::TestCase. Now every method that Rails defines for testing is made available to TaskTest class. By requiring test_helper, we have ensured the default configurations for testing are made available. Let's explore some of the commonly used methods provided by Rails to test various scenarios.

Testing truth value

Testing is all about knowing if the expectations matches the actual behavior. We'll use assert method to test if a statement is true.

Let's define a simple method to test the class for an instance of Task.

1def test_instance_of_task
2  task = Task.new
4  # See if object task actually belongs to Task
5  assert_instance_of Task, task

Now let's test this code. In our terminal we will execute the following command.

1bundle exec rails test test/models/task_test.rb
1Finished in 5.887601s, 0.6794 runs/s, 2.2080 assertions/s.
21 runs, 1 assertions, 0 failures, 0 errors, 0 skips

We can see the message that there was 1 run i.e., one test method was executed. The file successfully ran 1 assertion without any failures or errors.

Here, the expectation was that task.is_a?(Task) should return true. This indeed happened and hence assertion passed. Let's observe the behaviour for a case when expectation doesn't match the actual result.

Modify the method as shown below.

1def test_instance_of_task
2  task = Task.new
3  assert_instance_of User, task

Here we are expecting task.is_a?(User) to return true. But this will fail because the object task was instantiated from Task class and not from the User class.

Let's run our test.

1rails test test/models/task_test.rb
2Expected false to be truthy.
4Finished in 5.887601s, 0.6794 runs/s, 2.2080 assertions/s.
51 runs, 1 assertions, 1 failures, 0 errors, 0 skips

We can see that 1 assertion was run, but that resulted in a failure. Let's revert the previous test for this test case to pass.

One way of testing negation or false values is by using refute or assert_not methods. Add another test method as follows.

1def test_not_instance_of_user
2  task = Task.new
3  assert_not_instance_of User, task

Run the test again and this time we will not see any failure.

Testing Equality

Add a new method to task_test.rb file to test title of the task.

1class TaskTest < ActiveSupport::TestCase
2  #previous tests
3  def test_value_of_title_assigned
4    task = Task.new(title: "Title assigned for testing")
6    assert task.title == "Title assigned for testing"
7  end

If you run the rails test test/models/task_test.rb This is because the truth value of argument to assert method is true. We need to use assert_equal instead of assert.

So modify the assertion in the above method to the following.

1  def test_value_of_title_assigned
2    task = Task.new(title: "Title assigned for testing")
4    assert_equal "Title assigned for testing", task.title
5  end

Analogous to assert_not, we can use assert_not_equal in cases where we want to test inequality.

Testing nil values

Add a method to task_test.rb file to test the created_at of the task record before it's saved.

1def test_value_created_at
2  task = Task.new(title: "This is a test task", user: @user)
3  assert_nil task.created_at
5  task.save!
6  assert_not_nil task.created_at

In the above case we are asserting that the value of created_at attribute should be nil when task is instantiated, and it shouldn't be a nil value when the record is saved to database.

Before running the test lets add a setup method in which we will create a user. This will allow us to skip creating a user every time we run a test. Add the following lines of code:

1class TaskTest < ActiveSupport::TestCase
2  def setup
3    @user = User.create(name: 'Sam Smith',
4                         email: 'sam@example.com',
5                         password: 'welcome',
6                         password_confirmation: 'welcome')
7    Task.delete_all
9    @task = Task.new(title: 'This is a test task', user: @user)
10  end
11  # rest of previous tests...

Now, we can update our tests as we don't need Task.new, since we already did so in setup.

Let's verify the results by running rails test test/models/task_test.rb.

Testing errors

ActiveRecord in Rails provides a find method that loads the record in memory of the passed id. If no such record exists, it raises a ActiveRecord::RecordNoFound error.

Let's test how this behaviour can be tested. Add the following method to the task_test.rb file.

1  def test_error_raised
2    assert_raises ActiveRecord::RecordNotFound do
3      Task.find(SecureRandom.uuid)
4    end
5  end

assert_raises is a method that takes the names of error classes and a block. It tests whether the block, when executed raises the error that was passed in the argument. So in our example, we are testing if using Task.find with a random unique id raises a ActiveRecord::RecordNotFound error.

Needless to say, the results can be tested by running rails test test/models/task_test.rb.

Testing expressions

Let's test if creating a task has actually increased the number of records in the database. Add the following code to the test file.

1  def test_count_of_number_of_tasks
2    assert_difference ['Task.count'] do
3      Task.create!(title: 'Creating a task through test', user: @user)
4    end
5  end

Let's run rails test test/models/task_test.rb. We'll notice that all the tests ran without any error.

The above code tests that when the block is executed, the result of Task.count changes by 1. Let's try adding another line of code to the above method. Modify this test and make it look as follows.

1  def test_count_of_number_of_tasks
2    assert_difference ['Task.count'] do
3      Task.create!(title: 'Creating a task through test', user: @user)
4      Task.create!(title: 'Creating another task through test', user: @user)
5    end
6  end

Now run the test and observe the result.

1rails test test/models/task_test.rb
1"Task.count" didn't change by 1.
2Expected: 1
3  Actual: 2

In the above case the block inside assert_difference has actually created two task records, but we're asserting that it had created only one. So how do we test this? Fortunately for us assert_difference helps us pass the count to evaluate the difference in the results of the expression. Modify the method as follows and run the tests.

1def test_count_of_number_of_tasks
2  assert_difference ['Task.count'], 2 do
3    Task.create!(title: 'Creating a task through test', user: @user)
4    Task.create!(title: 'Creating another task through test', user: @user)
5  end
1Finished in 0.366018s, 79.2311 runs/s, 122.9448 assertions/s.
27 runs, 8 assertions, 0 failures, 0 errors, 0 skips

The above code will not result in test failure as we are asserting that Task.count has changed by 2 when the block was executed.

In order to test that an expression has not changed, we can use assert_no_difference method.

Rails provides a larger array of methods for testing and we haven't covered them all in this chapter. The idea was to walk through how testing can be done and describe some of the methods that you are most likely to use in the testing.

Testing validations

Previously, we had added some validations to our Task model. Now, we can write tests to check those validations:

1def test_task_should_not_be_valid_without_title
2  @task.title = ''
3  assert @task.invalid?

Above, we are asserting that the task should be invalid without title.

Testing for race conditions

Let's add a test case to assert that the slug attribute is the parameterized version of a tasks title:

1def test_task_slug_is_parameterized_title
2  title = @task.title
3  @task.save!
4  assert_equal title.parameterize, @task.slug

Now let's check if slug is generated as title slug appended with an incremental count

1def test_incremental_slug_generation_for_tasks_with_same_title
2  first_task = Task.create!(title: 'test task', user: @user)
3  second_task = Task.create!(title: 'test task', user: @user)
5  assert_equal 'test-task', first_task.slug
6  assert_equal 'test-task-2', second_task.slug

Let's also test if an error is raised when a duplicate slug is being stored in task table:

1def test_error_raised_for_duplicate_slug
2  test_task = Task.create!(title: 'test task', user: @user)
3  another_test_task = Task.create!(title: 'anoter test task', user: @user)
5  test_task_tile = test_task.title
6  assert_raises ActiveRecord::RecordInvalid do
7    another_test_task.update!(slug: test_task_tile.parameterize)
8  end
10  assert_match t('task.slug.immutable'),
11                another_test_task.errors.full_messages.to_sentence

According to our slug implementation logic, slug should be immutable. Let's test to make sure, that is the case:

1def test_updating_title_does_not_update_slug
2  @task.save!
3  task_slug = @task.slug
5  updated_task_title = 'updated task tile'
6  @task.update!(title: updated_task_title)
8  assert_equal updated_task_title, @task.title
10  assert_equal task_slug, @task.slug

Finally we have to also check if after deleting task the slug can be reused for a new task with previous tasks title:

1def test_slug_to_be_reused_after_getting_deleted
2  first_task = Task.create!(title: 'test task', user: @user)
3  second_task = Task.create!(title: 'test task', user: @user)
5  second_task_slug = second_task.slug
6  second_task.destroy
7  new_task_with_same_title = Task.create!(title: 'test task', user: @user)
9  assert_equal second_task_slug, new_task_with_same_title.slug

Let's run the tests:

1bundle exec rails test test/models/task_test.rb
1Finished in 0.277572s, 50.4374 runs/s, 72.0534 assertions/s.
214 runs, 20 assertions, 0 failures, 0 errors, 0 skips

Yay! The tests have passed.

Things to pay attention to

Use bang method when possible

Let's look at setup method of a test.

1def setup
2  @user = User.create(name: "Sam Smith", email: "sam@example.com",
3                      password: 'welcome')

Let's say that later we added a validation and the user record is not created in the above statement. Now some test will fail for a different reason and we will have to spend time debugging it. It will be much better if user is not created above then an exception is raised. In this case the failure will happen at the right spot. Using bang method will ensure that if user record is not created then user fails fast.

1def setup
2  @user = User.create!(name: "Sam Smith", email: "sam@example.com",
3                       password: 'welcome')

The setup method

Every public method written in a test file that starts with test_ is a test case. As our application grows, we'll have a lot of test cases and these test cases may require some data to be loaded as part of getting things ready for the testing. To load any such configuration, or to carry out any operation that is common to all the tests in the class, we make use of setup method.

So this is how the setup method should look like in our TaskTest class:

1class TaskTest < ActiveSupport::TestCase
2  def setup
3    @user = User.create!(name: "Sam Smith", email: "sam@example.com",
4     password: 'welcome')
5    Task.delete_all
7    @task = Task.new(title: 'This is a test task', user: @user)
8  end
9  # rest of the previous code...

All the records in tasks table are deleted before any test case is executed.

Writing tests in blocks instead of methods

In order to have a better readability, we can modify our tests as follows.

1  test "error raised" do
2    assert_raises ActiveRecord::RecordNotFound do
3      Task.find(SecureRandom.uuid)
4    end
5  end

This gives the exact behaviour to that of the method test_error_raised that we had written in 16.3.4.

Executed a single test

Every time we run rails test test/models/task_test.rb, we notice that all the tests in the file are run. Now go to task_test.rb, and choose a test of your choice. Let's say you selected the first test that we added test_instance_of_task. Note down the line number in file where this test is starting. Let's assume that line number is 10. Now run the following command in the terminal.

1bundle exec rails test test/models/task_test.rb:10

Voila! We see that only one test is run, which is test_instance_of_task. That's because we have appended :10 in the end of the command to run the test. It means, we are asking Rails to run only the test whose code is present in line number 10. Observe the behaviour by making changes to this number while running the test.

Read more about assertions: https://docs.ruby-lang.org/en/2.1.0/MiniTest/Assertions.html

1git add -A
2git commit -m "Add unit tests"