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.
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
andsetup_all
. There is better ways to do it. You can find one of them in the admin repository, branchbonus-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.AuthCasesetup_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!