Last time we have learned about general testing concepts and have talked about unit testing in Elixir. Now let’s go further and learn how to add tests to Phoenix project. We will learn how to write tests for Models and Controllers.
This article is the part of the testing in Elixir series:
- Part 1: Introduction to testing
- Part 2: Testing Models and Controllers
- Part 3: Testing Channels
- Part 4: User Interface testing
- Part 5: Property-based testing
During the last months, we were building the “Prater” chat application. Thus we have an existing functionality we can cover with tests now.
Testing Models
Let’s start with the Models (aka Schemas). Models are close to the unit tests. We can even refer to them as unit tests. Even though they are covering interaction with a DataBase, the scope of coverage is pretty narrow.
Let’s get Prater.Auth.User
model as an example and cover it with tests.
We have no test file for that model yet. So the first thing would be to create a test/prater/auth/user_test.exs
file with the empty test suite.
defmodule Prater.Auth.UserTest do
use Prater.DataCase, async: true
end
If you are familiar with the ExUnit structure for test suite we have covered last time.
You can notice that we are not using use ExUnit.Case
. Instead, we are using use Prater.DataCase
here defined in test/support/data_case.ex
.
That module extends testing functionality in order to support test which requires access to data layer (DataBase).
We have also talked about async: true
option in the last article and find out that is a cool option which can increase the speed of our test suites.
Note about an async option
But there is a pitfall when we are testing databases which we need to know.
By default, Phoenix uses the Ecto.Adapters.SQL.Sandbox
module.
Which basically wraps every test within a transaction in order to rollback it after a test is finished.
That helps to keep the test database clean.
Phoenix guide does not recommend us to use async: true
option if we are going to interact with the database:
Note: We should not tag any schema case that interacts with a database as :async. This may cause erratic test results and possibly even deadlocks.
Although, Ecto.Adapters.SQL.Sandbox
also contains a note about that:
While both PostgreSQL and MySQL support SQL Sandbox, only PostgreSQL supports concurrent tests while running the SQL Sandbox. Therefore, do not run concurrent tests with MySQL as you may run into deadlocks due to its transaction implementation.
We are using PostgreSQL for that project. So I am going to enable that option at my own peril.
OK. End of note. Let’s move on and write some tests.
The Prater.Auth.User
module contains the registration_changeset/2
function.
def registration_changeset(%User{} = user, attrs) do
user
|> changeset(attrs)
|> validate_confirmation(:password)
|> cast(attrs, [:password], [])
|> validate_length(:password, min: 6, max: 128)
|> encrypt_password()
end
which depends on the Prater.Auth.User.changeset/2
function:
def changeset(%User{} = user, attrs) do
user
|> cast(attrs, [:email, :username])
|> validate_required([:email, :username])
|> validate_length(:username, min: 3, max: 30)
|> unique_constraint(:email)
|> unique_constraint(:username)
end
It’s time to write our first test.
alias Prater.Auth.User
describe "User.registration_changeset/2" do
@invalid_attrs %{}
test "changeset with invalid attributes" do
changeset = User.registration_changeset(%User{}, @invalid_attrs)
refute changeset.valid?
end
end
Here we are checking that the registration changeset should be invalid if we are using an empty map as attributes. Now let’s check the opposite. We want to check when it is also valid. So we are using all required and valid attributes.
@valid_attrs %{
email: "user@example.com",
username: "user",
password: "password",
password_confirmation: "password"
}
test "changeset with valid attributes" do
changeset = User.registration_changeset(%User{}, @valid_attrs)
assert changeset.valid?
end
Now we know when our changeset would be valid and then it would not be valid. But that does not provide us a lot of information. What if we want to check the validation error of the changeset. In that case, we can make a single attribute invalid and check the error message of that field.
test "changest with username less than 3 characters" do
changeset = User.registration_changeset(%User{}, %{@valid_attrs | username: "uu"})
assert %{username: ["should be at least 3 character(s)"]} = errors_on(changeset)
end
The same for the upper border of the username
field.
test "changest with username more than 30 characters" do
attrs = %{@valid_attrs | username: String.duplicate("u", 31)}
changeset = User.registration_changeset(%User{}, attrs)
assert %{username: ["should be at most 30 character(s)"]} = errors_on(changeset)
end
And we can cover the rest of the fields in the same way.
Now let’s move upper one level and test Prater.Auth
module. The module contains the following function:
def register(params) do
User.registration_changeset(%User{}, params) |> Repo.insert()
end
The function uses the changeset we just have tested and also creates a database record. By testing that function we are kinda going to test these two pieces together.
Let’s create a new file test/prater/auth/auth_test.exs
with the following content:
defmodule Prater.AuthTest do
use Prater.DataCase, async: true
alias Prater.Repo
alias Prater.Auth
alias Prater.Auth.User
describe "Auth.register/1" do
@valid_attrs %{
email: "user@example.com",
username: "user",
password: "password",
password_confirmation: "password"
}
test "changeset with non-unique email" do
Auth.register(@valid_attrs)
{:error, changeset} = Auth.register(@valid_attrs)
assert %{email: ["has already been taken"]} = errors_on(changeset)
end
end
end
In that test, we are registering a new user. Then we are trying to register another user with the same attributes.
That should not be possible because the user with the same email already exists.
And yes, we can see that the changeset contains a validation error email: ["has already been taken"]
.
Next, let’s check if that function actually creates a record in the database.
test "all attributes are saved properly" do
{:ok, user} = Auth.register(@valid_attrs)
user = Repo.get!(User, user.id)
assert is_nil(user.password)
assert Comeonin.Bcrypt.checkpw(@valid_attrs[:password], user.encrypted_password)
assert user.email == @valid_attrs[:email]
assert user.username == @valid_attrs[:username]
end
We are registering a user. Then fetch him from the database and check if the record contains all required attributes. We even check if the password properly encrypted.
As I’ve already mentioned above, Model Testing is pretty similar to Unit Testing. By using the same technique you can cover most of the Models’ functionality.
Let’s move on and write some tests for controllers.
Testing controllers
Controller test we can be attributed to integration layer. Because they are covering several pieces of functionality at once.
At first, let’s test how our registration functionality works.
We will start from the test/prater_web/controllers/registration_controller_test.exs
file.
defmodule PraterWeb.RegistrationControllerTest do
use PraterWeb.ConnCase
end
In the same way, as we were using Prater.DataCase
for testing Models.
The PraterWeb.ConnCase
extends our test suites with the functionality to use connection struct.
Let’s start with the simplest test. We have a “Sign Up” page. We would like to request that page and check if it contains the “Sign Up” label.
test "GET /registrations/new", %{conn: conn} do
conn = get conn, "/registrations/new"
assert html_response(conn, 200) =~ "Sign Up"
end
as a second argument of the test
macro, we are grabbing the connection struct in order to use it.
Then we are sending a GET request to the /registrations/new
path. Finally, we are checking if a successful response contains the text we want to see. And it does.
Now let’s move on to another example. Let’s check if our create
action works.
@params %{
email: "user@example.com",
username: "username",
password: "password",
password_confirmation: "password"
}
test "POST /registrations", %{conn: conn} do
conn = post conn, "/registrations", [registration: @params]
assert redirected_to(conn) == "/"
assert get_flash(conn, :info) == "You have successfully signed up!"
assert get_last_user().email == "user@example.com"
end
defp get_last_user do
alias Prater.Auth.User
import Ecto.Query
Prater.Repo.one(from u in User, order_by: [desc: u.id], limit: 1)
end
At the beginning we define params we will use to create a user.
Then we perform a POST request to the /registrations
path and passing those params.
As a result, we are expecting the page to be redirected to root path,
and have a flash message properly set up.
In the last step, we are fetching a last user from the database and check if that the user we were creating.
The get_last_user
function is just to make our test shorter.
We have covered “Happy path” here, you can test the endpoint with invalid attributes in a similar way.
Now let’s move on to RoomController
where we can cover other examples.
Create the test/prater_web/controllers/room_controller_test.exs
file:
defmodule PraterWeb.RoomControllerTest do
use PraterWeb.ConnCase
test "GET /", %{conn: conn} do
conn = get conn, "/"
assert html_response(conn, 200) =~ "Welcome to Prater"
end
end
The test for index
action is pretty similar to one we already have.
Let’s write a test for RoomController.show
action.
Unlike index
action we need to have a room in the database and our user have to be authenticated.
Let’s look at the first version of the test and then walk through step by step.
test "GET /room/:id", %{conn: conn} do
user = create_user()
{:ok, room} = Prater.Conversation.create_room(
user,
%{name: "Lobby", description: "The general chat room"}
)
conn =
conn
|> Plug.Test.init_test_session(current_user_id: user.id)
|> get("/rooms/#{room.id}")
assert html_response(conn, 200) =~ "<h2>Lobby</h2>"
assert html_response(conn, 200) =~ "<div>The general chat room</div>"
end
@default_email "user@example.com"
@default_password "password"
defp create_user(email \\ @default_email) do
[username, _] = String.split(email, "@")
params = %{
email: email,
username: username,
password: @default_password,
password_confirmation: @default_password
}
{:ok, user} = Prater.Auth.register(params)
user
end
First, as I’ve already mentioned we need a user, so we create it. Using create_user
function as a helper.
Then we are creating a room. The room should belong to a user, and we assign the user to the room.
Next, we need to authenticate the user somehow.
On one hand, we can perform a POST request to sign in our user and then perform another request to access the room’s page. That is a viable solution. But we probably would need to repeat that approach for other tests, which would make +1 request for every test. That would slow down every test by the time required to perform that sign in request. At the end that would pile up and make all the tests slower.
On another hand, we can make a shortcut and pretend our user already authenticated.
If we look at the implementation of our SetCurrentUser
plug, we can see that in order to be authenticated all we need is to have a current_user_id
in the session struct:
user_id = Plug.Conn.get_session(conn, :current_user_id)
Let’s update the session struct then.
The naive solution could be just to use put_session/2
function. Then struggle with the
(ArgumentError) session not fetched, call fetch_session/2
error. The thing is, that we have no session struct that that point because we didn’t perform any requests yet. So one of the solutions would be to use the Plug.Test.init_test_session/2 function with the attributes we need.
And finally to perform a GET request to /room/:id
URL.
As a result, we are expecting to see the room’s title and description on the page.
At this moment our test looks quite cluttered. Let’s see how we can refactor it to look nicer.
At first, we can extract authentication logic into setup
block.
setup do
user = create_user()
conn = Plug.Test.init_test_session(build_conn(), current_user_id: user.id)
{:ok, conn: conn, user: user}
end
Here we are creating the user, and init session with his ID. We are using build_conn/0
function from the Prater.ConnCase
module.
In the end, we return the extended connection and the user out of setup
.
We still need to know the user in order to create the room, so we are fetching it from the test
arguments.
test "GET /room/:id", %{conn: conn, user: user} do
{:ok, room} = Prater.Conversation.create_room(
user,
%{name: "Lobby", description: "The general chat room"}
)
conn = get(conn, "/rooms/#{room.id}")
assert html_response(conn, 200) =~ "<h2>Lobby</h2>"
assert html_response(conn, 200) =~ "<div>The general chat room</div>"
end
Now the test looks cleaner.
Now, by using setup
in the way we use it, we authenticate the user for every request even if we don’t need it as for index
action.
Can we improve that? Yes, sure. We can use tags.
First, let’s add a tag in front of our test and set the value of user’s email we want to use:
@tag sign_in_as: "user@example.com"
test "GET /room/:id", %{conn: conn, user: user} do
Then in the setup
block, we check for that tag, and if it exists, we would create and authenticate the user and do nothing otherwise.
setup %{conn: conn} = config do
if email = config[:sign_in_as] do
user = create_user(email)
conn = Plug.Test.init_test_session(build_conn(), current_user_id: user.id)
{:ok, conn: conn, user: user}
else
:ok
end
end
One more thing we can notice is that we would probably need to create a user in some other test suites. It would be nice to avoid duplication.
We can extract create_user/1
function into helper module.
Create the test/support/auth_helpers.ex
file and move the function there.
defmodule Prater.AuthHelpers do
@default_email "user@example.com"
@default_password "password"
def create_user(email \\ @default_email) do
[username, _] = String.split(email, "@")
params = %{
email: email,
username: username,
password: @default_password,
password_confirmation: @default_password
}
{:ok, user} = Prater.Auth.register(params)
user
end
end
Now to make it work we need to import that module in the ConnCase
. Open test/support/conn_case.ex
and update the quote
block:
using do
quote do
# ...
import Prater.AuthHelpers
# ...
end
end
Now check your tests again. They should be green.
Wrapping up
Today we have covered how to write tests for two main pillars of MVC: Models and Controllers. Using those two types of tests we can cover a lot of functionality and be calm during refactoring sessions. Because we don’t need to check every piece of functionality manually anymore.
That’s all for now. But there is still some type of tests we are going to cover.
All the changes we did today, you can find in the GitHub repository.
See you next time.