Testing Controllers

Requirement: This guide expects that you have gone through the introductory guides and got a Phoenix application up and running.

Requirement: This guide expects that you have gone through the Introduction to Testing guide.

At the end of the Introduction to Testing guide, we generated an HTML resource for posts using the following command:

$ mix phx.gen.html Blog Post posts title body:text

This gave us a number of modules for free, including a PostController and the associated tests. We are going to explore those tests to learn more about testing controllers in general. At the end of the guide, we will generate a JSON resource, and explore how our API tests look like.

HTML controller tests

If you open up test/hello_web/controllers/post_controller_test.exs, you will find the following:

defmodule HelloWeb.PostControllerTest do
  use HelloWeb.ConnCase

  alias Hello.Blog

  @create_attrs %{body: "some body", title: "some title"}
  @update_attrs %{body: "some updated body", title: "some updated title"}
  @invalid_attrs %{body: nil, title: nil}

  def fixture(:post) do
    {:ok, post} = Blog.create_post(@create_attrs)
    post
  end

  ...

Similar to the PageControllerTest that ships with our application, this controller tests uses use HelloWeb.ConnCase to setup the testing structure. Then, as usual, it defines some aliases, some module attributes to use throughout testing, and then it starts a series of describe blocks, each of them to test a different controller action.

The index action

The first describe block is for the index action. The action itself is implemented like this in lib/hello_web/controllers/post_controller.ex:

def index(conn, _params) do
  posts = Blog.list_posts()
  render(conn, "index.html", posts: posts)
end

It gets all posts and renders the "index.html" template. The template can be found in "lib/hello_web/templates/page/index.html.eex".

The test looks like this:

describe "index" do
  test "lists all posts", %{conn: conn} do
    conn = get(conn, Routes.post_path(conn, :index))
    assert html_response(conn, 200) =~ "Listing Posts"
  end
end

The test for the index page is quite straight-forward. It uses the get/2 helper to make a request to the "/posts" page, returned by Routes.post_path(conn, :index), then we assert we got a successful HTML response and match on its contents.

The create action

The next test we will look at is the one for the create action. The create action implementation is this:

def create(conn, %{"post" => post_params}) do
  case Blog.create_post(post_params) do
    {:ok, post} ->
      conn
      |> put_flash(:info, "Post created successfully.")
      |> redirect(to: Routes.post_path(conn, :show, post))

    {:error, %Ecto.Changeset{} = changeset} ->
      render(conn, "new.html", changeset: changeset)
  end
end

Since there are two possible outcomes for the create, we will have at least two tests:

describe "create post" do
  test "redirects to show when data is valid", %{conn: conn} do
    conn = post(conn, Routes.post_path(conn, :create), post: @create_attrs)

    assert %{id: id} = redirected_params(conn)
    assert redirected_to(conn) == Routes.post_path(conn, :show, id)

    conn = get(conn, Routes.post_path(conn, :show, id))
    assert html_response(conn, 200) =~ "Show Post"
  end

  test "renders errors when data is invalid", %{conn: conn} do
    conn = post(conn, Routes.post_path(conn, :create), post: @invalid_attrs)
    assert html_response(conn, 200) =~ "New Post"
  end
end

The first test starts with a post/2 request. That's because once the form in the "/posts/new" page is submitted, it becomes a POST request to the create action. Because we have supplied valid attributes, the post should have been successfully created and we should have redirected to the show action of the new post. This new page will have an address like "/posts/ID", where ID is the identifier of the post in the database.

We then use redirected_params(conn) to get the ID of the post and then match that we indeed redirected to the show action. Finally, we do request a get request to the page we redirected to, allowing us to verify that the post was indeed created.

For the second test, we simply test the failure scenario. If any invalid attribute is given, it should re-render the "New Post" page.

One common question is: how many failure scenarios do you test at the controller level? For example, in the Testing Contexts guide, we introduced a validation to the title field of the post:

def changeset(post, attrs) do
  post
  |> cast(attrs, [:title, :body])
  |> validate_required([:title, :body])
  |> validate_length(:title, min: 2)
end

In other words, creating a post can fail for the following reasons:

  • the title is missing
  • the body is missing
  • the title is present but is less than 2 characters

Should we test all of these possible outcomes in our controller tests?

The answer is no. All of the different rules and outcomes should be verified in your context and schema tests. The controller works as the integration layer. In the controller tests we simply want to verify, in broad strokes, that we handle both success and failure scenarios.

The test for update follows a similar structure to the test on create, so we let's skip to the delete test.

The delete action

The delete action looks like this:

def delete(conn, %{"id" => id}) do
  post = Blog.get_post!(id)
  {:ok, _post} = Blog.delete_post(post)

  conn
  |> put_flash(:info, "Post deleted successfully.")
  |> redirect(to: Routes.post_path(conn, :index))
end

The test is written like this:

  describe "delete post" do
    setup [:create_post]

    test "deletes chosen post", %{conn: conn, post: post} do
      conn = delete(conn, Routes.post_path(conn, :delete, post))
      assert redirected_to(conn) == Routes.post_path(conn, :index)
      assert_error_sent 404, fn ->
        get(conn, Routes.post_path(conn, :show, post))
      end
    end
  end

  defp create_post(_) do
    post = fixture(:post)
    %{post: post}
  end

First of all, setup is used to declare that the create_post function should run before every test in this describe block. The create_post function simply creates a post and stores it in the test metadata. This allows us to, in the first line of the test, match on both the post and the connection:

test "deletes chosen post", %{conn: conn, post: post} do

The test uses delete/2 to delete the post and then asserts that we redirected to the index page. Finally, we check that it is no longer possible to access the show page of the deleted post:

assert_error_sent 404, fn ->
  get(conn, Routes.post_path(conn, :show, post))
end

assert_error_sent is a testing helper provided by Phoenix.ConnTest. In this case, it verifies that:

  1. An exception was raised
  2. The exception has a status code equivalent to 404 (which stands for Not Found)

This pretty much mimics how Phoenix handles exceptions. For example, when we access "/posts/12345" where 12345 is an ID that does not exist, we will invoke our show action:

def show(conn, %{"id" => id}) do
  post = Blog.get_post!(id)
  render(conn, "show.html", post: post)
end

When an unknown post ID is given to Blog.get_post!/1, it raises an Ecto.NotFoundError. If your application raises any exception during a web request, Phoenix translates those requests into proper HTTP response codes. In this case, 404.

We could, for example, have written this test as:

assert_raise Ecto.NotFoundError, fn ->
  get(conn, Routes.post_path(conn, :show, post))
end

However, you may prefer the implementation Phoenix generates by default as it ignores the specific details of the failure, and instead verifies what the browser would actually receive.

The tests for new, edit, and show actions are simpler variations of the tests we have seen so far. You can check the action implementation and their respective tests yourself. Now we are ready to move to JSON controller tests.

JSON controller tests

So far we have been working with a generated HTML resource. However, let's take a look at how our tests look like when we generate a JSON resource.

First of all, run this command:

$ mix phx.gen.json News Article articles title body

We choose a very similar concept to the Blog context <-> Post schema, except we are using a different name so we can study these concepts in isolation.

After you run the command above, do not forgot to follow the final steps output by the generator. Once all is done, we should run mix test and now have 33 passing tests:

$ mix test
................

Finished in 0.6 seconds
33 tests, 0 failures

Randomized with seed 618478

You may have noticed that this time the scaffold controller has generated fewer tests. Previously it generated 16 (we went from 3 to 19) and now it generated 14 (we went from 19 to 33). That's because JSON APIs do not need to expose the new and edit actions. We can see this is the case in the resource we have added to the router at the end of the mix phx.gen.json command:

resources "/articles", ArticleController, except: [:new, :edit]

new and edit are only necessary for HTML because they basically exist to assist users in creating and updating resources. Besides having less actions, we will the controller and view tests and implementations for JSON are drastically different from the HTML ones.

The only thing that is pretty much the same between HTML and JSON is the contexts and the schema, which, once you think about it, it makes total sense. After all, your business logic should remain the same, regardless if you are exposing it as HTML or JSON.

With the differences in hand, let's take a look at the controller tests.

The index action

Open up test/hello_web/controllers/article_controller_test.exs. The initial structure is quite similar to post_controller_test.exs. So let's take a look at the tests for the index action. The index action itself is implemented in lib/hello_web/controllers/article_controller.ex like this:

def index(conn, _params) do
  articles = News.list_articles()
  render(conn, "index.json", articles: articles)
end

The action gets all articles and renders "index.json". Since we are talking about JSON, we don't have a "index.json.eex" template. Instead, the code that converts "articles" into JSON can be found directly in the ArticleView module, defined at lib/hello_web/views/article_view.ex like this:

defmodule HelloWeb.ArticleView do
  use HelloWeb, :view
  alias HelloWeb.ArticleView

  def render("index.json", %{articles: articles}) do
    %{data: render_many(articles, ArticleView, "article.json")}
  end

  def render("show.json", %{article: article}) do
    %{data: render_one(article, ArticleView, "article.json")}
  end

  def render("article.json", %{article: article}) do
    %{id: article.id,
      title: article.title,
      body: article.body}
  end
end

We talked about render_many in the Views and Templates guide. All we need to know for now is that all JSON replies have a "data" key with either a list of posts (for index) or a single post inside of it.

Let's take a look at the test for the index action then:

describe "index" do
  test "lists all articles", %{conn: conn} do
    conn = get(conn, Routes.article_path(conn, :index))
    assert json_response(conn, 200)["data"] == []
  end
end

It simply accesses the index path, asserts we got a JSON response with status 200 and that it contains a "data" key with an empty list, as we have no articles to return.

That was quite boring. Let's look at something more interesting.

The create action

The create action is defined like this:

def create(conn, %{"article" => article_params}) do
  with {:ok, %Article{} = article} <- News.create_article(article_params) do
    conn
    |> put_status(:created)
    |> put_resp_header("location", Routes.article_path(conn, :show, article))
    |> render("show.json", article: article)
  end
end

As we can see, it checks if an article could be created. If so, it sets the status code to :created (which translates to 201), it sets a "location" header with the location of the article, and then renders "show.json" with the article.

This is precisely what the first test for the create action verifies:

describe "create" do
  test "renders article when data is valid", %{conn: conn} do
    conn = post(conn, Routes.article_path(conn, :create), article: @create_attrs)
    assert %{"id" => id} = json_response(conn, 201)["data"]

    conn = get(conn, Routes.article_path(conn, :show, id))

    assert %{
             "id" => id,
             "body" => "some body",
             "title" => "some title"
           } = json_response(conn, 200)["data"]
  end

The test uses post/2 to create a new article and then we verify that the article returned a JSON response, with status 201, and that it had a "data" key in it. We pattern match the "data" on %{"id" => id}, which allows us to extract the ID of the new article. Then we perform a get/2 request on the show route and verify that the article was successfully created.

Inside describe "create", we will find another test, which handles the failure scenario. Can you spot the failure scenario in the create action? Let's recap it:

def create(conn, %{"article" => article_params}) do
  with {:ok, %Article{} = article} <- News.create_article(article_params) do

The with special form that ships as part of Elixir allows us to check explicitly for the happy paths. In this case, we are interested only in the scenarios where News.create_article(article_params) returns {:ok, article}, if it returns anything else, the other value will simply be returned directly and none of the contents inside the do/end block will be executed. In other words, if News.create_article/1 returns {:error, changeset}, we will simply return {:error, changeset} from the action.

However, this introduces an issue. Our actions do not know how to handle the {:error, changeset} result by default. Luckily, we can teach Phoenix Controllers to handle it with the Action Fallback controller. At the top of the ArticleController, you will find:

  action_fallback HelloWeb.FallbackController

This line says: if any action does not return a %Plug.Conn{}, we want to invoke the FallbackController with the result. You will find HelloWeb.FallbackController at lib/hello_web/controllers/fallback_controller.ex and it looks like this:

defmodule HelloWeb.FallbackController do
  use HelloWeb, :controller

  def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
    conn
    |> put_status(:unprocessable_entity)
    |> put_view(HelloWeb.ChangesetView)
    |> render("error.json", changeset: changeset)
  end

  def call(conn, {:error, :not_found}) do
    conn
    |> put_status(:not_found)
    |> put_view(HelloWeb.ErrorView)
    |> render(:"404")
  end
end

You can see how the first clause of the call/2 function handles the {:error, changeset} case, setting the status code to unprocessable entity (422), and then rendering "error.json" from the changeset view with the failed changeset.

With this in mind, let's look at our second test for create:

test "renders errors when data is invalid", %{conn: conn} do
  conn = post(conn, Routes.article_path(conn, :create), article: @invalid_attrs)
  assert json_response(conn, 422)["errors"] != %{}
end

It simply posts to the create path with invalid parameters. This makes it return a JSON response, with status code 422, and a response with a non-empty "errors" key.

The action_fallback can be extremely useful to reduce boilerplate when designing APIs. You can learn more about the "Action Fallback" in the Controllers guide.

The delete action

Finally, the last action we will stidy is the delete action for JSON. Its implementation looks like this:

def delete(conn, %{"id" => id}) do
  article = News.get_article!(id)

  with {:ok, %Article{}} <- News.delete_article(article) do
    send_resp(conn, :no_content, "")
  end
end

The new action simply attempts to delete the article and, if it succeeds, it returns an empty response with status code :no_content (204).

The test looks like this:

describe "delete article" do
  setup [:create_article]

  test "deletes chosen article", %{conn: conn, article: article} do
    conn = delete(conn, Routes.article_path(conn, :delete, article))
    assert response(conn, 204)

    assert_error_sent 404, fn ->
      get(conn, Routes.article_path(conn, :show, article))
    end
  end
end

defp create_article(_) do
  article = fixture(:article)
  %{article: article}
end

It setups a new article, then in the test it invokes the delete path to delete it, asserting on a 204 response, which is neither JSON nor HTML. Then it verifies that we can no longer access said article.

That's all!

Now that we understand how the scaffolded code and their tests work for both HTML and JSON APIs, we are prepared to move forward in building and maintaining our web applications!