Testing Phoenix controllers

Published on 01 February 2015, updated on 01 February 2015, Comments

This article is out of date. You should instead read Introduction to Testing from the Phoenix documentation.

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 conn = conn( :get , " / " ) conn = AppRouter .call(conn, @opts ) 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 conn = conn( :get , " / " ) conn = Menu .Router.call(conn, @opts ) 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 get " / " , PageController , :index 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 conn = with_session conn( :get , " / " ) conn = Menu .Router.call(conn, @opts ) 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 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