CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/hex-phoenix

Peace of mind from prototype to production - comprehensive web framework for Elixir

Overview
Eval results
Files

testing.mddocs/

Testing Support

Phoenix provides comprehensive testing utilities for controllers, channels, and integration testing. The testing framework is built on ExUnit with specialized helpers for web applications and real-time features.

Controller and Connection Testing

Phoenix.ConnTest provides utilities for testing HTTP requests, responses, and controller actions.

Phoenix.ConnTest

defmodule Phoenix.ConnTest do
  # Connection building
  def build_conn() :: Plug.Conn.t()
  def build_conn(atom, binary, term) :: Plug.Conn.t()

  # HTTP request functions
  def get(Plug.Conn.t(), binary, term) :: Plug.Conn.t()
  def post(Plug.Conn.t(), binary, term, keyword) :: Plug.Conn.t()
  def put(Plug.Conn.t(), binary, term, keyword) :: Plug.Conn.t()
  def patch(Plug.Conn.t(), binary, term, keyword) :: Plug.Conn.t()
  def delete(Plug.Conn.t(), binary, term) :: Plug.Conn.t()
  def options(Plug.Conn.t(), binary, term) :: Plug.Conn.t()
  def head(Plug.Conn.t(), binary, term) :: Plug.Conn.t()
  def trace(Plug.Conn.t(), binary, term) :: Plug.Conn.t()

  # Request dispatch
  def dispatch(Plug.Conn.t(), atom, atom, atom, keyword) :: Plug.Conn.t()

  # Response assertions
  def response(Plug.Conn.t(), atom | integer) :: binary
  def html_response(Plug.Conn.t(), atom | integer) :: binary
  def json_response(Plug.Conn.t(), atom | integer) :: map
  def text_response(Plug.Conn.t(), atom | integer) :: binary

  # Response status
  def response_content_type(Plug.Conn.t(), atom) :: binary
  def redirected_to(Plug.Conn.t(), integer) :: binary
  def redirected_params(Plug.Conn.t()) :: map

  # Assertions
  def assert_error_sent(atom | integer, (() -> any)) :: any
  def assert_redirected_to(Plug.Conn.t(), binary) :: Plug.Conn.t()

  # Flash helpers
  def clear_flash(Plug.Conn.t()) :: Plug.Conn.t()
  def fetch_flash(Plug.Conn.t()) :: Plug.Conn.t()
  def put_flash(Plug.Conn.t(), atom, binary) :: Plug.Conn.t()

  # Session helpers
  def init_test_session(Plug.Conn.t(), map) :: Plug.Conn.t()
  def put_req_header(Plug.Conn.t(), binary, binary) :: Plug.Conn.t()
  def put_req_cookie(Plug.Conn.t(), binary, binary) :: Plug.Conn.t()
  def delete_req_cookie(Plug.Conn.t(), binary, keyword) :: Plug.Conn.t()

  # Route testing
  def bypass_through(Plug.Conn.t(), atom | [atom]) :: Plug.Conn.t()
end

Usage Examples

defmodule MyAppWeb.UserControllerTest do
  use MyAppWeb.ConnCase

  import MyApp.AccountsFixtures

  describe "index" do
    test "lists all users", %{conn: conn} do
      user = user_fixture()

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

      assert html_response(conn, 200) =~ "Users"
      assert response(conn, 200) =~ user.name
    end
  end

  describe "show user" do
    setup [:create_user]

    test "displays user when found", %{conn: conn, user: user} do
      conn = get(conn, ~p"/users/#{user}")

      assert html_response(conn, 200) =~ user.name
      assert html_response(conn, 200) =~ user.email
    end

    test "returns 404 when user not found", %{conn: conn} do
      assert_error_sent 404, fn ->
        get(conn, ~p"/users/999999")
      end
    end
  end

  describe "create user" do
    test "redirects to show when data is valid", %{conn: conn} do
      create_attrs = %{name: "John Doe", email: "john@example.com"}

      conn = post(conn, ~p"/users", user: create_attrs)

      assert %{id: id} = redirected_params(conn)
      assert redirected_to(conn) == ~p"/users/#{id}"

      conn = get(conn, ~p"/users/#{id}")
      assert html_response(conn, 200) =~ "John Doe"
    end

    test "renders errors when data is invalid", %{conn: conn} do
      invalid_attrs = %{name: nil, email: "invalid"}

      conn = post(conn, ~p"/users", user: invalid_attrs)

      assert html_response(conn, 200) =~ "New User"
      assert response(conn, 200) =~ "can't be blank"
      assert response(conn, 200) =~ "has invalid format"
    end
  end

  describe "JSON API" do
    test "creates user and returns JSON", %{conn: conn} do
      create_attrs = %{name: "Jane Doe", email: "jane@example.com"}

      conn =
        conn
        |> put_req_header("accept", "application/json")
        |> post(~p"/api/users", user: create_attrs)

      assert %{"id" => id} = json_response(conn, 201)["data"]

      conn = get(conn, ~p"/api/users/#{id}")
      data = json_response(conn, 200)["data"]

      assert data["name"] == "Jane Doe"
      assert data["email"] == "jane@example.com"
    end
  end

  defp create_user(_) do
    user = user_fixture()
    %{user: user}
  end
end

Channel Testing

Phoenix.ChannelTest provides utilities for testing real-time channel communication.

Phoenix.ChannelTest

defmodule Phoenix.ChannelTest do
  # Socket management
  def connect(atom, map, map) :: {:ok, Phoenix.Socket.t()} | :error
  def close(Phoenix.Socket.t()) :: :ok

  # Channel operations
  def subscribe_and_join(Phoenix.Socket.t(), atom, binary, map) ::
    {:ok, map, Phoenix.Socket.t()} | {:error, map}
  def subscribe_and_join!(Phoenix.Socket.t(), atom, binary, map) ::
    {map, Phoenix.Socket.t()}

  def join(Phoenix.Socket.t(), atom, binary, map) ::
    {:ok, map, Phoenix.Socket.t()} | {:error, map}

  def leave(Phoenix.Socket.t()) :: :ok

  # Message operations
  def push(Phoenix.Socket.t(), binary, map) :: reference
  def broadcast_from!(Phoenix.Socket.t(), binary, map) :: :ok
  def broadcast_from(Phoenix.Socket.t(), binary, map) :: :ok

  # Assertions
  def assert_push(binary, map, timeout \\ 100)
  def assert_push(binary, map, timeout)
  def refute_push(binary, map, timeout \\ 100)

  def assert_reply(reference, atom, map, timeout \\ 100)
  def assert_reply(reference, atom, timeout \\ 100)
  def refute_reply(reference, atom, timeout \\ 100)

  def assert_broadcast(binary, map, timeout \\ 100)
  def refute_broadcast(binary, map, timeout \\ 100)

  # Socket reference
  def socket_ref(Phoenix.Socket.t()) :: reference
end

Usage Examples

defmodule MyAppWeb.RoomChannelTest do
  use MyAppWeb.ChannelCase

  import MyApp.AccountsFixtures

  setup do
    user = user_fixture()
    {:ok, socket} = connect(MyAppWeb.UserSocket, %{user_id: user.id})
    %{socket: socket, user: user}
  end

  describe "joining rooms" do
    test "successful join to public room", %{socket: socket} do
      {:ok, reply, socket} = subscribe_and_join(socket, MyAppWeb.RoomChannel, "room:lobby")

      assert reply == %{}
      assert socket.assigns.room_id == "lobby"
    end

    test "join requires authentication", %{socket: socket} do
      {:ok, socket} = connect(MyAppWeb.UserSocket, %{})

      assert subscribe_and_join(socket, MyAppWeb.RoomChannel, "room:lobby") == {:error, %{}}
    end

    test "cannot join private room without permission", %{socket: socket} do
      {:error, reply} = subscribe_and_join(socket, MyAppWeb.RoomChannel, "room:private")

      assert reply.reason == "unauthorized"
    end
  end

  describe "sending messages" do
    setup %{socket: socket} do
      {:ok, _, socket} = subscribe_and_join(socket, MyAppWeb.RoomChannel, "room:lobby")
      %{socket: socket}
    end

    test "new_message broadcasts to all subscribers", %{socket: socket} do
      push(socket, "new_message", %{"body" => "Hello, World!"})

      assert_broadcast "new_message", %{body: "Hello, World!"}
    end

    test "new_message replies with success", %{socket: socket} do
      ref = push(socket, "new_message", %{"body" => "Hello, World!"})

      assert_reply ref, :ok, %{status: "sent"}
    end

    test "empty message returns error", %{socket: socket} do
      ref = push(socket, "new_message", %{"body" => ""})

      assert_reply ref, :error, %{errors: %{body: "can't be blank"}}
    end
  end

  describe "typing indicators" do
    setup %{socket: socket} do
      {:ok, _, socket} = subscribe_and_join(socket, MyAppWeb.RoomChannel, "room:lobby")
      %{socket: socket}
    end

    test "typing event broadcasts to others but not sender", %{socket: socket, user: user} do
      push(socket, "typing", %{"typing" => true})

      refute_push "typing", %{user: user.name, typing: true}
      assert_broadcast "typing", %{user: user.name, typing: true}
    end
  end

  describe "presence tracking" do
    setup %{socket: socket} do
      {:ok, _, socket} = subscribe_and_join(socket, MyAppWeb.RoomChannel, "room:lobby")
      %{socket: socket}
    end

    test "tracks user presence on join", %{socket: socket, user: user} do
      # Presence is tracked after join
      users = MyApp.Presence.list(socket)

      assert Map.has_key?(users, to_string(user.id))
      assert users[to_string(user.id)].metas == [%{online_at: inspect(System.system_time(:second))}]
    end

    test "removes presence on leave", %{socket: socket, user: user} do
      # Initially present
      users = MyApp.Presence.list(socket)
      assert Map.has_key?(users, to_string(user.id))

      # Leave channel
      leave(socket)

      # No longer present
      users = MyApp.Presence.list("room:lobby")
      refute Map.has_key?(users, to_string(user.id))
    end
  end
end

Integration Testing

Integration tests verify complete workflows across multiple components.

Integration Test Patterns

defmodule MyAppWeb.UserRegistrationTest do
  use MyAppWeb.ConnCase

  import Phoenix.LiveViewTest
  import MyApp.AccountsFixtures

  describe "user registration flow" do
    test "successful registration via HTML form", %{conn: conn} do
      # Visit registration page
      conn = get(conn, ~p"/users/register")
      assert html_response(conn, 200) =~ "Register"

      # Submit registration form
      conn = post(conn, ~p"/users/register", user: %{
        name: "John Doe",
        email: "john@example.com",
        password: "password123"
      })

      # Should redirect to welcome page
      assert redirected_to(conn) == ~p"/"

      # Should be logged in
      conn = get(conn, ~p"/")
      assert html_response(conn, 200) =~ "Welcome, John!"

      # User should exist in database
      user = MyApp.Repo.get_by(MyApp.Accounts.User, email: "john@example.com")
      assert user.name == "John Doe"
      assert Bcrypt.verify_pass("password123", user.password_hash)
    end

    test "registration with invalid data shows errors", %{conn: conn} do
      conn = post(conn, ~p"/users/register", user: %{
        name: "",
        email: "invalid-email",
        password: "123"
      })

      assert html_response(conn, 200) =~ "Register"
      assert response(conn, 200) =~ "can't be blank"
      assert response(conn, 200) =~ "has invalid format"
      assert response(conn, 200) =~ "should be at least 8 character"
    end
  end

  describe "authentication flow" do
    setup do
      user = user_fixture()
      %{user: user}
    end

    test "login and logout flow", %{conn: conn, user: user} do
      # Login
      conn = post(conn, ~p"/users/login", user: %{
        email: user.email,
        password: "password123"
      })

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

      # Should be authenticated
      conn = get(conn, ~p"/users/settings")
      assert html_response(conn, 200) =~ "Settings"

      # Logout
      conn = delete(conn, ~p"/users/logout")
      assert redirected_to(conn) == ~p"/"

      # Should no longer be authenticated
      conn = get(conn, ~p"/users/settings")
      assert redirected_to(conn) == ~p"/users/login"
    end
  end
end

LiveView Testing

Phoenix provides specialized testing for LiveView components.

LiveView Test Helpers

# Example LiveView test
defmodule MyAppWeb.UserLiveTest do
  use MyAppWeb.ConnCase

  import Phoenix.LiveViewTest
  import MyApp.AccountsFixtures

  describe "user management" do
    test "lists users with live updates", %{conn: conn} do
      user = user_fixture()

      {:ok, view, html} = live(conn, ~p"/users")

      assert html =~ "Users"
      assert html =~ user.name

      # Create new user via form
      assert view
             |> form("#user-form", user: %{name: "New User", email: "new@example.com"})
             |> render_submit()

      # Should see new user in list
      assert render(view) =~ "New User"
    end

    test "real-time updates from other processes", %{conn: conn} do
      {:ok, view, _html} = live(conn, ~p"/users")

      # Create user in separate process
      user = user_fixture()

      # Should automatically appear in LiveView
      assert render(view) =~ user.name
    end
  end
end

Test Configuration and Setup

Test Configuration

# config/test.exs
import Config

config :my_app, MyAppWeb.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 4002],
  secret_key_base: "test_secret_key",
  server: false

config :my_app, MyApp.Repo,
  username: "postgres",
  password: "postgres",
  hostname: "localhost",
  database: "my_app_test#{System.get_env("MIX_TEST_PARTITION")}",
  pool: Ecto.Adapters.SQL.Sandbox

config :logger, level: :warning

# Test-specific configuration
config :bcrypt_elixir, :log_rounds, 1  # Fast hashing for tests
config :my_app, :email_backend, MyApp.Email.TestAdapter

Test Case Modules

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

  using do
    quote do
      use MyAppWeb, :verified_routes

      import Plug.Conn
      import Phoenix.ConnTest
      import MyAppWeb.ConnCase

      alias MyAppWeb.Router.Helpers, as: Routes

      @endpoint MyAppWeb.Endpoint
    end
  end

  setup tags do
    MyApp.DataCase.setup_sandbox(tags)
    {:ok, conn: Phoenix.ConnTest.build_conn()}
  end
end

# test/support/channel_case.ex
defmodule MyAppWeb.ChannelCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      import Phoenix.ChannelTest
      import MyAppWeb.ChannelCase

      @endpoint MyAppWeb.Endpoint
    end
  end

  setup tags do
    MyApp.DataCase.setup_sandbox(tags)
    :ok
  end
end

Test Fixtures

# test/support/fixtures/accounts_fixtures.ex
defmodule MyApp.AccountsFixtures do
  @moduledoc """
  Test fixtures for accounts context.
  """

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

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

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

  def admin_fixture(attrs \\ %{}) do
    user_fixture(Map.put(attrs, :role, :admin))
  end

  def extract_user_token(fun) do
    {:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]")
    [_, token | _] = String.split(captured_email.text_body, "[TOKEN]")
    token
  end
end

Testing Patterns and Best Practices

Database Testing

# Async tests for isolated database operations
defmodule MyApp.AccountsTest do
  use MyApp.DataCase, async: true

  test "create_user/1 with valid data creates user" do
    attrs = %{name: "Test User", email: "test@example.com", password: "password123"}

    assert {:ok, user} = MyApp.Accounts.create_user(attrs)
    assert user.name == "Test User"
    assert user.email == "test@example.com"
    assert Bcrypt.verify_pass("password123", user.password_hash)
  end
end

# Synchronous tests for operations that affect shared state
defmodule MyApp.IntegrationTest do
  use MyApp.DataCase, async: false

  test "email sending integration" do
    user = user_fixture()

    MyApp.Accounts.send_welcome_email(user)

    assert_delivered_email MyApp.Email.welcome_email(user)
  end
end

Mocking and Stubbing

# Using Mox for mocking external services
defmodule MyApp.PaymentTest do
  use MyApp.DataCase
  import Mox

  # Set up mocks
  setup :verify_on_exit!

  test "processes payment successfully" do
    MyApp.PaymentGateway.Mock
    |> expect(:charge, fn %{amount: 1000, token: "tok_123"} ->
      {:ok, %{id: "ch_123", status: "succeeded"}}
    end)

    assert {:ok, charge} = MyApp.Payments.process_charge(1000, "tok_123")
    assert charge.id == "ch_123"
    assert charge.status == "succeeded"
  end
end

Install with Tessl CLI

npx tessl i tessl/hex-phoenix

docs

code-generation.md

index.md

presence.md

real-time.md

security.md

testing.md

web-foundation.md

tile.json