Advanced ExUnit: Tags, Doctests, Async Tests, and Custom Assertions

Advanced ExUnit: Tags, Doctests, Async Tests, and Custom Assertions

ExUnit is Elixir's built-in test framework. Most guides cover the basics — assert, refute, describe. But ExUnit has powerful features that separate good test suites from great ones: tags for selective execution, doctests that keep documentation accurate, async execution for faster runs, and custom assertions that make failures meaningful.

This guide covers these advanced features with practical patterns for real Elixir applications.

Test Tags

Tags let you categorize tests and run subsets of your suite — critical for large codebases and CI pipelines.

Defining Tags

defmodule MyApp.UserTest do
  use ExUnit.Case

  @tag :integration
  test "connects to real database" do
    # ...
  end

  @tag :slow
  test "processes large batch" do
    # ...
  end

  @tag integration: true, slow: true
  test "full export pipeline" do
    # ...
  end

  @tag :pending
  test "feature not yet implemented" do
    # This will still fail if it runs — :pending doesn't skip by default
  end
end

Filtering by Tag

# Run only integration tests
mix <span class="hljs-built_in">test --only integration

<span class="hljs-comment"># Exclude slow tests
mix <span class="hljs-built_in">test --exclude slow

<span class="hljs-comment"># Combine filters
mix <span class="hljs-built_in">test --only integration --exclude slow

<span class="hljs-comment"># Run tests with specific tag value
mix <span class="hljs-built_in">test --only integration:<span class="hljs-literal">true

Configuring in test_helper.exs

# test/test_helper.exs
ExUnit.start()

# Skip integration tests by default (only run with --include integration)
ExUnit.configure(exclude: [:integration])

# Skip tests tagged :wip
ExUnit.configure(exclude: [:wip])

With exclude: [:integration] in config, integration tests only run when explicitly included:

mix test --include integration

This pattern keeps CI fast by default while allowing full runs when needed.

Module-Level Tags

Apply tags to an entire module with @moduletag:

defmodule MyApp.PaymentIntegrationTest do
  use ExUnit.Case

  @moduletag :integration
  @moduletag timeout: 60_000  # override timeout for all tests

  test "charges card successfully" do
    # ...
  end

  test "handles declined card" do
    # ...
  end
end

Doctests

Doctests extract iex> examples from @doc attributes and run them as tests. They keep documentation accurate — if the code changes and the docs don't, the test fails.

Writing Doctests

defmodule MyApp.StringUtils do
  @doc """
  Truncates a string to the given length, appending "..." if truncated.

  ## Examples

      iex> MyApp.StringUtils.truncate("Hello, World!", 5)
      "Hello..."

      iex> MyApp.StringUtils.truncate("Hi", 10)
      "Hi"

      iex> MyApp.StringUtils.truncate("", 5)
      ""

  """
  def truncate(string, max_length) when byte_size(string) <= max_length, do: string
  def truncate(string, max_length) do
    String.slice(string, 0, max_length) <> "..."
  end
end

Enabling Doctests

defmodule MyApp.StringUtilsTest do
  use ExUnit.Case, async: true
  doctest MyApp.StringUtils
end

doctest/1 generates one test per iex> example block. A "block" is a contiguous sequence of iex> and ...> lines that produces a single result.

Multi-Line Doctests

@doc """
Builds a user map from keyword options.

## Examples

    iex> MyApp.UserBuilder.build(name: "Alice", role: :admin)
    %{name: "Alice", role: :admin, active: true}

    iex> opts = [name: "Bob", role: :member]
    ...> MyApp.UserBuilder.build(opts)
    %{name: "Bob", role: :member, active: true}

"""

Doctest Options

# Run only specific examples by line number
doctest MyApp.StringUtils, only: [truncate: 2]

# Exclude specific examples
doctest MyApp.StringUtils, except: [truncate: 2]

# Import module into doctest scope
doctest MyApp.StringUtils, import: true

Testing Exceptions in Doctests

@doc """
Parses an integer, raising on invalid input.

## Examples

    iex> MyApp.Parser.parse_int!("42")
    42

    iex> MyApp.Parser.parse_int!("abc")
    ** (ArgumentError) invalid integer: "abc"

"""

The ** (ExceptionModule) message format matches exception doctests.

Async Tests

By default, ExUnit runs test modules sequentially. Adding async: true runs modules concurrently — a significant speedup for large suites.

defmodule MyApp.UserTest do
  use ExUnit.Case, async: true

  test "normalizes email" do
    assert User.normalize_email("ALICE@EXAMPLE.COM") == "alice@example.com"
  end
end

When to use async: true:

  • Pure function tests with no shared state
  • Tests that use Ecto.Adapters.SQL.Sandbox (it handles concurrent DB access)
  • Tests using in-memory data structures

When NOT to use async: true:

  • Tests that write to shared files or directories
  • Tests that rely on global process state (Application.put_env, ETS tables)
  • Tests using external services without isolation

Async with Ecto

Ecto's SQL sandbox allows concurrent database tests:

# test/test_helper.exs
Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual)

# 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
end

Tests using MyApp.DataCase can safely use async: true — each test gets an isolated transaction that rolls back after the test.

defmodule MyApp.UserRepoTest do
  use MyApp.DataCase, async: true  # concurrent DB tests!

  test "creates user" do
    {:ok, user} = MyApp.Users.create_user(%{email: "alice@example.com"})
    assert user.id
  end
end

Custom Assertions

ExUnit's assert with patterns is powerful, but custom assertion functions provide better failure messages for domain-specific checks.

Basic Custom Assertion

defmodule MyApp.Assertions do
  import ExUnit.Assertions

  def assert_valid_changeset(%Ecto.Changeset{} = changeset) do
    assert changeset.valid?,
      "Expected changeset to be valid, but got errors: #{inspect(changeset.errors)}"
  end

  def assert_invalid_changeset(%Ecto.Changeset{} = changeset, field) do
    refute changeset.valid?,
      "Expected changeset to be invalid on #{field}"
    assert Keyword.has_key?(changeset.errors, field),
      "Expected error on #{field}, got errors: #{inspect(changeset.errors)}"
  end

  def assert_email_sent(to: email) do
    assert_received {:email, %{to: ^email}},
      "Expected email sent to #{email}, but none was received"
  end
end

Using Custom Assertions

defmodule MyApp.UserTest do
  use ExUnit.Case
  import MyApp.Assertions

  test "validates email format" do
    changeset = User.changeset(%User{}, %{email: "not-an-email"})
    assert_invalid_changeset(changeset, :email)
  end

  test "accepts valid email" do
    changeset = User.changeset(%User{}, %{email: "alice@example.com", name: "Alice"})
    assert_valid_changeset(changeset)
  end
end

Pattern-Based Custom Assertions

ExUnit's assert_receive with pattern matching is already powerful:

test "publishes event on order completion" do
  :ok = OrderService.complete_order(order_id: 42)

  assert_receive {:event, :order_completed, %{order_id: 42, total: _}}, 1000
end

test "sends correct notification type" do
  NotificationService.notify_user(user_id: 1, type: :welcome)

  assert_receive {:notification, user_id: 1, type: :welcome, channel: channel}
  assert channel in [:email, :push]
end

ExUnit.Callbacks for Setup

defmodule MyApp.PaymentTest do
  use ExUnit.Case

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

  setup_all do
    # Runs once before all tests in this module
    {:ok, config: Application.get_env(:my_app, :payment)}
  end

  test "charges card", %{user: user} do
    assert {:ok, _} = PaymentService.charge(user, amount: 100)
  end
end

Test Callbacks and On Exit

setup do
  original_env = Application.get_env(:my_app, :feature_flags)

  on_exit(fn ->
    Application.put_env(:my_app, :feature_flags, original_env)
  end)

  Application.put_env(:my_app, :feature_flags, %{new_feature: true})
  :ok
end

on_exit runs after each test, even on failure — perfect for cleanup.

ExUnit.CaseTemplate

Share setup across multiple test modules:

defmodule MyApp.ApiCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      use MyApp.DataCase
      import MyApp.ApiCase
      import Plug.Conn
      import Phoenix.ConnTest
      @endpoint MyApp.Endpoint
    end
  end

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

  def authenticate(conn, user) do
    token = MyApp.Auth.generate_token(user)
    put_req_header(conn, "authorization", "Bearer #{token}")
  end
end

Use it in controller tests:

defmodule MyAppWeb.UserControllerTest do
  use MyApp.ApiCase

  test "GET /users/:id", %{conn: conn} do
    user = Factory.insert(:user)
    authed_conn = authenticate(conn, user)

    response = get(authed_conn, ~p"/api/users/#{user.id}")
    assert response.status == 200
  end
end

Summary

Advanced ExUnit features scale with your application. Tags separate fast unit tests from slow integration tests and enable targeted CI pipelines. Doctests keep examples in sync with implementation. Async execution cuts wall-clock test time dramatically. Custom assertions provide domain-meaningful failure messages that reduce debugging time.

Master these patterns and your Elixir test suite becomes both faster to run and easier to maintain. Complement unit testing with HelpMeTest for continuous production monitoring — catching the failures that only appear under real traffic and real infrastructure.

Read more