Phoenix.ConnTest: Testing Phoenix Controllers and Endpoints

Phoenix.ConnTest: Testing Phoenix Controllers and Endpoints

Phoenix applications expose their behavior through HTTP endpoints. Testing these endpoints — verifying correct status codes, response bodies, redirects, and authentication — is essential for confident Phoenix development. Phoenix.ConnTest provides exactly this capability, letting you exercise your controllers without starting an actual HTTP server.

What Phoenix.ConnTest Does

Phoenix.ConnTest builds on Plug.Test and gives you a conn-based testing interface. You simulate HTTP requests by building a Plug.Conn struct, passing it through your router, and asserting on the resulting conn.

This is faster than spinning up a real HTTP server and more integrated than unit testing controllers in isolation — you test the full plug pipeline: router, plugs, controller, and view.

Setting Up

Phoenix generates a ConnCase for you in test/support/conn_case.ex:

defmodule MyAppWeb.ConnCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      import Plug.Conn
      import Phoenix.ConnTest
      import MyAppWeb.Router.Helpers

      @endpoint MyAppWeb.Endpoint
    end
  end

  setup tags do
    :ok = Ecto.Adapters.SQL.Sandbox.checkout(MyApp.Repo)

    unless tags[:async] do
      Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, {:shared, self()})
    end

    %{conn: build_conn()}
  end
end

Your test modules use this case:

defmodule MyAppWeb.UserControllerTest do
  use MyAppWeb.ConnCase
  # ...
end

Making HTTP Requests

The request helpers follow HTTP verbs:

# GET request
conn = get(conn, ~p"/users")
conn = get(conn, ~p"/users/#{user.id}")

# POST with body
conn = post(conn, ~p"/users", %{user: %{name: "Alice", email: "alice@example.com"}})

# PUT/PATCH
conn = put(conn, ~p"/users/#{user.id}", %{user: %{name: "Alice Updated"}})
conn = patch(conn, ~p"/users/#{user.id}", %{user: %{name: "Alice Updated"}})

# DELETE
conn = delete(conn, ~p"/users/#{user.id}")

The ~p sigil (Phoenix 1.7+) is a verified route helper — it fails at compile time if the route doesn't exist.

Asserting on Responses

After making a request, assert on the resulting conn:

test "lists all users", %{conn: conn} do
  conn = get(conn, ~p"/users")
  assert html_response(conn, 200) =~ "Users"
end

test "shows a user", %{conn: conn} do
  user = create_user()
  conn = get(conn, ~p"/users/#{user.id}")
  assert html_response(conn, 200) =~ user.name
end

test "redirects after creation", %{conn: conn} do
  conn = post(conn, ~p"/users", %{user: valid_attrs()})
  assert redirected_to(conn) == ~p"/users"
end

test "returns 404 for unknown user", %{conn: conn} do
  conn = get(conn, ~p"/users/9999")
  assert html_response(conn, 404)
end

Testing JSON APIs

For API endpoints, use json_response/2:

defmodule MyAppWeb.Api.UserControllerTest do
  use MyAppWeb.ConnCase

  test "creates user and returns JSON", %{conn: conn} do
    conn = post(conn, ~p"/api/users", %{
      name: "Alice",
      email: "alice@example.com"
    })

    assert %{"id" => id, "name" => "Alice"} = json_response(conn, 201)
    assert is_integer(id)
  end

  test "returns validation errors as JSON", %{conn: conn} do
    conn = post(conn, ~p"/api/users", %{name: ""})
    assert %{"errors" => errors} = json_response(conn, 422)
    assert errors["email"] == ["can't be blank"]
  end

  test "lists users as JSON", %{conn: conn} do
    insert_list(3, :user)
    conn = get(conn, ~p"/api/users")
    users = json_response(conn, 200)
    assert length(users) == 3
  end
end

Testing Authentication

Most applications require authentication. The typical pattern is to build an authenticated conn in a helper:

defmodule MyAppWeb.ConnCase do
  # ... existing code ...

  def log_in_user(conn, user) do
    token = MyApp.Accounts.generate_user_session_token(user)
    conn
    |> Phoenix.ConnTest.init_test_session(%{})
    |> Plug.Conn.put_session(:user_token, token)
  end
end

Use it in tests:

defmodule MyAppWeb.DashboardControllerTest do
  use MyAppWeb.ConnCase

  describe "GET /dashboard" do
    test "requires authentication", %{conn: conn} do
      conn = get(conn, ~p"/dashboard")
      assert redirected_to(conn) == ~p"/login"
    end

    test "shows dashboard to authenticated user", %{conn: conn} do
      user = create_user()
      conn = conn |> log_in_user(user) |> get(~p"/dashboard")
      assert html_response(conn, 200) =~ "Dashboard"
    end
  end
end

For token-based API auth:

defp auth_conn(conn, user) do
  token = MyApp.Accounts.create_api_token(user)
  put_req_header(conn, "authorization", "Bearer #{token}")
end

test "returns data for authenticated request", %{conn: conn} do
  user = create_user()
  conn = conn |> auth_conn(user) |> get(~p"/api/profile")
  assert %{"email" => _} = json_response(conn, 200)
end

Testing Flash Messages

Flash messages signal outcomes to users. Test them directly:

test "shows success flash after creating user", %{conn: conn} do
  conn = post(conn, ~p"/users", %{user: valid_attrs()})
  assert get_flash(conn, :info) == "User created successfully."
end

test "shows error flash on failure", %{conn: conn} do
  conn = post(conn, ~p"/users", %{user: invalid_attrs()})
  assert html_response(conn, 200) =~ "Oops, something went wrong"
end

Testing File Uploads

For multipart form submissions with file uploads:

test "uploads avatar", %{conn: conn} do
  user = create_user()

  upload = %Plug.Upload{
    path: "test/fixtures/avatar.png",
    filename: "avatar.png",
    content_type: "image/png"
  }

  conn =
    conn
    |> log_in_user(user)
    |> put(~p"/users/#{user.id}", %{user: %{avatar: upload}})

  assert redirected_to(conn) == ~p"/users/#{user.id}"
end

Reusing Conn State Between Requests

A fresh conn is given to each test. To simulate a multi-step flow (login, then visit page), chain requests:

test "login and access protected page", %{conn: conn} do
  user = create_user(password: "secret")

  # Login
  conn = post(conn, ~p"/login", %{email: user.email, password: "secret"})
  assert redirected_to(conn) == ~p"/dashboard"

  # Follow redirect with same conn (preserves session)
  conn = get(conn, ~p"/dashboard")
  assert html_response(conn, 200) =~ "Welcome"
end

End-to-End Complement

Controller tests verify your Phoenix logic layer. For validating real browser behavior — JavaScript interactions, LiveView updates, visual layout — HelpMeTest provides browser-based testing that runs your full front-end stack. Controller tests catch backend logic errors early; browser tests catch integration issues users would actually encounter.

Summary

Phoenix.ConnTest gives you a fast, expressive way to test Phoenix controllers:

  • Use get, post, put, patch, delete to simulate requests
  • Assert with html_response, json_response, redirected_to
  • Build authenticated conns with helper functions
  • Test flash messages, uploads, and multi-step flows
  • Run tests async with Ecto's SQL sandbox

Controller tests in Phoenix are deliberately fast — no browser, no real HTTP server. That speed lets you run the full suite often without waiting, catching regressions early in the development cycle.

Read more