Testing

Master testing in Elixir with ExUnit - write unit tests, integration tests, doctests, and build reliable applications.

ExUnit

Elixir's built-in testing framework.

Basic Test

# test/myapp_test.exs
defmodule MyappTest do
  use ExUnit.Case
  doctest Myapp

  test "the truth" do
    assert true
  end

  test "addition" do
    assert 1 + 1 == 2
  end
end

Run Tests

# Run all tests
mix test

# Run specific file
mix test test/myapp_test.exs

# Run specific test line
mix test test/myapp_test.exs:12

# Watch mode (with mix_test_watch)
mix test.watch

# Run with coverage
mix test --cover

# Run only failed tests
mix test --failed

# Run with trace (no async)
mix test --trace

Assertions

Basic Assertions

# Equality
assert 1 + 1 == 2
refute 1 + 1 == 3

# Truthy/Falsy
assert true
assert "non-empty string"
refute false
refute nil

# Pattern matching
assert {:ok, value} = {:ok, 42}
assert [1, 2, 3] = [1, 2, 3]

# In collection
assert 2 in [1, 2, 3]
refute 4 in [1, 2, 3]

Advanced Assertions

# assert_raise
assert_raise ArithmeticError, fn ->
  1 / 0
end

# assert_raise with message
assert_raise RuntimeError, "boom", fn ->
  raise "boom"
end

# catch_throw
assert catch_throw(throw(:value)) == :value

# catch_exit
assert catch_exit(exit(:shutdown)) == :shutdown

# assert_received (for process messages)
send(self(), :hello)
assert_received :hello

# refute_received
refute_received :goodbye

Test Structure

Setup and Teardown

defmodule MyTest do
  use ExUnit.Case

  setup do
    # Runs before each test
    user = create_user()
    {:ok, user: user}
  end

  setup do
    # Multiple setup blocks run in order
    on_exit(fn -> cleanup() end)
    :ok
  end

  test "example", %{user: user} do
    assert user.name == "Test User"
  end
end

Setup All

defmodule MyTest do
  use ExUnit.Case

  setup_all do
    # Runs once before all tests
    {:ok, pid} = start_supervised(MyApp.Server)
    {:ok, server: pid}
  end

  setup %{server: server} do
    # Runs before each test
    reset_server(server)
    :ok
  end
end

Tags

defmodule MyTest do
  use ExUnit.Case

  @tag :slow
  test "slow operation" do
    # ...
  end

  @tag :external
  @tag timeout: 120_000
  test "external API call" do
    # ...
  end

  @tag skip: "Not implemented yet"
  test "future feature" do
    # ...
  end
end

Run tagged tests:

# Run only slow tests
mix test --only slow

# Skip slow tests
mix test --exclude slow

# Multiple tags
mix test --only external --exclude slow

Describe Blocks

defmodule CalculatorTest do
  use ExUnit.Case

  describe "add/2" do
    test "adds positive numbers" do
      assert Calculator.add(1, 2) == 3
    end

    test "adds negative numbers" do
      assert Calculator.add(-1, -2) == -3
    end
  end

  describe "subtract/2" do
    test "subtracts numbers" do
      assert Calculator.subtract(5, 3) == 2
    end
  end
end

Testing Contexts

Basic Context Test

defmodule MyApp.AccountsTest do
  use MyApp.DataCase
  alias MyApp.Accounts

  describe "users" do
    test "list_users/0 returns all users" do
      user = user_fixture()
      assert Accounts.list_users() == [user]
    end

    test "get_user!/1 returns user with given id" do
      user = user_fixture()
      assert Accounts.get_user!(user.id) == user
    end

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

    test "create_user/1 with invalid data returns error" do
      attrs = %{name: nil, email: "invalid"}
      assert {:error, changeset} = Accounts.create_user(attrs)
      assert %{name: ["can't be blank"]} = errors_on(changeset)
    end
  end

  defp user_fixture(attrs \\ %{}) do
    {:ok, user} =
      attrs
      |> Enum.into(%{name: "Test User", email: "test@example.com"})
      |> Accounts.create_user()

    user
  end
end

Testing with Ecto

DataCase Setup

# test/support/data_case.ex
defmodule MyApp.DataCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      alias MyApp.Repo
      import Ecto
      import Ecto.Changeset
      import Ecto.Query
      import MyApp.DataCase
    end
  end

  setup tags do
    pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyApp.Repo, shared: not tags[:async])
    on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
    :ok
  end

  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
end

Async Tests with Sandbox

# Enable sandbox in config/test.exs
config :myapp, MyApp.Repo,
  pool: Ecto.Adapters.SQL.Sandbox

# Use async in tests
defmodule MyApp.AccountsTest do
  use MyApp.DataCase, async: true

  # Tests run in parallel
end

Factory Pattern

# test/support/factory.ex
defmodule MyApp.Factory do
  alias MyApp.Repo

  def build(:user) do
    %MyApp.Accounts.User{
      name: "Test User",
      email: "user#{System.unique_integer()}@example.com"
    }
  end

  def build(factory_name, attributes) do
    factory_name |> build() |> struct!(attributes)
  end

  def insert!(factory_name, attributes \\ []) do
    factory_name |> build(attributes) |> Repo.insert!()
  end
end

Usage:

import MyApp.Factory

user = insert!(:user)
user = insert!(:user, name: "Custom Name")

Testing Controllers

ConnCase Setup

# 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

      @endpoint MyAppWeb.Endpoint
    end
  end

  setup tags do
    pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyApp.Repo, shared: not tags[:async])
    on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
    {:ok, conn: Phoenix.ConnTest.build_conn()}
  end
end

Controller Tests

defmodule MyAppWeb.PostControllerTest do
  use MyAppWeb.ConnCase

  test "GET /posts", %{conn: conn} do
    conn = get(conn, Routes.post_path(conn, :index))
    assert html_response(conn, 200) =~ "Posts"
  end

  test "creates post with valid data", %{conn: conn} do
    attrs = %{title: "Test Post", body: "Content"}
    conn = post(conn, Routes.post_path(conn, :create), post: attrs)
    
    assert redirected_to(conn) =~ Routes.post_path(conn, :index)
    assert get_flash(conn, :info) =~ "created"
  end

  test "renders errors when data is invalid", %{conn: conn} do
    attrs = %{title: nil}
    conn = post(conn, Routes.post_path(conn, :create), post: attrs)
    
    assert html_response(conn, 200) =~ "New Post"
  end
end

JSON API Tests

test "lists all users", %{conn: conn} do
  user = insert!(:user)
  conn = get(conn, Routes.api_user_path(conn, :index))
  
  assert json_response(conn, 200)["data"] == [
    %{"id" => user.id, "name" => user.name, "email" => user.email}
  ]
end

test "creates user with valid data", %{conn: conn} do
  attrs = %{name: "Alice", email: "alice@example.com"}
  conn = post(conn, Routes.api_user_path(conn, :create), user: attrs)
  
  assert %{"id" => id} = json_response(conn, 201)["data"]
  assert is_integer(id)
end

Authenticated Requests

defmodule MyAppWeb.ConnCase do
  # ...
  
  def authenticate(conn, user) do
    conn
    |> Plug.Test.init_test_session(%{})
    |> put_session(:user_id, user.id)
  end
end

# In tests
test "authenticated user can create post", %{conn: conn} do
  user = insert!(:user)
  conn = authenticate(conn, user)
  
  conn = post(conn, Routes.post_path(conn, :create), post: %{title: "Test"})
  assert redirected_to(conn) =~ Routes.post_path(conn, :index)
end

Testing Channels

ChannelCase

# 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
    pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyApp.Repo, shared: not tags[:async])
    on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
    :ok
  end
end

Channel Tests

defmodule MyAppWeb.RoomChannelTest do
  use MyAppWeb.ChannelCase

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

    %{socket: socket}
  end

  test "ping replies with status ok", %{socket: socket} do
    ref = push(socket, "ping", %{"hello" => "there"})
    assert_reply ref, :ok, %{"hello" => "there"}
  end

  test "broadcasts are pushed to the client", %{socket: socket} do
    broadcast_from!(socket, "broadcast", %{"some" => "data"})
    assert_push "broadcast", %{"some" => "data"}
  end

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

Testing LiveView

defmodule MyAppWeb.CounterLiveTest do
  use MyAppWeb.ConnCase
  import Phoenix.LiveViewTest

  test "connected mount", %{conn: conn} do
    {:ok, view, html} = live(conn, "/counter")
    assert html =~ "Counter: 0"
  end

  test "increment button increases counter", %{conn: conn} do
    {:ok, view, _html} = live(conn, "/counter")
    
    assert view |> element("button", "+") |> render_click() =~ "Counter: 1"
    assert view |> element("button", "+") |> render_click() =~ "Counter: 2"
  end

  test "decrement button decreases counter", %{conn: conn} do
    {:ok, view, _html} = live(conn, "/counter")
    
    assert view |> element("button", "-") |> render_click() =~ "Counter: -1"
  end

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

Doctests

Test code examples in documentation.

Writing Doctests

defmodule Calculator do
  @doc """
  Adds two numbers.

  ## Examples

      iex> Calculator.add(1, 2)
      3

      iex> Calculator.add(-1, 1)
      0
  """
  def add(a, b), do: a + b

  @doc """
  Divides two numbers.

  ## Examples

      iex> Calculator.divide(10, 2)
      {:ok, 5.0}

      iex> Calculator.divide(10, 0)
      {:error, :division_by_zero}
  """
  def divide(_a, 0), do: {:error, :division_by_zero}
  def divide(a, b), do: {:ok, a / b}
end

Running Doctests

defmodule CalculatorTest do
  use ExUnit.Case
  doctest Calculator
end

Multi-line Doctests

@doc """
Example with multiple lines:

    iex> list = [1, 2, 3]
    iex> Enum.map(list, &(&1 * 2))
    [2, 4, 6]
"""

Mocking and Stubbing

Mox

# mix.exs
{:mox, "~> 1.0", only: :test}

# Define behavior
defmodule MyApp.Weather do
  @callback get_temperature(String.t()) :: {:ok, integer()} | {:error, term()}
end

# Define mock
# test/test_helper.exs
Mox.defmock(MyApp.WeatherMock, for: MyApp.Weather)

# In application code
defmodule MyApp.ForecastService do
  @weather_api Application.compile_env(:myapp, :weather_api)

  def forecast(city) do
    @weather_api.get_temperature(city)
  end
end

# Config
# config/test.exs
config :myapp, weather_api: MyApp.WeatherMock

# In tests
import Mox

test "forecast returns temperature" do
  expect(MyApp.WeatherMock, :get_temperature, fn "NYC" ->
    {:ok, 72}
  end)

  assert ForecastService.forecast("NYC") == {:ok, 72}
end

Bypass

For mocking external HTTP APIs:

# mix.exs
{:bypass, "~> 2.1", only: :test}

# In tests
test "fetches user data from API" do
  bypass = Bypass.open()

  Bypass.expect_once bypass, "GET", "/users/1", fn conn ->
    Plug.Conn.resp(conn, 200, ~s<{"id": 1, "name": "Alice"}>)
  end

  api_url = "http://localhost:#{bypass.port}"
  assert {:ok, user} = APIClient.fetch_user(api_url, 1)
  assert user.name == "Alice"
end

Testing Concurrency

Process-based Tests

test "GenServer handles concurrent requests" do
  {:ok, pid} = MyServer.start_link([])

  tasks = for i <- 1..100 do
    Task.async(fn ->
      MyServer.increment(pid)
    end)
  end

  Task.await_many(tasks)

  assert MyServer.get(pid) == 100
end

Testing Message Passing

test "sends notification" do
  pid = self()

  spawn(fn ->
    send(pid, {:notification, "Hello"})
  end)

  assert_receive {:notification, "Hello"}, 1000
end

test "does not send when condition is false" do
  # Code that shouldn't send message

  refute_receive {:notification, _}, 100
end

Property-Based Testing

StreamData

# mix.exs
{:stream_data, "~> 0.5", only: :test}

# In tests
use ExUnitProperties

property "list reversal is symmetric" do
  check all list <- list_of(integer()) do
    assert Enum.reverse(Enum.reverse(list)) == list
  end
end

property "string concatenation" do
  check all str1 <- string(:alphanumeric),
            str2 <- string(:alphanumeric) do
    result = str1 <> str2
    assert String.starts_with?(result, str1)
    assert String.ends_with?(result, str2)
  end
end

Practical Examples

Complete Test Suite

defmodule MyApp.Blog.PostTest do
  use MyApp.DataCase, async: true
  alias MyApp.Blog
  alias MyApp.Blog.Post

  describe "list_posts/0" do
    test "returns all posts" do
      post1 = insert!(:post)
      post2 = insert!(:post)

      assert Blog.list_posts() == [post2, post1]  # Ordered by recent
    end

    test "returns empty list when no posts" do
      assert Blog.list_posts() == []
    end
  end

  describe "get_post!/1" do
    test "returns post with given id" do
      post = insert!(:post)
      assert Blog.get_post!(post.id).id == post.id
    end

    test "raises when post not found" do
      assert_raise Ecto.NoResultsError, fn ->
        Blog.get_post!(999)
      end
    end
  end

  describe "create_post/1" do
    test "creates post with valid attrs" do
      attrs = %{title: "Test", body: "Content", user_id: 1}
      
      assert {:ok, post} = Blog.create_post(attrs)
      assert post.title == "Test"
      assert post.body == "Content"
    end

    test "returns error with invalid attrs" do
      attrs = %{title: nil, body: nil}
      
      assert {:error, changeset} = Blog.create_post(attrs)
      assert %{title: ["can't be blank"]} = errors_on(changeset)
    end
  end

  describe "update_post/2" do
    test "updates post with valid attrs" do
      post = insert!(:post)
      attrs = %{title: "Updated"}

      assert {:ok, updated} = Blog.update_post(post, attrs)
      assert updated.title == "Updated"
    end
  end

  describe "delete_post/1" do
    test "deletes the post" do
      post = insert!(:post)

      assert {:ok, _} = Blog.delete_post(post)
      assert_raise Ecto.NoResultsError, fn ->
        Blog.get_post!(post.id)
      end
    end
  end
end

Test Coverage

# Run with coverage
mix test --cover

# Output:
# Generating cover results ...
# Percentage | Module
# -----------|-------------------------
#     100.0% | MyApp.Accounts
#      85.7% | MyApp.Blog
#      70.0% | MyAppWeb.PageController
# -----------|-------------------------
#      85.2% | Total

Configure Coverage

# mix.exs
def project do
  [
    test_coverage: [tool: ExCoveralls],
    preferred_cli_env: [
      coveralls: :test,
      "coveralls.detail": :test,
      "coveralls.post": :test,
      "coveralls.html": :test
    ]
  ]
end

Exercises

  1. Write tests for a Calculator module with add, subtract, multiply, divide

  2. Create a context test suite for a User management module

  3. Write controller tests for a RESTful posts resource

  4. Test a GenServer that manages a counter with concurrent operations

  5. Write property-based tests for a string manipulation function

# Solutions

# 1. Calculator tests
defmodule CalculatorTest do
  use ExUnit.Case
  doctest Calculator

  describe "add/2" do
    test "adds positive numbers" do
      assert Calculator.add(2, 3) == 5
    end

    test "adds negative numbers" do
      assert Calculator.add(-2, -3) == -5
    end
  end

  describe "divide/2" do
    test "divides numbers" do
      assert Calculator.divide(10, 2) == {:ok, 5.0}
    end

    test "returns error for division by zero" do
      assert Calculator.divide(10, 0) == {:error, :division_by_zero}
    end
  end
end

# 2. User context tests
defmodule MyApp.AccountsTest do
  use MyApp.DataCase, async: true
  alias MyApp.Accounts

  describe "create_user/1" do
    test "creates user with valid data" do
      attrs = %{name: "Alice", email: "alice@test.com", age: 30}
      assert {:ok, user} = Accounts.create_user(attrs)
      assert user.name == "Alice"
    end

    test "validates email format" do
      attrs = %{name: "Bob", email: "invalid", age: 25}
      assert {:error, changeset} = Accounts.create_user(attrs)
      assert %{email: ["has invalid format"]} = errors_on(changeset)
    end

    test "requires unique email" do
      insert!(:user, email: "taken@test.com")
      attrs = %{name: "Bob", email: "taken@test.com", age: 25}
      
      assert {:error, changeset} = Accounts.create_user(attrs)
      assert %{email: ["has already been taken"]} = errors_on(changeset)
    end
  end
end

# 3. Controller tests
defmodule MyAppWeb.PostControllerTest do
  use MyAppWeb.ConnCase

  setup do
    user = insert!(:user)
    %{user: user}
  end

  describe "index" do
    test "lists all posts", %{conn: conn} do
      conn = get(conn, Routes.post_path(conn, :index))
      assert html_response(conn, 200) =~ "Posts"
    end
  end

  describe "create" do
    test "creates post with valid data", %{conn: conn, user: user} do
      conn = authenticate(conn, user)
      attrs = %{title: "Test", body: "Content"}
      
      conn = post(conn, Routes.post_path(conn, :create), post: attrs)
      
      assert redirected_to(conn) =~ Routes.post_path(conn, :index)
      assert get_flash(conn, :info) =~ "created"
    end

    test "renders errors with invalid data", %{conn: conn, user: user} do
      conn = authenticate(conn, user)
      conn = post(conn, Routes.post_path(conn, :create), post: %{})
      
      assert html_response(conn, 200) =~ "can&#39;t be blank"
    end
  end
end

# 4. GenServer concurrent tests
defmodule MyApp.CounterTest do
  use ExUnit.Case

  test "handles concurrent increments correctly" do
    {:ok, pid} = MyApp.Counter.start_link(0)

    tasks = for _ <- 1..1000 do
      Task.async(fn -> MyApp.Counter.increment(pid) end)
    end

    Task.await_many(tasks)

    assert MyApp.Counter.get(pid) == 1000
  end
end

# 5. Property-based tests
defmodule StringUtilsTest do
  use ExUnit.Case
  use ExUnitProperties

  property "capitalize first letter" do
    check all str <- string(:alphanumeric, min_length: 1) do
      result = StringUtils.capitalize_first(str)
      assert String.at(result, 0) == String.upcase(String.at(str, 0))
    end
  end

  property "reverse twice returns original" do
    check all str <- string(:printable) do
      assert str |> String.reverse() |> String.reverse() == str
    end
  end
end

Next Steps

Continue to 14-best-practices.md to learn Elixir idioms, naming conventions, design patterns, and anti-patterns.