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/todosreach 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
endKey things happening here:
@endpoint: Tells Phoenix.ConnTest which endpoint to dispatch requests touse ExTestWeb, :verified_routes: Enables the~psigil for type-safe routesimport Phoenix.ConnTest: Brings inget/2,post/3,json_response/2, etc.- 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
endNotice the patterns:
use ExTestWeb.ConnCase, async: true: Enables parallel test execution- Module attributes for test data: Just like in context tests
%{conn: conn}in test signature: Receives the connection from setupget(conn, ~p"/api/todos"): Makes a GET request using verified routesjson_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
endThree tests cover the index action:
- Happy path: Multiple todos are returned
- Empty case: No todos returns empty array (not null or error)
- 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
endThe 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
endCreate tests verify:
- Status 201: Created, not 200
- Persistence: The todo actually exists in the database
- Location header: REST convention for new resources
- 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}
endThe 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
endDelete 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
endThe 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
endThis 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
endNow 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
endThese 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:
- Asserts the content-type is
application/json - Asserts the status code matches
- 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...
endCheck 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"]
endBest 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")
# ...
endGroup 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" doName 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)andinsert(: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/1setup 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:
| Part | Pattern | Key Concept |
|---|---|---|
| 1 | ExUnit Basics | Test structure, assertions, pattern matching |
| 2 | Ecto.Sandbox | Database isolation, async vs sync tests |
| 3 | Factory Patterns | ExMachina, Faker, build vs insert |
| 4 | Mox Mocking | Behaviors, expect, verify_on_exit! |
| 5 | Adapter Pattern | Stub implementations, config injection |
| 6 | Test Helpers | StubHelper, AssertHelper, SetupHelper |
| 7 | Controllers | ConnCase, 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:
- PR #2: ExUnit basics
- PR #3: Ecto.Sandbox
- PR #4: Factory patterns
- PR #5: Mox mocking
- PR #6: Adapter pattern
- PR #7: Test helpers
- PR #8: Controller testing
Happy testing!
Series Navigation
Previous: Part 6 - Centralized Test Helpers
All Parts
- ExUnit Fundamentals
- Database Isolation with Ecto.Sandbox
- Test Data with ExMachina
- Mocking with Mox
- Adapter Pattern and Stubs
- Centralized Test Helpers
- Phoenix Controller Testing (You are here)
