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"