How to build multiple web apps with Elixir thanks to umbrella — Bonus 2: Having our tests working

A (wannabe) guide to learn how to create web apps with Elixir 1.9, Phoenix 1.4, Docker (for development), Git (including submodules) to version each app, PostgresQL, Redis, REST calls and deployment.

Cédric Paumard
9 min readSep 20, 2019

--

This is the second bonus chapter. It takes place after the third chapter. It is addressed mainly to people who follow the main guide. However, you can take it as a standalone: any Phoenix project using guardian and an authentication system should be enough.

In this chapter, we will see how to generate an authenticated connection (we will call it “auth conn” from now) so each controller test can run smoothly. There are multiple methods (such as this one from Simon Ström). Here, we will see how doing it by creating another _case file and using setup_all.

As usual, you can find the modifications in each repositories, under the branch bonus-test_admin.

Note: This guide is a basic approach to create an auth_conn. It focus on teaching you how to use setup and setup_all. There is better ways to do it. You can find one of them in the admin repository, branch bonus-test_admin_v02. I will also write another guide following this one to explain how to change from what you actually have to the second possibility.

Back to basics

Before going inside the file and working around, there are a few commands you need to remember, as it will not be an easy journey. It will not be an easy journey, in the sense where you may have to execute these commands even if this guide don’t tell you to do so.

Making your tests working will mostly, at the end, depends on you. So, here are the tools:

# if you use docker, put this in front of each test commands:
docker-compose exec elixir
# running every tests:
$> mix test
# running only the tests in one app:
$> mix test app/admin/test
# running only tests in one folder:
$> mix test app/admin/test/path/to/folder
# running only the tests in one file:
$> mix test app/admin/test/path/to/file.exs
# running a specific test in one file (where 42 is the first line of the test):
$> mix test app/admin/test/path/to/file.exs:42
# make the test more verbose:
$> mix test --trace
# remove all admin_user in our test database:
$> docker-compose exec postgres psql user role
psql-shell> \c test_database
psql-shell> DELETE FROM table_name;
psql-shell> \q
# cleaning the test database:
$> docker-compose exec postgres psql user role
psql-shell> DROP DATABASE your_test_database;
psql-shell> \q

You will probably have to reset your database some time. But because our Admin app imports our Database app, and because mix is well written, the test commands recreate and populate your database with the migrations.

test: ["ecto.create — quiet", "ecto.migrate", "test"]

Let’s have a look at the state before going further:

$> docker-compose exec elixir mix test

First, you may have some warning, but these shouldn’t create too many problems.

Then, as you can see, some test works great. In facts, theses tests are from the different apps we haven’t used yet.

Tests from database also work perfectly.

Then, come the tests from our Admin app. Here, we can see most of them doesn’t work, and display an unauthenticated message. Which means we need to sens an auth_conn!

auth_case

On purpose to make the tests working, we need to have a conn map with a logged user inside. The way I choose to do it is simple: create a file which generates this conn, and call it though a callback called setup_all which will be explained later.

First, let’s create the file. Because the admin controller tests are only in the Admin app, we won’t use this file in any other application. So, let’s add auth_case.ex to our admin/test/support folder. Let’s do it step by step, beginning with the module creation and the call function.

defmodule AdminWeb.AuthCase do
use Database.DataFixtures, [:admin_user]
alias Phoenix.ConnTest
alias Database.{AdminUsers, AdminUsers.AdminUser, Common.AdminGuardian, Repo}
def setup(is_for_case \\ true) do
if is_for_case do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
Ecto.Adapters.SQL.Sandbox.mode(Repo, :auto)
end
conn = ConnTest.build_conn()
admin_user = admin_user_fixture(@super_admin_attrs)
auth_conn = AdminGuardian.Plug.sign_in(conn, admin_user)
%{auth_conn: auth_conn, admin_user: admin_user}
end

The use and the import have already been seen. The fixtures will help us populate the database, as seen later. alias Database calls multiple modules from the same application (here Database).

Then, come the first function, called setup. You can name it the way you like, but using a common vocabulary may be better. I say common vocabulary, because setup is a callback from ExUnit, and this function will be called either in a setup or a setup_all callback. You can see the full list here.

AdminWeb.AuthCase.setup/1 takes a boolean, which is used to set up the Sandbox only if there is a need to do so. Because setup_all works in a separate process, its Sandbox needs a configuration. But if you choose to call this function from a setup callback, then the Sandbox being already configured may create a problem.

Once this step is done, setup needs a conn. To do so, it calls a function defined in Phoenix.ConnTest which just create it. Then, we populate our admin user in the database thanks to the fixture, and sign_in him with our Guardian, using the conn and the admin user we created.

Once we have both a new user and an auth_conn, we send them back. But, we will have to delete them once we are done with them. To do so, we need another function:

  def delete_user_if_found(id) do
with %AdminUser{} = admin_user <- AdminUsers.get_admin_user(id) do
AdminUsers.delete_admin_user(admin_user)
else
_ -> :deleted
end
end
end

If you red the different functions present in ExUnit.CallBack, the first one in the list is on_exit. This function could have been named as such, but because it does only one thing (delete a user if found) then we should call it as such.

If you want to do anything else on_exit, you will have to create your own function, and made a call to delete_user_if_found/1 inside of it.

Once it is done, we just have to delete the user, if he exist, and return a nice message.

This function is simple. It checks if a user exists, and if it does, it calls the function to delete it from the database.

Adding some fixtures

Because we call a non existent fixture, we need to add it to our fixture file. To do so, open app/database/test/support/data_fixtures.ex and add, before "@valid_attrs":

@super_admin_attrs %{
username: "test_admin",
password: "test_pwd",
accreditation: "super_admin",
email: "admin@mail.test"
}

Using AuthCase

Once AuthCase is set and our fixture file updated, we can think about our main test files from our admin application:

  • test/admin_web/controllers/page_controller
  • test/admin_web/controllers/admin_user_controller

In both files, you will need to add our AuthCase module in an alias and call it, such as:

alias AdminWeb.AuthCasesetup_all do
%{auth_conn: auth_conn, admin_user: admin_user} = AuthCase.setup()

on_exit fn -> AuthCase.delete_user_if_found(admin_user.id) end

[auth_conn: auth_conn, admin_user: admin_user]
end

setup_all is a callback running before every test in a case. And in both files, all tests need an logged user, which make it the perfect match. Another point is, for some reasons, the persisted data doesn’t stay in the database. But it’s for the best, because we just need to auth the user, not to keep it.

Also, having another entity in the database may lead to some error in the list tests. Therefor, it looks like the best place to create and log our user, as it will save computation time.

As you have guessed, the first line calls the function setup we wrote earlier. Then, on_exit is the callback which will call the second function we wrote. Even if the entity is destroyed, it’s better to be sure. Finally, the last line will allow our tests to get the data.

In both files, you will also have to change one word in each line beginning with test or containing a function get, post, put, delete... (any HTML method). This word is conn or conn:, which should be replaced by auth_conn and auth_conn:.

The reason you don’t change all conn to auth_conn is that assert modify a little bit conn, and as the user doesn’t exist anymore in the database, the verification will fail, making the test fail.

Finally, we just need to do a small modification in our guardian file apps/database/lib/database/common/guardian.ex, in the function resource_from_claims:

def resource_from_claims(%{"sub" => id}) do
user = if (Mix.env == :test) do
AdminUsers.get_admin_user(id)
else
AdminUsers.get_admin_user!(id)
end

We added a condition checking the environment. In some case, you can have a failed test because of this function. This is because AdminUsers.get_admin_user!/1 will try to find the user created in your setup_all, user which doesn’t exist in the database. Because the user doesn’t exist, an error is sent, making the test fail.
AdminUsers.get_admin_user(id) (which, by the way, you have to code by yourself. It is really easy though) will just return nil, making it not fail the tests.

Deleting remaining data

If you run the tests now, you should have all tests validated. But if you run it a second time, then at least one shouldn’t work.

This is because of some uncleared data once the tests are done. To ensure nothing remain, we will have to add an on_exit callback in our create_admin_user function, which should be the same as the one in our setup_all callback.

Finally, in the test “redirects to show when data is valid”, the user created isn’t removed. Just add AuthCase.delete_user_if_found(id), as the user_id is already assigned to this variable.

Test our AuthController

Because we worked on our test controllers, why not create one brand new, like we created a controller in the third part of this guide? Yeah, let’s do that.

In the controller test folder, create a file named auth_controller_test.exs. As any time we create a file, we need to define a module and add some dependencies. We will also create our setup_all callback, as we will need to try to get the login page when we are connected, but also logout.

defmodule AdminWeb.AuthControllerTest do
use AdminWeb.ConnCase
use Database.DataFixtures, [:admin_user]
alias AdminWeb.AuthCase
setup_all _context do
%{auth_conn: auth_conn, admin_user: admin_user} = AuthCase.setup()
on_exit(fn -> AuthCase.delete_user_if_found(admin_user.id) end) [auth_conn: auth_conn, admin_user: admin_user]
end

So far, nothing new under the sky. Well, not that there will be anything new in this part of the guide. So, you should try to complete it by yourself, before reading further. Could be a good training.

Then, we will create our first describe. This part will be all about login (and only login). It will set up a user, and do four tests: get the login page, post login with valid data, post login with invalid data, and try to get the login page when connected.

describe "login" do
setup [:create_admin_user]
test "get login page", %{conn: conn} do
conn = get(conn, Routes.auth_path(conn, :login_page))
assert html_response(conn, 200) =~ "Login page"
end
test "post login with valid data", %{conn: conn} do
conn = post(conn, Routes.auth_path(conn, :login), admin_user: @valid_attrs)
assert redirected_to(conn) == Routes.page_path(conn, :index)
end
test "post login with invalid data", %{conn: conn} do
conn = post(conn, Routes.auth_path(conn, :login), admin_user: @invalid_attrs)
assert get_flash(conn, :info) == "Error: invalid_credentials"
end
test "try to get login page when connected", %{auth_conn: conn} do
conn = get(conn, Routes.auth_path(conn, :login_page))
assert redirected_to(conn) == Routes.page_path(conn, :index)
end
end

Each test runs in two lines. It makes a call, then assert the conn. Simple. Effective.

Note: I had to modify the lib/admin_web/templates/auth/login.html.eex in purpose to have a “Login page” somewhere, to render the assert valid. But you may want to verify if there is the email field or something else. That’s up to you.

As you can also observe, we use a setup to create an admin_user, allowing us to try the connection. We also use the normal conn but in the last test, because the normal conn isn’t authenticated.

Then, we need to test logout whether if we are connected or not. Once again, you’ll need to create a describe, but we won’t need any setup because we don’t use any user. What we need is to test if logout works with the auth_conn, and if it fails with the base conn.

describe "logout" do
test "try to logout when not connected", %{conn: conn} do
conn = post(conn, Routes.auth_path(conn, :logout))
assert text_response(conn, 401) =~ "unauthenticated"
end
test "logout when connected", %{auth_conn: conn} do
conn = post(conn, Routes.auth_path(conn, :logout))
assert redirected_to(conn) == Routes.auth_path(conn, :login_page)
end
end

It is almost over. We only need to have our create_admin_user function, and it should be good! (in fact, it is a function we could put in our macro. It is your call)

  defp create_admin_user(_) do
admin_user = admin_user_fixture()
on_exit(fn -> AuthCase.delete_user_if_found(admin_user.id) end) :ok
end
end

Other test we can do

There is plenty of other tests we can do. Most of them would be in database, and would be unitary tests. One for our Plus, another one for Guardian, then you could also tests password_handler. Doing them should allow you to skip some end to end tests (such as testing if an unauthenticated user can access to a private page). They should also help you find errors faster. But they aren’t the most necessary tests.

That’s all for today! I hope this tutorial guide you through your journey about TDD, setting up good practices and showing you how simple it can be!

--

--

Cédric Paumard

Elixir developer, bass player and probably too curious.