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.

blog comments powered by Disqus