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:
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:
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:
For the purpose of testing, we will be using *_path
helpers to simulate
requests to various controller actions.
Testing Home Controller
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
:
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:
Testing Tasks controller
Let's write tests for our TasksController
. Create a tasks_controller_test.rb
file, if it doesn't exist:
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
:
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
:
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 sorted
list of ids of both pending and completed tasks received via response for a user
is same as the sorted list of ids within DB.
Comparing with the sorted list of ids in database is a strong indicator of a
reliable test case. Special emphasis on the term sorted
, since with sorting we
do not have to worry about the default ordering for select
queries. 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
, to make sure
that our validations as well as other actions are working as intended:
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:
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
:
Add the highlighted line to en.yml
file to add the translation for
task.not_found
:
Now you can try running all the test cases and they should pass:
Let's write tests for our Comments Controller. Create a
comments_controller_test.rb
file, if it doesn't exist:
Now add the following code inside comments_controller_test.rb
:
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 its 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:
Testing Users controller
Let's write tests for our Users Controller. Create a users_controller_test.rb
file, if it doesn't exist:
Now add the following code inside users_controller_test.rb:
Here, above tests validate user signup with credentials.
Now you can try running all the test cases and they should pass:
Testing Sessions controller
Let's write tests for our Sessions Controller. Create a
sessions_controller_test.rb
file, if it doesn't exist:
Now add the following code inside sessions_controller_test.rb:
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:
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 this:
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: