Testing Phoenix Applications with ExUnit: Controllers, Channels, and LiveView

Testing Phoenix Applications with ExUnit: Controllers, Channels, and LiveView

Phoenix applications are testable by design. The framework ships with first-class testing support built on ExUnit, Elixir's built-in test framework. Controllers, channels, LiveView components, and database interactions all have purpose-built test helpers. This guide covers the full picture.

Project Structure

A freshly generated Phoenix project already has a test directory laid out for you:

test/
  my_app/
    accounts/
      user_test.exs
  my_app_web/
    controllers/
      page_controller_test.exs
    channels/
      user_socket_test.exs
  support/
    conn_case.ex
    channel_case.ex
    data_case.ex
  test_helper.exs

The three case modules (ConnCase, ChannelCase, DataCase) are the foundation of Phoenix testing. Each wraps ExUnit with domain-specific helpers.

ConnCase: Testing Controllers

ConnCase sets up an HTTP connection struct and provides request helpers:

defmodule MyAppWeb.PageControllerTest do
  use MyAppWeb.ConnCase

  describe "GET /" do
    test "returns 200", %{conn: conn} do
      conn = get(conn, ~p"/")
      assert html_response(conn, 200) =~ "Welcome"
    end
  end

  describe "POST /users" do
    test "creates user with valid params", %{conn: conn} do
      params = %{user: %{email: "test@example.com", password: "secret123"}}
      conn = post(conn, ~p"/users", params)
      assert redirected_to(conn) == ~p"/dashboard"
    end

    test "renders errors with invalid params", %{conn: conn} do
      conn = post(conn, ~p"/users", %{user: %{email: "bad"}})
      assert html_response(conn, 422) =~ "can't be blank"
    end
  end
end

The ~p sigil is Phoenix's verified routes — it catches broken routes at compile time.

JSON API Testing

For API controllers, use json_response/2:

defmodule MyAppWeb.UserControllerTest do
  use MyAppWeb.ConnCase

  setup %{conn: conn} do
    {:ok, conn: put_req_header(conn, "accept", "application/json")}
  end

  test "lists users", %{conn: conn} do
    insert(:user, email: "alice@example.com")
    conn = get(conn, ~p"/api/users")
    body = json_response(conn, 200)
    assert length(body["data"]) == 1
    assert hd(body["data"])["email"] == "alice@example.com"
  end
end

Authentication in Controller Tests

Most apps require an authenticated user. Extract this into a helper in ConnCase:

# test/support/conn_case.ex
defmodule MyAppWeb.ConnCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      import Plug.Conn
      import Phoenix.ConnTest
      import MyAppWeb.ConnCase

      alias MyAppWeb.Router.Helpers, as: Routes
    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

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

  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

Then use it in tests:

describe "authenticated routes" do
  setup %{conn: conn} do
    user = insert(:user)
    conn = log_in_user(conn, user)
    {:ok, conn: conn, user: user}
  end

  test "GET /dashboard returns 200", %{conn: conn} do
    conn = get(conn, ~p"/dashboard")
    assert html_response(conn, 200) =~ "Dashboard"
  end
end

DataCase: Testing Contexts and Schemas

DataCase is for testing business logic that doesn't touch HTTP:

defmodule MyApp.AccountsTest do
  use MyApp.DataCase

  alias MyApp.Accounts

  describe "create_user/1" do
    test "creates user with valid attrs" do
      attrs = %{email: "test@example.com", password: "secret123"}
      assert {:ok, user} = Accounts.create_user(attrs)
      assert user.email == "test@example.com"
    end

    test "returns error with duplicate email" do
      insert(:user, email: "taken@example.com")
      attrs = %{email: "taken@example.com", password: "secret123"}
      assert {:error, changeset} = Accounts.create_user(attrs)
      assert "has already been taken" in errors_on(changeset).email
    end
  end
end

DataCase wraps each test in a transaction that rolls back after the test — no database cleanup needed.

The errors_on/1 helper (generated by Phoenix) converts changeset errors to a plain map:

def errors_on(changeset) do
  Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
    Regex.replace(~r"%{(\w+)}", message, fn _, key ->
      opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
    end)
  end)
end

ChannelCase: Testing Phoenix Channels

WebSocket channels need ChannelCase:

defmodule MyAppWeb.RoomChannelTest do
  use MyAppWeb.ChannelCase

  setup do
    {:ok, _, socket} =
      MyAppWeb.UserSocket
      |> socket("user_id", %{some: :assign})
      |> subscribe_and_join(MyAppWeb.RoomChannel, "room:lobby")

    %{socket: socket}
  end

  test "joins room successfully", %{socket: socket} do
    assert socket.topic == "room:lobby"
  end

  test "handles incoming message", %{socket: socket} do
    push(socket, "new_msg", %{"body" => "hello"})
    assert_broadcast "new_msg", %{"body" => "hello"}
  end

  test "shuts down on disconnect", %{socket: socket} do
    Process.monitor(socket.channel_pid)
    close(socket)
    assert_receive {:DOWN, _, _, _, _}
  end
end

Key assertions for channels:

  • assert_push/2 — message pushed to the current socket
  • assert_broadcast/2 — message broadcast to all subscribers
  • assert_reply/3 — reply to a push event
  • refute_push/2 — assert no message was pushed

LiveView Testing

Phoenix LiveView has its own test module:

defmodule MyAppWeb.CounterLiveTest do
  use MyAppWeb.ConnCase, async: true
  import Phoenix.LiveViewTest

  test "renders initial count", %{conn: conn} do
    {:ok, view, html} = live(conn, ~p"/counter")
    assert html =~ "Count: 0"
  end

  test "increments count on click", %{conn: conn} do
    {:ok, view, _html} = live(conn, ~p"/counter")
    
    view |> element("button", "Increment") |> render_click()
    
    assert render(view) =~ "Count: 1"
  end

  test "handles form submission", %{conn: conn} do
    {:ok, view, _html} = live(conn, ~p"/search")
    
    view
    |> form("#search-form", %{query: "elixir"})
    |> render_submit()
    
    assert render(view) =~ "Results for: elixir"
  end
end

Testing LiveView Components

Isolated component tests:

defmodule MyAppWeb.ButtonComponentTest do
  use MyAppWeb.ConnCase, async: true
  import Phoenix.LiveViewTest

  test "renders with label" do
    html = render_component(MyAppWeb.ButtonComponent, %{label: "Click me", disabled: false})
    assert html =~ "Click me"
    refute html =~ "disabled"
  end

  test "renders disabled state" do
    html = render_component(MyAppWeb.ButtonComponent, %{label: "Nope", disabled: true})
    assert html =~ "disabled"
  end
end

Factory Setup with ExMachina

Manual fixtures get tedious. Use ExMachina for test factories:

# test/support/factory.ex
defmodule MyApp.Factory do
  use ExMachina.Ecto, repo: MyApp.Repo

  def user_factory do
    %MyApp.Accounts.User{
      email: sequence(:email, &"user-#{&1}@example.com"),
      hashed_password: Bcrypt.hash_pwd_salt("password123"),
      confirmed_at: DateTime.utc_now()
    }
  end

  def post_factory do
    %MyApp.Blog.Post{
      title: sequence(:title, &"Post #{&1}"),
      body: "Some content",
      author: build(:user)
    }
  end
end

Add to test support:

# test/test_helper.exs
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual)
# test/support/data_case.ex (add import)
import MyApp.Factory

Testing Plug Middleware

Test custom plugs in isolation:

defmodule MyAppWeb.RateLimitPlugTest do
  use MyAppWeb.ConnCase, async: true

  alias MyAppWeb.RateLimitPlug

  test "allows request under limit" do
    conn =
      build_conn()
      |> put_req_header("x-forwarded-for", "1.2.3.4")
      |> RateLimitPlug.call(RateLimitPlug.init([]))

    refute conn.halted
  end

  test "blocks request over limit" do
    ip = "5.6.7.8"
    
    # Exhaust the rate limit
    for _ <- 1..100 do
      build_conn()
      |> put_req_header("x-forwarded-for", ip)
      |> RateLimitPlug.call(RateLimitPlug.init([]))
    end

    conn =
      build_conn()
      |> put_req_header("x-forwarded-for", ip)
      |> RateLimitPlug.call(RateLimitPlug.init([]))

    assert conn.halted
    assert conn.status == 429
  end
end

Async Tests

ExUnit supports concurrent test execution within a file:

defmodule MyAppWeb.StaticPageTest do
  use MyAppWeb.ConnCase, async: true

  # Each test in this module runs concurrently with other async tests
  test "home page", %{conn: conn} do
    conn = get(conn, ~p"/")
    assert html_response(conn, 200)
  end
end

Important: Only use async: true when tests don't share mutable state. Database tests using Ecto.Adapters.SQL.Sandbox work in async mode because each connection gets its own sandbox transaction.

Running Phoenix Tests

# All tests
mix test

# Specific file
mix test test/my_app_web/controllers/user_controller_test.exs

# Specific line
mix test test/my_app_web/controllers/user_controller_test.exs:42

# Watch mode (with mix_test_watch)
mix test.watch

# With coverage
mix test --cover

# Only tagged tests
mix test --only integration

Common Patterns

Reset Sandbox Between Tests

If you hit database ownership issues, make sure DataCase or ConnCase calls Sandbox.checkout/1 in setup.

Testing Email Delivery

Use Swoosh's test adapter:

# config/test.exs
config :my_app, MyApp.Mailer, adapter: Swoosh.Adapters.Test

# In tests
import Swoosh.TestAssertions

test "sends welcome email on registration" do
  create_user(%{email: "new@example.com"})
  assert_email_sent(to: "new@example.com", subject: "Welcome!")
end

Mocking External Services

Use Mox for HTTP clients:

# In config/test.exs
config :my_app, :http_client, MyApp.MockHTTPClient

# Define mock
Mox.defmock(MyApp.MockHTTPClient, for: MyApp.HTTPClientBehaviour)

# In test
test "fetches user from external API" do
  expect(MyApp.MockHTTPClient, :get, fn _url ->
    {:ok, %{status: 200, body: ~s({"id": 1, "name": "Alice"})}}
  end)

  assert {:ok, user} = MyApp.ExternalAPI.fetch_user(1)
  assert user.name == "Alice"
end

Summary

Phoenix testing with ExUnit covers the full stack:

  • ConnCase for HTTP layer (controllers, plugs, routing)
  • DataCase for business logic and Ecto interactions
  • ChannelCase for WebSocket channels
  • Phoenix.LiveViewTest for LiveView components and pages

The framework makes it easy to test each layer in isolation while giving you tools to do integration-style testing when needed. Run mix test early and often — Phoenix's fast compilation cycle makes it practical to keep the test loop tight.

Read more

Start now free