Testing HTML in Phoenix Controllers

Paul Smith

Phoenix has a nice set of helpers for testing HTML responses. The default Phoenix generators offer a great way to get a quick start on testing HTML, but this guide hopes to dive in a bit further.

In this post we’ll go over:

  • Using the Phoenix helpers to test your controllers and their responses.
  • Some common errors, what they mean, and how to fix them.
  • How to use ExMachina to generate test data.
  • How to use regular functions and the Elixir |> to customize test data generated by ExMachina.

Setup

Follow the Phoenix installation instructions and then start a new project with mix phoenix.new my_blog. Once all the dependencies are installed and you’ve created your database with mix ecto.create, we can get started.

Testing the list of blog posts

Let’s create a file at test/controllers/post_controller_test.exs

defmodule MyBlog.PostControllerTest do
  use MyBlog.ConnCase

  setup do
    {:ok, conn: Phoenix.ConnTest.conn()}
  end

  test "lists all blog posts", %{conn: conn} do
    posts = create_pair(:post)

    conn = get conn, post_path(conn, :index)

    for post <- posts do
      assert html_response(conn, 200) =~ post.title
    end
  end
end

In the setup block we are generating a conn to be used. Note that when Phoenix 1.1 is released this will be done automatically by MyBlog.ConnCase.

The second part of the tuple returned in the setup block is available as the second argument to your test. We pattern match against it to make the conn available to our test.

We are calling the ExMachina function called create_pair/3 that will insert 2 posts. We could have used build_pair/3 if we didn’t need to save to the database, but in this case we need the posts saved.

Next we make a request to our controller and then check that the response has the titles of each of our blog posts.

The html_response helper makes sure that the response was successful (status code 200) and returns the HTML body. Using =~ checks that whatever is on the right hand side (post.title) is found somewhere in the string on the left hand side (the HTML body).

Now let’s try to run the tests with mix test. You should get an error like this:

** (CompileError) test/controllers/post_controller_test.exs:5: function create_pair/1 undefined

Setting up ExMachina

To fix that, let’s add ExMachina and add our factory.

Add {:ex_machina, "~> 0.5.0"} to your mix.exs dependencies. It will look something like this:

defp deps do
  [{:ex_machina, "~> 0.5.0"},
   {:phoenix, "~> 1.0.3"},
   # Other dependencies...
  ]
end

Then add :ex_machina to your list of applications. In a default Phoenix app this will look something like this:

def application do
  [mod: {MyBlog, []},
   applications: [:ex_machina, :phoenix, :phoenix_html, :cowboy, :logger,
                  :phoenix_ecto, :postgrex]]
end

Now let’s create our factory for generating test data. We’ll do this in lib/my_blog/factory.ex. Be sure to make the extension .ex and not .exs or it will not be compiled and you will get errors. See the ExMachina README for the latest.

defmodule MyBlog.Factory do
  use ExMachina.Ecto, repo: MyBlog.Repo

  def factory(:post) do
    %MyBlog.Post{
      title: sequence(:title, &"My Post #{&1}"),
      body: "This is my post about something",
      author: "Me!"
    }
  end
end

This is setting up a factory called :post that is built using the MyBlog.Post struct. The body and author are static, but the title uses the sequence/2 to ensure that the title is always unique. Whenever a post is built or created n will be incremented by 1. The &("My Post #{&1}") syntax is a shorthand for fn(n) -> "My Post #{n}" end. You can use whichever one you are most comfortable with.

Let’s add our factory to MyBlog.ConnCase so that our factory functions are available in all of our controller tests.

In test/support/conn_case.ex add import MyBlog.Factory in the using block.

using do
  quote do
    # ...other code automatically generated by Phoenix when you start a project
    import MyBlog.Factory
  end
end

Adding the Post model

If we run mix test we will now get:

== Compilation error on file test/support/factory.ex ==
** (CompileError) test/support/factory.ex:5: MyBlog.Post.__struct__/0 is
undefined, cannot expand struct MyBlog.Post

This means that we have not set up a struct called MyBlog.Post. Let’s create a model and migration to add it. Run:

mix phoenix.gen.model Post posts title:string body:text author:string
mix ecto.migrate

Adding routes and controller action

Running mix test will now show us:

** (CompileError) test/controllers/post_controller_test.exs:11: function posts_path/2 undefined

Let’s go ahead and add our route and controller action now.

In web/router.ex add your route to the default browser scope

scope "/", MyBlog do
  pipe_through :browser # Use the default browser stack

  get "/", PageController, :index
  resources "/posts", PostController, only: [:index]
end

Then create web/controllers/post_controller.ex

defmodule MyBlog.PostController do
  use MyBlog.Web, :controller

  def index(conn, _params) do
  end
end

Get and display posts

Let’s finish off the controller and make sure we are rendering our posts.

defmodule MyBlog.PostController do
  use MyBlog.Web, :controller

  alias MyBlog.Post

  def index(conn, _params) do
    # MyBlog.Repo is aliased for you when you `use MyBlog.Web, :controller`
    posts = Repo.all(Post)
    render(conn, "index.html", posts: posts)
  end
end

If we run mix test we’ll see something like this:

undefined function: MyBlog.PostView.render/2 (module MyBlog.PostView is not available)

That means we need to add our view and template. Let’s add a view at web/views/post_view.ex

defmodule MyBlog.PostView do
  use MyBlog.Web, :view
end

And a template at web/templates/post/index.html.eex

<%= for post <- @posts do %>
  <%= post.title %>
<% end %>

Now when you run mix test everything should pass smoothly.

Using function composition for more robust tests

Let’s say that our post can be tagged. A post can be tagged with multiple tags. We’ll start by updating our factory. We’ll assume our models have already been created and jump to updating our tests.

def factory(:tag) do
  %MyBlog.Tag{
    name: sequence(:tag_name, fn(n) -> "Tag #{n}" end)
  }
end

def factory(:post_tag) do
  %MyBlog.PostTag{
    post: build(:post)
    tag: build(:tag)
  }
end

We’re going to use another sequence so that when we build or create a :tag, the name is always unique. When building a :post_tag we will build a post and a tag by default. This can be overridden just like any other attribute. We’ll see how to do that in a second.

Let’s add a test:

test "list of post shows the post's tags", %{conn: conn} do
  tag_name = "elixir"
  create(:post)
  tag = create(:tag, name: tag_name)
  create(:post_tag, tag: tag, post: post)

  conn = get conn, post_path(conn, :index)

  assert html_response(conn, 200) =~ tag_name
end

This test will work, but the problem is that the test is brittle and not very expressive. If someone needs to create a tagged post in another test they will have to remember to do this. If you change how you create posts, you will then need to change this in every test that uses it.

Instead let’s add a function to our factory in lib/my_blog/factory.ex to make this more clear.

def tagged_with(post, tag_name) do
  tag = create(:tag, name: tag_name)
  create(:post_tag, post: post, tag: tag)
  post
end

Now our test will look like this

test "list of post shows the post's tags", %{conn: conn} do
  tag_name = "elixir"
  post = create(:post) |> tagged_with(tag_name)

  conn = get conn, post_path(conn, :index)

  assert html_response(conn, 200) =~ tag_name
end

Now if you need to create a tagged post in another test, it is much easier to do so. This is also nice because if we change how we tag posts we can just update the tagged_with/2 function instead of changing every test.

Using regular functions with ExMachina can make your code easier to read, and easier to change in the future.

This is a small taste of what you can do with ExMachina and the Phoenix tests. Be sure to check out the Phoenix.ConnTest docs and the ExMachina docs for more examples.