Learn Ruby on Rails Book

Deep diving into unit testing

In previous chapters, we had setup a base for writing unit tests and also learnt the basics of writing unit tests with User model. Now let's deep dive on writing unit tests for Task model.

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"
2
3class TaskTest < ActiveSupport::TestCase
4end

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 truthy value

Testing is all about knowing if the expectations match 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
3
4  # See if object task actually belongs to Task
5  assert_instance_of Task, task
6end

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 behavior 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
4end

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
1TaskTest#test_instance_of_task
2Expected false to be truthy.
3
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
4end

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")
5
6    assert task.title == "Title assigned for testing"
7  end
8end

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")
3
4    assert_equal "Title assigned for testing", task.title
5  end

Similar 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 to test the created_at value 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
4
5  task.save!
6  assert_not_nil task.created_at
7end

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.

But these test cases, testing instances, are pretty useless and often not required. Why do we need to test something that rails team has already tested and verified? For example, we know for sure that Task.new won't generate an instance of an entirely different User class.

Let us start writing some bit more serious ones that we can use to verify our own logic.

The setup method

As discussed in previous chapters, rails will run the setup method automatically before every test case. Therefore, we can use this method to run common tasks like setting up demo data, loading configuration data, etc.

In this case, we can use it to avoid hardcoding a task in every test case. We don't need our previous tests anymore. Fully replace the contents of test/models/task_test.rb with the following code:

1require 'test_helper'
2
3class TaskTest < ActiveSupport::TestCase
4  def setup
5    @task = create(:task)
6  end
7
8  # ... new tests will be added here ...
9end

Now onwards, we don't need to create a default test task unless required otherwise. We can always make use of the @task instance variable to access default task.

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 behavior 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_task_count_increases_on_saving
2    assert_difference ['Task.count'] do
3      create(:task)
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_task_count_increases_on_saving
2    assert_difference ['Task.count'] do
3      create(:task)
4      create(:task)
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_task_count_increases_on_saving
2  assert_difference ['Task.count'], 2 do
3    create(:task)
4    create(:task)
5  end
6end
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.

Let us revert back our previous test to normal. We don't need to generate two tasks to verify the count is increasing right? Replace the test case with the following code:

1def test_task_count_increases_on_saving
2  assert_difference ['Task.count'], 1 do
3    create(:task)
4  end
5end

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?
4end

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
5end

Now let's test whether tasks with duplicate title generate slugs incrementally.

1def test_incremental_slug_generation_for_tasks_with_same_title
2  duplicate_task = create(:task, title: @task.title)
3  assert_equal "#{@task.slug}-2", duplicate_task.slug
4end

As you can see, we can dynamically pass field values to factory-bot. Here we are passing a value for title field. The generated task will have the specified title, and not any fake data.

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  new_task = create(:task)
3  assert_raises ActiveRecord::RecordInvalid do
4    new_task.update!(slug: @task.slug)
5  end
6
7  error_msg = new_task.errors.full_messages.to_sentence
8  assert_match t('task.slug.immutable'), error_msg
9end

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  assert_no_changes -> { @task.reload.slug } do
3    updated_task_title = 'updated task tile'
4    @task.update!(title: updated_task_title)
5    assert_equal updated_task_title, @task.title
6  end
7end

The assert_no_changes method evaluates the expression in the lambda function before and after executing the code block it wraps. If their values are different, the test fails.

In this case, we will be checking the task's slug before and after updating its title. This test ensures that the slug remains the same even if the title is changed.

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_index_to_be_reused_after_getting_deleted
2  duplicate_task = create(:task, title: @task.title)
3  assert_equal "#{@task.slug}-2", duplicate_task.slug
4
5  duplicate_task.destroy
6  duplicate_task_with_same_title = create(:task, title: @task.title)
7
8  assert_equal "#{@task.slug}-2", duplicate_task_with_same_title.slug
9end

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 assume for a while we are using plain old hardcode way to generate test data and not use any factories to do the same.

The difference between bang method (create!) and normal method (create) is that the normal method ignores errors. For example, consider the following code:

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

Here we are using a non-bang method (create) to create a user. Let's say that later we added a validation and the user record is not created in the above statement. Then, this method call won't throw any error. Instead it will set the value of @user as nil.

Now some test will fail for a different reason and we will have to spend time debugging it.

Due to this reason, we prefer the fail-fast approach while writing tests. We want the test to break immediately when an error occurs.

Using bang method (create!) will ensure that if user record is not created then user fails fast. i.e. This is the preferred approach:

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

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 behavior 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 behavior 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"
⌘K
    to navigateEnterto select Escto close
    Previous
    Next