Back
Chapters

Controller tests

Search icon
Search Book
⌘K

Writing functional controller tests

In previous chapters, we have seen the testing setup and testing of models. Similarly, we should test controllers. In Rails, testing the various actions of a controller is a form of writing functional tests.

Controllers handle the incoming web requests to your application and eventually respond with data or a rendered view. You should test how your actions handle the requests and what kind of responses they send back.

The following are some outlines for the kind of controller test cases that you can write:

  • Was the web request successful?
  • Was the user successfully authenticated?
  • Was the user redirected to the right page?
  • Was the appropriate message displayed to the user in the view?
  • Was the correct information displayed in the response?

HTTP helper methods

Rails provides us with helper methods to mock an HTTP request.

Those HTTP helper methods can accept up to 6 arguments:

  • The URI which maps to the controller action you are testing. This can be in the form of a string or a route helper (e.g. tasks_url).

  • params: option with a hash of request parameters to pass into the action.

  • headers: for setting the headers that will be passed with the request.

  • env: for customizing the request environment as needed.

  • xhr: whether the request is Ajax request or not. Can be set to true for marking the request as Ajax.

  • as: for encoding the request with different content type.

To test controller actions, we will make use of various helper methods to simulate a request on the controller actions. Names of these helper methods are the same as that of HTTP verbs such as get, post, put, patch and delete.

Named route helpers

Instead of passing raw urls to HTTP helper methods, we can pass named routes which are auto generated by Rails for each route. Named routes can either be suffixed with _url or _path. For example, in our application, the /tasks route can be referred to using either tasks_path or tasks_url methods.

*_url helpers return an absolute path, including protocol and host name. These should mainly be used when providing links for external use, like say email links, RSS, and any other third-party apps or services.

*_path helpers on the other hand only return a path relative to site root. These are better for internally referring to a page in the application since any domain changes will not affect the route. Moreover, domain names are redundant and add to the page size.

You can also test these route helpers out in the Rails console. Let's see what tasks_url helper will return. To do so, run the following command:

1app.tasks_url
2=> "http://www.example.com/tasks"

As you can see, tasks_url helper returned the absolute path along with the protocol and host name.

Now, let's see what tasks_path helper will return. Run the following command:

1app.tasks_path
2=> "/tasks"

You can see that tasks_path helper only returned a relative path.

Sometimes figuring out these path helper names can be tricky. In such cases we can run the following statement from our Rails console to view all such helper names, based on our current routes:

1Rails.application.routes.named_routes.helper_names

For the purpose of testing, we will be using *_path helpers to simulate requests to various controller actions.

Testing Home Controller

1touch test/controllers/home_controller_test.rb

It's a convention to use the _controller_test suffix for all the controller test files.

The test file name will be the snake_case version of the corresponding class name.

Now add the following code inside home_controller_test.rb:

1require 'test_helper'
2
3class HomeControllerTest < ActionDispatch::IntegrationTest
4  def test_should_get_successfully_from_root_url
5    get root_path
6    assert_response :success
7  end
8end

The HomeControllerTest class has inherited from ActionDispatch::IntegrationTest. Thus all methods, which helps with integration testing, are now available in the HomeControllerTest class too.

By requiring test_helper at the top, we have ensured that the default configurations for testing are made available to this particular test.

In the test_should_get_successfully_from_root_url, Rails simulates a request on the action called index from Home controller. It's making sure the request was successful and the status code returned was :ok.

Now let's run this test and it should pass. In our terminal, we will execute the following command:

1bundle exec rails test -v test/controllers/home_controller_test.rb

Testing Tasks controller

Let's write tests for our TasksController. Create a tasks_controller_test.rb file, if it doesn't exist:

1touch test/controllers/tasks_controller_test.rb

We will be creating two users: a @creator and an @assignee. The @creator always create the tasks and assigns them either to themselves or to the @assignee. We need these two users to test our authorization logic.

Now add the following code inside tasks_controller_test.rb:

1require 'test_helper'
2
3class TasksControllerTest < ActionDispatch::IntegrationTest
4  def setup
5    @creator = create(:user)
6    @assignee = create(:user)
7    @task = create(:task, assigned_user: @assignee, task_owner: @creator)
8    @creator_headers = headers(@creator)
9    @assignee_headers = headers(@assignee)
10  end
11
12  def test_should_list_all_tasks_for_valid_user
13    get tasks_path, headers: @creator_headers
14    assert_response :success
15    response_body = response.parsed_body
16    all_tasks = response_body['tasks']
17
18    pending_tasks_count = Task.where(progress: 'pending').count
19    completed_tasks_count = Task.where(progress: 'completed').count
20
21    assert_equal all_tasks['pending'].length, pending_tasks_count
22    assert_equal all_tasks['completed'].length, completed_tasks_count
23  end
24
25  def test_should_create_valid_task
26    post tasks_path,
27      params: { task: { title: 'Learn Ruby', task_owner_id: @creator.id, assigned_user_id: @assignee.id } },
28      headers: @creator_headers
29    assert_response :success
30    response_json = response.parsed_body
31    assert_equal response_json['notice'], t('successfully_created', entity: 'Task')
32  end
33end

In response.parsed_body the parsed_body method parses the response body using the JSON encoder.

Remember when we run the application, our axios.js file adds some default headers for each of the HTTP requests made from client.

These headers primarily help with authorizing a user in the backend as part of each request.

Similarly, for the test cases, we can set such headers using the headers function which we have defined in our test_helper.

Since we have enforced authentication and authorization for TasksController, we need to pass the user's email and authentication token in the request headers.

So, we have created objects @creator_headers and @assignee_headers for our users using the headers method.

Let's define the headers method in our test/test_helper.rb. Add the following method into test_helper.rb:

1#previous code
2
3def headers(user, options = {})
4  {
5    Accept: "application/json",
6    "Content_Type" => "application/json",
7    'X-Auth-Token' => user.authentication_token,
8    'X-Auth-Email' => user.email
9  }.merge(options)
10end

First test is testing index action which should list all tasks for valid user. test_should_list_all_tasks_for_valid_user test case asserts that the count of both pending and completed tasks received via response for a user is same as the count of items within DB.

Comparing with the count of items in database is a strong indicator of a reliable test case. It accurately makes sure that we are testing the right entity.

test_should_create_valid_task is testing the create action of TasksController. We are testing the response message string using the translation helper method t().

Hope you now understand the benefit of adding strings to en.yml. We don't need to hardcode the strings anymore.

Let's add some more test cases into tasks_controller_test.rb,in order to make sure that our validations as well as other actions are working as intended:

1def test_shouldnt_create_task_without_title
2  post tasks_path, params: { task: { title: "", task_owner_id: @creator.id, assigned_user_id: @assignee.id } },
3    headers: @creator_headers
4  assert_response :unprocessable_entity
5  response_json = response.parsed_body
6  assert_equal response_json['error'], "Title can't be blank"
7end
8
9def test_creator_can_update_any_task_fields
10  new_title = "#{@task.title}-(updated)"
11  task_params = { task: { title: new_title, assigned_user_id: 1 } }
12
13  put task_path(@task.slug), params: task_params, headers: @creator_headers
14  assert_response :success
15  @task.reload
16  assert_equal @task.title, new_title
17  assert_equal @task.assigned_user_id, 1
18end
19
20def test_should_destroy_task
21  assert_difference "Task.count", -1 do
22    delete task_path(@task.slug), headers: @creator_headers
23  end
24
25  assert_response :ok
26end

There are a couple of important points to note here. We have used two different route helper methods, namely tasks_path and task_path. The difference between these two is that the plural route helper resolves into a URL which does not contain an identifier. For example, tasks_path will resolve into /tasks.

Whereas, a singular route helper will resolve into a URL with an identifier. For example, task_path will resolve into /tasks/:slug.

Another important point to note here is that we have to pass the value of identifier as an argument to the singular route helpers. In this case, we are passing the @task.slug as an argument to tasks_path. The :slug part is replaced with the argument that we pass in.

Let us add test cases to make sure that all the authorizations for a task are working as intended:

1def test_assignee_shouldnt_destroy_task
2  delete task_path(@task.slug), headers: @assignee_headers
3  assert_response :forbidden
4  response_json = response.parsed_body
5  assert_equal response_json["error"], t("authorization.denied")
6end
7
8def test_assignee_shouldnt_update_restricted_task_fields
9  new_title = "#{@task.title}-(updated)"
10  task_params = { task: { title: new_title, assigned_user_id: 1 } }
11
12  assert_no_changes -> { @task.reload.title } do
13    put task_path(@task.slug), params: task_params, headers: @assignee_headers
14    assert_response :forbidden
15  end
16end
17
18def test_assignee_can_change_status_and_progress_of_task
19  task_params = { task: { status: "starred", progress: "completed" } }
20
21  put task_path(@task.slug), params: task_params, headers: @assignee_headers
22  assert_response :success
23  @task.reload
24  assert @task.starred?
25  assert @task.completed?
26end
27
28def test_creator_can_change_status_and_progress_of_task
29  task_params = { task: { status: "starred", progress: "completed" } }
30
31  put task_path(@task.slug), params: task_params, headers: @creator_headers
32  assert_response :success
33  @task.reload
34  assert @task.starred?
35  assert @task.completed?
36end

Let's also test if the correct error is rendered when a task is fetched with invalid task slug.

Add the following test case to TasksControllerTest:

1def test_not_found_error_rendered_for_invalid_task_slug
2  invalid_slug = "invalid-slug"
3
4  get task_path(invalid_slug), headers: @creator_headers
5  assert_response :not_found
6  assert_equal response.parsed_body["error"], t("task.not_found")
7end

Now you can try running all the test cases and they should pass:

1bundle exec rails test -v test/controllers/tasks_controller_test.rb

Testing Comments controller

Let's write tests for our Comments Controller. Create a comments_controller_test.rb file, if it doesn't exist:

1touch test/controllers/comments_controller_test.rb

Now add the following code inside comments_controller_test.rb:

1require 'test_helper'
2
3class CommentsControllerTest < ActionDispatch::IntegrationTest
4  def setup
5    @task = create(:task)
6    @headers = headers(@task.assigned_user)
7  end
8
9  def test_should_create_comment_for_valid_request
10    content = 'Wow!'
11    post comments_path, params: { comment: { content: content, task_id: @task.id } }, headers: @headers
12    assert_response :success
13    assert_equal @task.comments.last.content, content
14  end
15
16  def test_shouldnt_create_comment_without_content
17    post comments_path, params: { comment: { content: '', task_id: @task.id } }, headers: @headers
18    assert_response :unprocessable_entity
19    response_json = response.parsed_body
20    assert_equal response_json['error'], "Content can't be blank"
21  end
22
23  def test_shouldnt_create_comment_without_task
24    post comments_path, params: { comment: { content: "This is a comment", task_id: "" } }, headers: @headers
25    assert_response :not_found
26    response_json = response.parsed_body
27    assert_equal response_json["error"], "Task not found"
28  end
29end

Here, test_should_create_comment_for_valid_request is testing create action of CommentsController. Test asserts that the newly created comment's content is reflected in the database.

test_shouldnt_create_comment_without_content is a negative test case.

Negative test cases allow us to make sure that the actions fail and send appropriate responses for invalid parameters.

In the above test case, we are making sure that the validation from the comments model is working it's magic.

In test_shouldnt_create_comment_without_task we are testing that no comment is being created in case a valid task doesn't exist and a proper error is being rendered.

Now you can try running all the test cases and they should pass:

1bundle exec rails test -v test/controllers/comments_controller_test.rb

Testing Users controller

Let's write tests for our Users Controller. Create a users_controller_test.rb file, if it doesn't exist:

1touch test/controllers/users_controller_test.rb

Now add the following code inside users_controller_test.rb:

1require 'test_helper'
2
3class UsersControllerTest < ActionDispatch::IntegrationTest
4  def setup
5    user = create(:user)
6    @headers = headers(user)
7  end
8
9  def test_should_list_all_users
10    get users_path, headers: @headers
11    assert_response :success
12    response_json = response.parsed_body
13    assert_equal response_json['users'].length, User.count
14  end
15
16  def test_should_signup_user_with_valid_credentials
17    post users_path, params: { user: { name: 'Sam Smith',
18                                      email: 'sam@example.com',
19                                      password: 'welcome',
20                                      password_confirmation: 'welcome' } }, headers: @headers
21    assert_response :success
22    response_json = response.parsed_body
23    assert_equal response_json['notice'], t('successfully_created', entity: 'User')
24  end
25
26  def test_shouldnt_signup_user_with_invalid_credentials
27    post users_path, params: { user: { name: 'Sam Smith',
28                                      email: 'sam@example.com',
29                                      password: 'welcome',
30                                      password_confirmation: 'not matching confirmation' } }, headers: @headers
31
32    assert_response :unprocessable_entity
33    assert_equal response.parsed_body['error'], "Password confirmation doesn't match Password"
34  end
35end

Here, above tests validate user signup with credentials.

Now you can try running all the test cases and they should pass:

1bundle exec rails test -v test/controllers/users_controller_test.rb

Testing Sessions controller

Let's write tests for our Sessions Controller. Create a sessions_controller_test.rb file, if it doesn't exist:

1touch test/controllers/sessions_controller_test.rb

Now add the following code inside sessions_controller_test.rb:

1require 'test_helper'
2
3class SessionsControllerTest < ActionDispatch::IntegrationTest
4  def setup
5    @user = create(:user)
6  end
7
8  def test_should_login_user_with_valid_credentials
9    post session_path, params: { login: { email: @user.email, password: @user.password } }, as: :json
10    assert_response :success
11    assert_equal response.parsed_body["authentication_token"], @user.authentication_token
12  end
13
14  def test_shouldnt_login_user_with_invalid_credentials
15    post session_path, params: { login: { email: @user.email, password: "invalid password" } }, as: :json
16    assert_response :unauthorized
17    assert_equal response_json["error"], t("session.incorrect_credentials")
18  end
19
20  def test_should_respond_with_not_found_error_if_user_is_not_present
21    non_existent_email = "this_email_does_not_exist_in_db@example.email"
22    post session_path, params: { login: { email: non_existent_email, password: "welcome" } }, as: :json
23    assert_response :not_found
24    assert_equal response_json["error"], "User not found"
25  end
26end

Here, above tests validate user login with valid credentials and auth_token. If user tries to enter wrong credentials, then user will get 401 i.e. unauthorized response.

Now you can try running all the test cases and they should pass:

1bundle exec rails test -v test/controllers/sessions_controller_test.rb

Testing updates to Bcrypt password

You needn't add the following test case to "granite". It's purely hypothetical.

So as we had discussed in the "authentication" chapter, we make use of has_secure_password helper from Rails to add useful methods on top of bcrypt gem to take care of password related functionalities.

Let's talk about a hypothetical scenario where we provide the user the ability to update their password from UI. Let's say we don't require any password confirmation when updating with a new password.

So if we want to test this logic, then the first instinct might be to write a controller test case like so:

1def test_should_update_user_password
2  old_password = @user.password
3  new_password = "Updated@password123"
4  put user_path(@user),
5    params: {
6      user: {
7        password: new_password
8      }
9    }, headers: @headers
10  assert_response :success
11  assert_equal t("successfully_updated", name: "User"), response_json["notice"]
12  assert_equal new_password, @user.reload.password
13end

In the first looks this test case should work out of the box. But it will fail!

It will fail because password column doesn't actually exist in the database. So is it just a figment of our imagination? Not quite. It's rather a virtual column stored in the volatile memory. You should by now know that we should always try our level best to test data in the database.

Here even if we reload our object, the password attr_reader wouldn't get updated with the newly decrypted password based on the latest password_digest. That's a security measure taken by module to ensure nobody has unwarranted access to the decrypted password. If anyone can get the decrypted password using a method, then it would defeat the purpose of having a password digest in the first place.

Thus here we need to modify the test case to:

  • Assert that the old and new password digests are NOT same.
  • Or verify that the instance gets properly authenticated with new password: assert @user.reload.authenticate(new_password).

Now, let's commit the changes:

1git add -A
2git commit -m "Added controller tests"