Testing Phoenix controllers
Published on 01 February 2015, updated on 01 February 2015, Comments
This article is going to show you how to write basic tests for your Phoenix controllers that interact with a PostgreSQL database.
The first test
Let’s start a new Phoenix app called menu
. Our app already contains a very simple test in the file test/menu_test.exs
:
defmodule MenuTest do
use ExUnit.Case
test "the truth" do
assert 1 + 1 == 2
end
end
The test in itself is not very useful, but it will allow to check that we can run our test suite. Let’s try to invoke the test
Mix command:
$ mix test
.
Finished in 0.05 seconds (0.05s on load, 0.00s on tests)
1 tests, 0 failures
Randomized with seed 460386
Apparently everything went well, so we can start adding more meaningful tests.
Minimal controller test
Phoenix is based on Plug, the standard Elixir web
connector, which provides a module called Plug.Test
to help testing plug
applications. Since a Phoenix application is also a plug application, we can
use that module to test our Phoenix controllers.
The Plug documentation provides a simple test example that looks promising:
defmodule MyPlugTest do
use ExUnit.Case, async: true
use Plug.Test
@opts AppRouter.init([])
test "returns hello world" do
# Create a test connection
conn = conn(:get, "/")
# Invoke the plug
conn = AppRouter.call(conn, @opts)
# Assert the response and status
assert conn.state == :sent
assert conn.status == 200
assert conn.resp_body == "Hello world"
end
end
Let’s try to adapt this test for our own code and see if it works. We change it to use our own router module and adapt the verification on the response body:
defmodule MenuTest do
use ExUnit.Case, async: true
use Plug.Test
@opts Menu.Router.init([])
test "says hello" do
# Create a test connection
conn = conn(:get, "/")
# Invoke the plug
conn = Menu.Router.call(conn, @opts)
# Assert the response and status
assert conn.state == :sent
assert conn.status == 200
assert String.contains?(conn.resp_body, "Hello Phoenix!")
end
end
Let’s run our test suite again and see how it goes:
$ mix test
1) test says hello (MenuTest)
test/menu_test.exs:7
** (ArgumentError) trying to access key "format" but they were not yet fetched. Please call Plug.Conn.fetch_params before accessing it
stacktrace:
(plug) lib/plug/conn/unfetched.ex:19: Access.Plug.Conn.Unfetched.raise_no_access/2
(elixir) lib/access.ex:113: Access.Map.get_and_update!/3
(phoenix) lib/phoenix/controller.ex:607: Phoenix.Controller.accept/2
(menu) web/router.ex:4: Menu.Router.browser/2
(menu) lib/phoenix/router.ex:2: Menu.Router.call/2
test/menu_test.exs:12
Finished in 0.09 seconds (0.07s on load, 0.02s on tests)
1 tests, 1 failures
Randomized with seed 636873
Clearly we have a problem with our test suite. By default a Phoenix app is
configured to use a few pieces of middleware for content negotiation, session
handling, etc. This is going to require a bit more work in our test and the
error message actually gives a clue about what we need to do. But what if don’t
need all that stuff and just want our minimal test to succeed? Let’s open our web/router.ex
file and see what we’ve got in there:
defmodule Menu.Router do
use Phoenix.Router
pipeline :browser do
plug :accepts, ~w(html)
plug :fetch_session
plug :fetch_flash
plug :protect_from_forgery
end
pipeline :api do
plug :accepts, ~w(json)
end
scope "/", Menu do
pipe_through :browser # Use the default browser stack
get "/", PageController, :index
end
# Other scopes may use custom stacks.
# scope "/api", Menu do
# pipe_through :api
# end
end
We can see that by default a Phoenix app is configured to use a middleware pipleline labelled
:browser
. Let’s comment out the line starting with pipe_through :browser
and run our test suite again:
$ mix test
12:03:40.366 [debug] Processing by Menu.PageController.index/2
Parameters: [UNFETCHED]
Pipelines: []
.
Finished in 0.1 seconds (0.08s on load, 0.05s on tests)
1 tests, 0 failures
Randomized with seed 327147
Fantastic, we’ve got our first controller test working!
Testing with the default plug pipeline
Now that we’ve got a minimal controller test passing, let’s see if we can improve
it so that it works with default Phoenix middleware. First let’s
uncomment the line starting with pipe_through :browser
and make sure our test
suite is failing again. To get our test working with middleware enabled, we can
get inspiration from Phoenix’s own test suite, in particular the file
router_helper.exs. It contains a function with_session
that we can copy to our test file, modify just a bit and use in our test
so that our test file now looks like this:
defmodule MenuTest do
use ExUnit.Case, async: true
use Plug.Test
@opts Menu.Router.init([])
def with_session(conn) do
session_opts = Plug.Session.init(store: :cookie, key: "_app",
encryption_salt: "abc", signing_salt: "abc")
conn
|> Map.put(:secret_key_base, String.duplicate("abcdefgh", 8))
|> Plug.Session.call(session_opts)
|> Plug.Conn.fetch_session()
|> Plug.Conn.fetch_params()
end
test "root URL" do
# Create a test connection
conn = with_session conn(:get, "/")
# Invoke the plug
conn = Menu.Router.call(conn, @opts)
# Assert the response and status
assert conn.state == :sent
assert conn.status == 200
assert String.contains?(conn.resp_body, "Hello Phoenix!")
end
end
The with_session
function prepares the session middleware and fetches query
string parameters so that content negotiation doesn’t fail.
Now run mix test
again. If it succeeds it means we now have a first controller test working with the default Phoenix router configuration!
A test database
Now let’s setup our project to use a database. The details of doing this go beyond the scope of this article, so I refer you to Ecto Models or Book Listing App With Elixir, Phoenix, Postgres and Ecto. You can also check out the source code for this article.
Our Ecto schema will define a dishes table with a title, a price and a description, as would be useful in a restaurant management system:
defmodule Menu.Dishes do
use Ecto.Model
schema "dishes" do
field :title, :string
field :description, :string
field :price, :decimal
end
end
First of all let’s check if we can use our database by adding a simple test. Let’s import Ecto.Query
and add a new test to our test suite:
defmodule MenuTest do
# ...
import Ecto.Query
# ...
# Our new test
test "create dish" do
dish = %Menu.Dishes{
title: "Pasta",
description: "Delicious pasta",
price: Decimal.new("8.50")
}
Menu.Repo.insert(dish)
query = from dish in Menu.Dishes,
order_by: [desc: dish.id],
select: dish
assert length(Menu.Repo.all(query)) == 1
end
end
Let’s run our test suite:
$ mix test
[...]
Finished in 0.3 seconds (0.1s on load, 0.2s on tests)
2 tests, 0 failures
Randomized with seed 306152
It looks fine, but let’s try to run again:
$ mix test
[...]
1) test create dish (MenuTest)
test/menu_test.exs:22
Assertion with == failed
code: length(Menu.Repo.all(query)) == 1
lhs: 2
rhs: 1
stacktrace:
test/menu_test.exs:32
Finished in 0.3 seconds (0.1s on load, 0.2s on tests)
2 tests, 1 failures
Randomized with seed 909877
Clearly we have a problem here: our new test is not repeatable. This is because it writes data to our development database and doesn’t clean it up after it’s finished. So the second time we test if we can insert a record in the database there’s already an existing record and the total count equals two instead of one. Ideally we’d like our tests to run against a separate database that’s in a known state. To accomplish this we’re going to use Mix environments.
Let’s open config/test.exs
and configure our test database by adding this line:
config :phoenix, :database, "menu_test"
Similarly, we configure our development database by appending this line to config/dev.ex
:
config :phoenix, :database, "menu"
Now we need to update web/models/repo.ex
to make use of our new Mix environment variable:
defmodule Menu.Repo do
use Ecto.Repo, adapter: Ecto.Adapters.Postgres
def conf do
database = Application.get_env(:phoenix, :database)
parse_url "ecto://al:t0t0@localhost/#{database}"
end
def priv do
app_dir(:menu, "priv/repo")
end
end
We also need to modify our test so it runs schema migrations and cleans up data upon exit. We add this to the test case:
setup do
Mix.Tasks.Ecto.Migrate.run(["--all", "Menu.Repo"])
on_exit fn ->
Mix.Tasks.Ecto.Rollback.run(["--all", "Menu.Repo"])
end
end
If everything goes well, you should now be able to run mix test
as many times as you want.