Testing

Phoenix testing — controllers, LiveView, channels, and context tests with ExUnit and ConnCase

Phoenix includes a robust testing setup using ExUnit with custom test cases for controllers, LiveViews, and channels.

Test structure

test/
├── test_helper.exs
├── support/
│   ├── conn_case.ex
│   ├── data_case.ex
│   └── channel_case.ex
├── my_app_web/
│   ├── controllers/
│   │   └── post_controller_test.exs
│   ├── live/
│   │   └── post_live_test.exs
│   └── channels/
│       └── room_channel_test.exs
└── my_app/
    └── blog_test.exs      # context tests

Controller tests (ConnCase)

defmodule MyAppWeb.PostControllerTest do
  use MyAppWeb.ConnCase

  describe "GET /posts" do
    test "lists all posts", %{conn: conn} do
      post = BlogFixtures.post_fixture()

      conn = get(conn, ~p"/posts")

      assert html_response(conn, 200) =~ post.title
    end
  end

  describe "POST /posts" do
    test "creates a post and redirects", %{conn: conn} do
      conn = post(conn, ~p"/posts", post: %{title: "Hello", body: "World"})

      assert redirected_to(conn) == ~p"/posts"
    end
  end

  describe "GET /posts/:id" do
    test "shows a post", %{conn: conn} do
      post = BlogFixtures.post_fixture()

      conn = get(conn, ~p"/posts/#{post}")

      assert html_response(conn, 200) =~ post.title
    end
  end
end

ConnCase helpers

Function Description
get(conn, path) GET request
post(conn, path, params) POST request
put(conn, path, params) PUT request
patch(conn, path, params) PATCH request
delete(conn, path) DELETE request
html_response(conn, status) Assert HTML response with status
json_response(conn, status) Assert JSON response with status
redirected_to(conn) Get redirect target URL
response(conn, status) Get response body

Authenticated requests

setup %{conn: conn} do
  user = AccountsFixtures.user_fixture()
  conn = log_in_user(conn, user)
  {:ok, conn: conn, user: user}
end

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

LiveView tests

defmodule MyAppWeb.PostLiveTest do
  use MyAppWeb.ConnCase

  import Phoenix.LiveViewTest

  describe "Index" do
    test "lists all posts", %{conn: conn} do
      post = BlogFixtures.post_fixture()

      {:ok, _index_live, html} = live(conn, ~p"/posts")

      assert html =~ post.title
    end

    test "creates new post", %{conn: conn} do
      {:ok, index_live, _html} = live(conn, ~p"/posts")

      assert index_live |> element("a", "New Post") |> render_click() =~
               "New Post"

      assert_patch(index_live, ~p"/posts/new")

      index_live
      |> form("#post-form", post: %{title: "Test", body: "Body"})
      |> render_submit()

      assert_patch(index_live, ~p"/posts")

      html = render(index_live)
      assert html =~ "Test"
    end

    test "deletes post", %{conn: conn} do
      post = BlogFixtures.post_fixture()
      {:ok, index_live, _html} = live(conn, ~p"/posts")

      assert index_live |> element("#post-#{post.id} a", "Delete") |> render_click()
      refute has_element?(index_live, "#post-#{post.id}")
    end
  end

  describe "Show" do
    test "displays post", %{conn: conn} do
      post = BlogFixtures.post_fixture()

      {:ok, _show_live, html} = live(conn, ~p"/posts/#{post}")

      assert html =~ post.title
    end
  end
end

LiveView test helpers

Function Description
live(conn, path) Connect to a LiveView
render(live_view) Get current HTML
element(lv, selector) Select an element
render_click(element) Click an element
render_submit(form) Submit a form
render_change(form, params) Trigger form change
form(lv, selector, params) Find and fill a form
assert_patch(lv, path) Assert navigation
assert_redirect(lv, path) Assert redirect
has_element?(lv, selector) Check element exists
follow_redirect(conn, lv) Follow a redirect

Context tests (DataCase)

defmodule MyApp.BlogTest do
  use MyApp.DataCase

  describe "posts" do
    test "create_post/1 with valid data creates a post" do
      attrs = %{title: "Test Post", body: "Some body text"}
      assert {:ok, %Post{} = post} = Blog.create_post(attrs)
      assert post.title == "Test Post"
    end

    test "create_post/1 with invalid data returns error changeset" do
      attrs = %{title: nil}
      assert {:error, %Ecto.Changeset{} = changeset} = Blog.create_post(attrs)
      assert "can't be blank" in errors_on(changeset).title
    end

    test "list_posts/0 returns all posts" do
      post = BlogFixtures.post_fixture()
      assert Blog.list_posts() == [post]
    end

    test "update_post/2 with valid data updates the post" do
      post = BlogFixtures.post_fixture()
      attrs = %{title: "Updated"}
      assert {:ok, %Post{} = post} = Blog.update_post(post, attrs)
      assert post.title == "Updated"
    end

    test "delete_post/1 deletes the post" do
      post = BlogFixtures.post_fixture()
      assert {:ok, %Post{}} = Blog.delete_post(post)
      assert_raise Ecto.NoResultsError, fn -> Blog.get_post!(post.id) end
    end
  end
end

Channel tests

defmodule MyAppWeb.RoomChannelTest do
  use MyAppWeb.ChannelCase

  setup do
    user = AccountsFixtures.user_fixture()
    {:ok, _, socket} =
      MyAppWeb.UserSocket
      |> socket("user_socket:#{user.id}", %{user_id: user.id})
      |> subscribe_and_join(MyAppWeb.RoomChannel, "rooms:42")

    {:ok, socket: socket, user: user}
  end

  test "joining a room", %{socket: socket} do
    assert_push "welcome", %{}
  end

  test "new_msg broadcasts to room", %{socket: socket} do
    push(socket, "new_msg", %{"body" => "hello"})
    assert_broadcast "new_msg", %{"body" => "hello"}
  end

  test "broadcasts are pushed to the client", %{socket: socket} do
    broadcast_from!(socket, "new_msg", %{"body" => "system"})
    assert_push "new_msg", %{"body" => "system"}
  end
end

Channel test helpers

Function Description
subscribe_and_join(socket, channel, topic) Join a channel
subscribe_and_join!(socket, channel, topic) Join (raises on error)
push(socket, event, payload) Push a client event
broadcast_from(socket, event, payload) Broadcast (excluding self)
broadcast_from!(socket, event, payload) Broadcast (excluding self, raises)
assert_push(event, payload) Assert client received push
assert_broadcast(event, payload) Assert broadcast was sent
refute_push(event, payload) Refute push was received
refute_broadcast(event, payload) Refute broadcast was sent

Fixtures

Fixtures provide test data factories:

# test/support/fixtures/blog_fixtures.ex
defmodule MyApp.BlogFixtures do
  def post_fixture(attrs \\ %{}) do
    attrs = Enum.into(attrs, %{
      title: "Test Post #{System.unique_integer()}",
      body: "Some body text"
    })

    {:ok, post} = MyApp.Blog.create_post(attrs)
    post
  end

  def unique_user_email, do: "user#{System.unique_integer()}@example.com"

  def user_fixture(attrs \\ %{}) do
    attrs = Enum.into(attrs, %{
      email: unique_user_email(),
      password: "password123"
    })

    {:ok, user} = MyApp.Accounts.register_user(attrs)
    user
  end
end

Running tests

mix test                       # Run all tests
mix test test/web/             # Run a directory
mix test test/web/post_test.exs # Run a file
mix test test/web/post_test.exs:42  # Run a specific line
mix test --only tag_name       # Run tagged tests
mix test --trace                # Verbose output
mix test --max-cases 1          # Serial execution
mix test --slowest 10           # Show 10 slowest tests
MIX_ENV=test mix test           # Explicit test environment