You’ve learned to test schemas, contexts, factories, mocks, adapters, and helpers. Now it’s time to put it all together at the web layer. Controller tests verify that your HTTP endpoints work correctly—routing, request parsing, response formatting, and error handling. This is Part 7, the final installment of our 7-part series on Elixir testing patterns.

This post covers PR #8: Phoenix controller testing patterns, which adds a complete JSON API for todos with comprehensive test coverage.

Why Controller Testing Matters

Controller tests sit at the integration layer. They test:

  • Routing: Does /api/todos reach the right controller action?
  • Request parsing: Are JSON payloads correctly decoded?
  • Business logic integration: Does the controller call context functions correctly?
  • Response formatting: Is the JSON response structured properly?
  • Error handling: Do validation errors return 422? Missing records return 404?

Some teams skip controller tests, arguing that context tests plus end-to-end tests provide enough coverage. But controller tests catch bugs that neither catches—parameter handling issues, response structure changes, HTTP status code errors.

ConnCase: The Controller Test Template

Just as DataCase provides database isolation for context tests, ConnCase provides connection handling for controller tests. Phoenix generates this template at test/support/conn_case.ex:

defmodule ExTestWeb.ConnCase do
  use ExUnit.CaseTemplate
 
  using do
    quote do
      # The default endpoint for testing
      @endpoint ExTestWeb.Endpoint
 
      use ExTestWeb, :verified_routes
 
      # Import conveniences for testing with connections
      import Plug.Conn
      import Phoenix.ConnTest
      import ExTestWeb.ConnCase
 
      # Import test helpers (same as DataCase)
      import ExTest.Factory
      import ExTest.FactoryHelper
      import ExTest.StubHelper
      import ExTest.AssertHelper
 
      # Import authentication simulation helpers
      import ExTestWeb.ConnSimulator
    end
  end
 
  setup tags do
    ExTest.DataCase.setup_sandbox(tags)
    {:ok, conn: Phoenix.ConnTest.build_conn()}
  end
end

Key things happening here:

  1. @endpoint: Tells Phoenix.ConnTest which endpoint to dispatch requests to
  2. use ExTestWeb, :verified_routes: Enables the ~p sigil for type-safe routes
  3. import Phoenix.ConnTest: Brings in get/2, post/3, json_response/2, etc.
  4. Setup provides conn: Every test receives a fresh connection in the context

The setup block reuses DataCase.setup_sandbox/1 for database isolation—controller tests need the sandbox too since they typically create and read data.

Your First Controller Test

Let’s look at a complete controller test file from the repository:

defmodule ExTestWeb.TodoControllerTest do
  use ExTestWeb.ConnCase, async: true
 
  alias ExTest.Todos
 
  @create_attrs %{
    title: "Test Todo",
    description: "Test description",
    completed: false,
    due_date: "2025-12-31"
  }
 
  @update_attrs %{
    title: "Updated Title",
    completed: true
  }
 
  @invalid_attrs %{title: nil}
 
  describe "GET /api/todos (index)" do
    test "lists all todos", %{conn: conn} do
      insert(:todo, title: "First Todo")
      insert(:todo, title: "Second Todo")
 
      conn = get(conn, ~p"/api/todos")
      response = json_response(conn, 200)
 
      assert length(response["data"]) == 2
    end
 
    test "returns empty list when no todos exist", %{conn: conn} do
      conn = get(conn, ~p"/api/todos")
 
      assert json_response(conn, 200) == %{"data" => []}
    end
  end
end

Notice the patterns:

  1. use ExTestWeb.ConnCase, async: true: Enables parallel test execution
  2. Module attributes for test data: Just like in context tests
  3. %{conn: conn} in test signature: Receives the connection from setup
  4. get(conn, ~p"/api/todos"): Makes a GET request using verified routes
  5. json_response(conn, 200): Parses JSON and asserts status code in one call

The ~p sigil is Phoenix 1.7+‘s verified routes. The compiler checks that the route exists, catching typos at compile time rather than runtime.

Testing CRUD Operations

A REST controller typically has five actions: index, show, create, update, delete. Let’s examine test patterns for each.

Testing Index (List All)

describe "GET /api/todos (index)" do
  test "lists all todos", %{conn: conn} do
    insert(:todo, title: "First Todo")
    insert(:todo, title: "Second Todo")
 
    conn = get(conn, ~p"/api/todos")
    response = json_response(conn, 200)
 
    assert length(response["data"]) == 2
  end
 
  test "returns empty list when no todos exist", %{conn: conn} do
    conn = get(conn, ~p"/api/todos")
 
    assert json_response(conn, 200) == %{"data" => []}
  end
 
  test "includes all todo fields in response", %{conn: conn} do
    todo = insert(:todo)
 
    conn = get(conn, ~p"/api/todos")
    [returned_todo] = json_response(conn, 200)["data"]
 
    assert returned_todo["id"] == todo.id
    assert returned_todo["title"] == todo.title
    assert returned_todo["description"] == todo.description
    assert returned_todo["completed"] == todo.completed
    assert returned_todo["due_date"] == Date.to_string(todo.due_date)
  end
end

Three tests cover the index action:

  1. Happy path: Multiple todos are returned
  2. Empty case: No todos returns empty array (not null or error)
  3. Field verification: Response structure matches expectations

That third test is often overlooked. It catches bugs where you accidentally omit a field from the JSON view or serialize it incorrectly.

Testing Show (Get One)

describe "GET /api/todos/:id (show)" do
  test "returns todo when found", %{conn: conn} do
    todo = insert(:todo, title: "Find Me")
 
    conn = get(conn, ~p"/api/todos/#{todo}")
    response = json_response(conn, 200)
 
    assert response["data"]["id"] == todo.id
    assert response["data"]["title"] == "Find Me"
  end
 
  test "returns 404 when todo not found", %{conn: conn} do
    assert_error_sent 404, fn ->
      get(conn, ~p"/api/todos/999999")
    end
  end
end

The assert_error_sent/2 function deserves attention. When Phoenix raises Ecto.NoResultsError, the error handling pipeline converts it to a 404 response. assert_error_sent wraps the request, catches the error, and asserts the correct status code.

You can’t use json_response(conn, 404) directly here because the error bubbles up as an exception, not a normal response.

Testing Create

describe "POST /api/todos (create)" do
  test "creates and returns todo with valid data", %{conn: conn} do
    conn = post(conn, ~p"/api/todos", todo: @create_attrs)
 
    assert %{"id" => id} = json_response(conn, 201)["data"]
    assert Todos.get_todo!(id).title == "Test Todo"
  end
 
  test "sets location header on successful create", %{conn: conn} do
    conn = post(conn, ~p"/api/todos", todo: @create_attrs)
 
    assert %{"id" => id} = json_response(conn, 201)["data"]
    assert get_resp_header(conn, "location") == ["/api/todos/#{id}"]
  end
 
  test "returns 422 with errors for invalid data", %{conn: conn} do
    conn = post(conn, ~p"/api/todos", todo: @invalid_attrs)
 
    assert json_response(conn, 422)["errors"] != %{}
  end
 
  test "returns specific error message for missing title", %{conn: conn} do
    conn = post(conn, ~p"/api/todos", todo: @invalid_attrs)
 
    errors = json_response(conn, 422)["errors"]
    assert errors["title"] == ["can't be blank"]
  end
end

Create tests verify:

  1. Status 201: Created, not 200
  2. Persistence: The todo actually exists in the database
  3. Location header: REST convention for new resources
  4. Validation errors: 422 with structured error messages

The fourth test checks the exact error message structure. This matters for API clients that display validation errors to users.

Testing Update

describe "PUT /api/todos/:id (update)" do
  setup [:with_todo]
 
  test "updates and returns todo with valid data", %{conn: conn, todo: todo} do
    conn = put(conn, ~p"/api/todos/#{todo}", todo: @update_attrs)
 
    assert %{"id" => id} = json_response(conn, 200)["data"]
 
    updated = Todos.get_todo!(id)
    assert updated.title == "Updated Title"
    assert updated.completed == true
  end
 
  test "preserves unchanged fields", %{conn: conn, todo: todo} do
    original_description = todo.description
 
    conn = put(conn, ~p"/api/todos/#{todo}", todo: %{title: "New Title"})
 
    updated = json_response(conn, 200)["data"]
    assert updated["description"] == original_description
  end
 
  test "returns 422 with errors for invalid data", %{conn: conn, todo: todo} do
    conn = put(conn, ~p"/api/todos/#{todo}", todo: @invalid_attrs)
 
    assert json_response(conn, 422)["errors"] != %{}
  end
 
  test "returns 404 when todo not found", %{conn: conn} do
    assert_error_sent 404, fn ->
      put(conn, ~p"/api/todos/999999", todo: @update_attrs)
    end
  end
end
 
defp with_todo(_context) do
  todo = insert(:todo)
  %{todo: todo}
end

The setup [:with_todo] pattern creates a todo before each test in this describe block. Named setup functions (covered in Part 6) keep tests focused on the behavior being tested.

The “preserves unchanged fields” test catches a subtle bug: partial updates should only change specified fields, not reset others to nil.

Testing Delete

describe "DELETE /api/todos/:id (delete)" do
  setup [:with_todo]
 
  test "deletes the todo", %{conn: conn, todo: todo} do
    conn = delete(conn, ~p"/api/todos/#{todo}")
 
    assert response(conn, 204)
  end
 
  test "todo no longer exists after deletion", %{conn: conn, todo: todo} do
    delete(conn, ~p"/api/todos/#{todo}")
 
    assert_error_sent 404, fn ->
      get(conn, ~p"/api/todos/#{todo}")
    end
  end
 
  test "returns 404 when todo not found", %{conn: conn} do
    assert_error_sent 404, fn ->
      delete(conn, ~p"/api/todos/999999")
    end
  end
end

Delete tests use response(conn, 204) instead of json_response because DELETE typically returns no content. The second test verifies the resource is actually gone by trying to fetch it again.

The Controller Under Test

Here’s the controller being tested:

defmodule ExTestWeb.TodoController do
  use ExTestWeb, :controller
 
  alias ExTest.Todos
  alias ExTest.Todos.Todo
 
  action_fallback ExTestWeb.FallbackController
 
  def index(conn, _params) do
    todos = Todos.list_todos()
    render(conn, :index, todos: todos)
  end
 
  def show(conn, %{"id" => id}) do
    todo = Todos.get_todo!(id)
    render(conn, :show, todo: todo)
  end
 
  def create(conn, %{"todo" => todo_params}) do
    with {:ok, %Todo{} = todo} <- Todos.create_todo(todo_params) do
      conn
      |> put_status(:created)
      |> put_resp_header("location", ~p"/api/todos/#{todo}")
      |> render(:show, todo: todo)
    end
  end
 
  def update(conn, %{"id" => id, "todo" => todo_params}) do
    todo = Todos.get_todo!(id)
 
    with {:ok, %Todo{} = todo} <- Todos.update_todo(todo, todo_params) do
      render(conn, :show, todo: todo)
    end
  end
 
  def delete(conn, %{"id" => id}) do
    todo = Todos.get_todo!(id)
 
    with {:ok, %Todo{}} <- Todos.delete_todo(todo) do
      send_resp(conn, :no_content, "")
    end
  end
end

The action_fallback directive is key. When a with block doesn’t match (e.g., {:error, changeset}), the fallback controller handles it:

defmodule ExTestWeb.FallbackController do
  use ExTestWeb, :controller
 
  def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
    conn
    |> put_status(:unprocessable_entity)
    |> put_view(json: ExTestWeb.ChangesetJSON)
    |> render(:error, changeset: changeset)
  end
 
  def call(conn, {:error, :not_found}) do
    conn
    |> put_status(:not_found)
    |> put_view(json: ExTestWeb.ErrorJSON)
    |> render(:"404")
  end
end

This pattern centralizes error handling, keeping controller actions focused on the happy path.

Testing Authentication

Most real applications require authentication. Our repository includes ConnSimulator, a module for simulating authenticated requests without implementing a real auth system:

defmodule ExTestWeb.ConnSimulator do
  @moduledoc """
  Helpers for simulating authentication in controller tests.
  """
 
  import Plug.Conn
  import Phoenix.ConnTest
  import ExTest.Factory
 
  def log_in_user(conn, user \\ nil) do
    user = user || insert(:user)
 
    conn
    |> assign(:current_user, user)
  end
 
  def with_api_token(conn, user \\ nil) do
    user = user || insert(:user)
    token = "test_token_#{user.id}"
 
    conn
    |> put_req_header("authorization", "Bearer #{token}")
    |> assign(:current_user, user)
  end
 
  def logged_in_conn(opts \\ []) do
    user = insert(:user, opts)
 
    build_conn()
    |> log_in_user(user)
  end
 
  def assert_unauthorized(conn) do
    assert conn.status == 401
    conn
  end
 
  def assert_forbidden(conn) do
    assert conn.status == 403
    conn
  end
end

Now tests can simulate different auth scenarios:

describe "authenticated requests (demonstration)" do
  test "simulates logged-in user with log_in_user/2", %{conn: conn} do
    user = insert(:user, name: "Test User")
    conn = conn |> log_in_user(user)
 
    # User is now in assigns
    assert conn.assigns.current_user.name == "Test User"
 
    # Can still make API requests
    conn = get(conn, ~p"/api/todos")
    assert json_response(conn, 200)
  end
 
  test "simulates API token auth with with_api_token/2", %{conn: conn} do
    user = insert(:user)
    conn = conn |> with_api_token(user)
 
    # Authorization header is set
    [auth_header] = get_req_header(conn, "authorization")
    assert auth_header =~ "Bearer test_token_"
 
    # User is in assigns
    assert conn.assigns.current_user.id == user.id
  end
 
  test "creates authenticated connection with logged_in_conn/0" do
    conn = logged_in_conn()
 
    assert conn.assigns.current_user
    assert conn.assigns.current_user.id
  end
end

These helpers abstract away auth complexity. Tests focus on the behavior being tested, not on setting up authentication every time.

In a real application with actual auth (like phx_gen_auth), you’d replace these simulations with real session/token handling. The pattern stays the same—helper functions that prepare the connection.

JSON Response Assertions

json_response/2 is the workhorse of controller testing:

# Parses JSON and asserts status 200
response = json_response(conn, 200)
 
# Assert on the parsed data
assert response["data"]["title"] == "Expected Title"

It does three things:

  1. Asserts the content-type is application/json
  2. Asserts the status code matches
  3. Parses and returns the JSON body

For non-JSON responses, use response/2:

# Just asserts status, returns raw body
assert response(conn, 204) == ""

Or for HTML:

# Assert status and get HTML body
html = html_response(conn, 200)
assert html =~ "Welcome"

Headers and Request Customization

Testing headers requires put_req_header/3 before the request:

test "respects Accept-Language header", %{conn: conn} do
  conn =
    conn
    |> put_req_header("accept-language", "es")
    |> get(~p"/api/todos")
 
  # Assert response is in Spanish...
end

Check response headers with get_resp_header/2:

test "sets cache headers", %{conn: conn} do
  conn = get(conn, ~p"/api/todos")
 
  assert get_resp_header(conn, "cache-control") == ["max-age=3600"]
end

Best Practices for Controller Tests

Test the Contract, Not the Implementation

Controller tests should verify the HTTP interface contract:

  • Correct status codes
  • Correct response structure
  • Correct headers

Don’t duplicate context tests. If Todos.create_todo/1 has edge case tests in the context test file, you don’t need to repeat them at the controller level.

Use Factories Consistently

Controller tests need data just like context tests. Use your factories:

test "lists all todos", %{conn: conn} do
  insert(:todo, title: "First")
  insert(:todo, title: "Second")
 
  conn = get(conn, ~p"/api/todos")
  # ...
end

Group by Endpoint

Organize tests by HTTP endpoint, not by behavior:

# Good: grouped by endpoint
describe "GET /api/todos (index)" do
describe "GET /api/todos/:id (show)" do
describe "POST /api/todos (create)" do
 
# Less clear: grouped by scenario
describe "happy paths" do
describe "error cases" do

Name Tests with HTTP Context

Include the HTTP method and path in test names:

# Clear what's being tested
test "GET /api/todos returns empty list when no todos exist"
 
# Less context
test "returns empty list"

Bringing It All Together

Part 7 demonstrates how all previous patterns integrate:

  • Part 1 (ExUnit): Test structure, describe blocks, assertions
  • Part 2 (Ecto.Sandbox): Database isolation via setup_sandbox/1
  • Part 3 (Factories): insert(:todo) and insert(:user) for test data
  • Part 4 (Mox): Could mock external services called by controllers
  • Part 5 (Adapters): Could stub storage/clock in controller tests
  • Part 6 (Helpers): with_todo/1 setup function, imported helpers

The test file is surprisingly readable because each pattern does one thing well, and they compose cleanly.

Summary: The Complete Testing Toolkit

Congratulations! You’ve completed the 7-part Elixir testing patterns series. Here’s what you’ve learned:

PartPatternKey Concept
1ExUnit BasicsTest structure, assertions, pattern matching
2Ecto.SandboxDatabase isolation, async vs sync tests
3Factory PatternsExMachina, Faker, build vs insert
4Mox MockingBehaviors, expect, verify_on_exit!
5Adapter PatternStub implementations, config injection
6Test HelpersStubHelper, AssertHelper, SetupHelper
7ControllersConnCase, JSON API testing, auth helpers

These patterns scale from solo projects to large teams. They’re used in production codebases handling millions of requests. The key is consistency—pick patterns that work for your team and apply them throughout the codebase.

The complete code is available in the ex-test repository. Each Pull Request corresponds to a blog post:

Happy testing!


Series Navigation

Previous: Part 6 - Centralized Test Helpers

All Parts

  1. ExUnit Fundamentals
  2. Database Isolation with Ecto.Sandbox
  3. Test Data with ExMachina
  4. Mocking with Mox
  5. Adapter Pattern and Stubs
  6. Centralized Test Helpers
  7. Phoenix Controller Testing (You are here)

Resources