Search
⌘K
    to navigateEnterto select Escto close

    Controller tests

    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.

    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    'X-Auth-Token' => user.authentication_token,
    6    'X-Auth-Email' => user.email
    7  }.merge(options)
    8end

    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"], t("not_found", entity: "Task")
    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 test_should_list_all_users
    5    get users_path
    6    assert_response :success
    7    response_json = response.parsed_body
    8    assert_equal response_json['users'].length, User.count
    9  end
    10
    11  def test_should_signup_user_with_valid_credentials
    12    post users_path, params: { user: { name: 'Sam Smith',
    13                                      email: 'sam@example.com',
    14                                      password: 'welcome',
    15                                      password_confirmation: 'welcome' } }
    16    assert_response :success
    17    response_json = response.parsed_body
    18    assert_equal response_json['notice'], t('successfully_created', entity: 'User')
    19  end
    20
    21  def test_shouldnt_signup_user_with_invalid_credentials
    22    post users_path, params: { user: { name: 'Sam Smith',
    23                                      email: 'sam@example.com',
    24                                      password: 'welcome',
    25                                      password_confirmation: 'not matching confirmation' } }
    26
    27    assert_response :unprocessable_entity
    28    assert_equal response.parsed_body['error'], "Password confirmation doesn't match Password"
    29  end
    30end

    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 sessions_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    non_existent_email = 'this_email_does_not_exist_in_db@example.email'
    16    post sessions_path, params: { login: { email: non_existent_email, password: 'welcome' } }, as: :json
    17
    18    assert_response :unauthorized
    19    assert_equal response.parsed_body["error"], t("session.incorrect_credentials")
    20  end
    21end

    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

    Now, let's commit the changes:

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