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.exsThe 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
endThe ~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
endAuthentication 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
endThen 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
endDataCase: 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
endDataCase 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)
endChannelCase: 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
endKey assertions for channels:
assert_push/2— message pushed to the current socketassert_broadcast/2— message broadcast to all subscribersassert_reply/3— reply to a push eventrefute_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
endTesting 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
endFactory 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
endAdd 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.FactoryTesting 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
endAsync 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
endImportant: 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 integrationCommon 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!")
endMocking 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"
endSummary
Phoenix testing with ExUnit covers the full stack:
ConnCasefor HTTP layer (controllers, plugs, routing)DataCasefor business logic and Ecto interactionsChannelCasefor WebSocket channelsPhoenix.LiveViewTestfor 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.