6 min read
On this page

ExUnit Fundamentals

ExUnit is part of the standard library. There's nothing to install. mix new creates a test/ folder with a working test setup, and mix test runs everything. That accessibility is a big part of why Elixir projects tend to have real test coverage — there's no "should we use Jest or Vitest" debate, no config sprawl, no plugins fighting each other. You write tests, they run.

The other reason is async: true. ExUnit was designed from day one to run tests in parallel processes, and most tests can opt in. On any modern machine, an Elixir test suite runs through a few hundred tests in a couple of seconds. That changes how you write code — you stop avoiding tests because they're slow.

The File Layout

A typical project has:

lib/
  my_app.ex
  my_app/
    accounts.ex
test/
  test_helper.exs
  my_app/
    accounts_test.exs

test/test_helper.exs runs once before the suite. The default contents:

ExUnit.start()

That's it. Add to it when you need to: start sandboxes, configure mocks, wire up test data factories.

A test file looks like:

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

  alias MyApp.Accounts

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

The convention is one test file per module under test, with the same path. lib/my_app/accounts.ex gets test/my_app/accounts_test.exs. Mix discovers files matching test/**/*_test.exs.

use ExUnit.Case

use ExUnit.Case brings the test macros into scope: test, setup, setup_all, describe, the assertions. It also registers the module with ExUnit so mix test finds it.

async: true is the option you'll set most often. It tells ExUnit "this module is safe to run in parallel with other async modules." That means no shared global state — no writing to files in a known location, no ETS tables read by other tests, no GenServers started with global names.

For database tests, async: true works because Ecto's SQL sandbox gives each test process its own database connection wrapped in a rolled-back transaction. Tests can hammer the database in parallel without seeing each other's data.

use ExUnit.Case, async: true
use MyApp.DataCase  # sets up the sandbox

When async isn't safe — say, a test that mocks a global Application config or talks to a real external service — leave it off.

Assertions

The basics:

assert 1 + 1 == 2
assert user.email =~ "@"
refute user.admin
assert_raise ArgumentError, "bad value", fn -> dangerous_op() end
assert_in_delta 0.1 + 0.2, 0.3, 0.001

ExUnit's assert is a macro that introspects what you're asserting. When assert user.email == "alice@example.com" fails, the failure message tells you what user.email actually was — not just "assertion failed." This is the single biggest quality-of-life feature of the framework.

Assertion with == failed
code:  assert user.email == "alice@example.com"
left:  "alice@old.com"
right: "alice@example.com"

assert_raise checks that a function raises a specific exception. Pass a regex or string for the message, or omit it to match any message.

assert_receive and refute_receive check the current process's mailbox — useful when testing GenServers or anything that sends messages.

test "broadcasts on save" do
  Phoenix.PubSub.subscribe(MyApp.PubSub, "users")
  {:ok, user} = Accounts.create_user(%{email: "alice@example.com"})
  assert_receive {:user_created, ^user}, 500
end

The ^user pin means "match exactly this value." The 500 is the timeout in milliseconds; without it, ExUnit waits 100ms by default. For tests touching async code, bump this.

Pattern Matching in Tests

Because Elixir loves pattern matching, ExUnit lets you assert on shapes:

assert {:ok, %User{email: "alice@example.com", admin: false}} =
         Accounts.create_user(%{email: "alice@example.com"})

Note this is =, not ==. It's a match: the right side must conform to the left's pattern. If it doesn't, you get a clear error about what didn't match. If it does, the matched bindings (user, in assert {:ok, user} = ...) are available for the rest of the test.

This pattern reads cleanly and gives you variables to assert on further:

test "creates a user with hashed password" do
  assert {:ok, user} = Accounts.create_user(%{email: "alice@example.com", password: "hunter2"})
  assert user.password_hash != nil
  assert user.password_hash != "hunter2"
end

setup and setup_all

Tests often need shared setup: a database row, a logged-in user, a started process. setup runs before every test in the module. setup_all runs once per module.

defmodule MyApp.PostsTest do
  use ExUnit.Case, async: true
  use MyApp.DataCase

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

  test "creates a post for a user", %{user: user} do
    assert {:ok, post} = Posts.create_post(user, %{title: "Hello"})
    assert post.user_id == user.id
  end
end

The map you return from setup is merged into the test context. Tests that need it pattern-match on the second argument.

You can pass a list of named setups:

setup [:create_user, :sign_in]

defp create_user(_context), do: %{user: insert!(:user)}
defp sign_in(%{user: user}), do: %{conn: log_in(build_conn(), user)}

setup_all is for expensive one-time setup. The catch is that data created in setup_all doesn't go through the SQL sandbox — it persists across tests in the module. For most database tests, you want setup, not setup_all.

describe Blocks

describe groups related tests. The string becomes part of the test name, which helps when reading failures.

describe "create_user/1" do
  test "with valid data inserts a user" do
    assert {:ok, %User{}} = Accounts.create_user(%{email: "alice@example.com"})
  end

  test "with invalid email returns an error" do
    assert {:error, changeset} = Accounts.create_user(%{email: "no-at-sign"})
    assert "has invalid format" in errors_on(changeset).email
  end
end

describe "delete_user/1" do
  setup do
    %{user: insert!(:user)}
  end

  test "removes the user", %{user: user} do
    assert {:ok, _} = Accounts.delete_user(user)
    refute Repo.get(User, user.id)
  end
end

setup inside a describe only applies to tests in that block. This lets different describes have different setup.

ExUnit.Callbacks: on_exit

If a test starts a process, opens a file, or otherwise creates state that needs cleanup, register an on_exit callback in the setup:

setup do
  pid = start_supervised!(MyServer)
  on_exit(fn -> :ok end)  # not needed if start_supervised handles cleanup
  %{server: pid}
end

start_supervised!/1 is the cleaner pattern — ExUnit owns the started process and shuts it down when the test ends. Use it instead of start_link plus manual cleanup whenever possible.

Tags and Filtering

Tag tests to filter at the command line:

@tag :slow
test "imports a million rows", %{file: f}, do: ...

@tag external: true
test "calls Stripe API", do: ...

Run only fast tests:

mix test --exclude slow
mix test --exclude external

By default, ExUnit excludes nothing. You can configure defaults in test/test_helper.exs:

ExUnit.start(exclude: [:external])

Then mix test --include external opts back in. This is the pattern for tests that hit real services — off by default, on in CI.

Running a Single Test

mix test test/my_app/accounts_test.exs
mix test test/my_app/accounts_test.exs:42  # the test on line 42
mix test --only describe:"create_user/1"

When debugging a specific failure, narrow it down. ExUnit's --failed flag re-runs only tests that failed in the last run, which is essential for fixing a specific bug without waiting for the whole suite.

A Realistic Test File

defmodule MyApp.AccountsTest do
  use ExUnit.Case, async: true
  use MyApp.DataCase

  alias MyApp.Accounts
  alias MyApp.Accounts.User

  describe "create_user/1" do
    test "with valid attrs creates a user" do
      attrs = %{email: "alice@example.com", password: "hunter2hunter2"}
      assert {:ok, %User{} = user} = Accounts.create_user(attrs)
      assert user.email == "alice@example.com"
      refute user.password_hash == "hunter2hunter2"
    end

    test "with missing email returns a changeset error" do
      assert {:error, cs} = Accounts.create_user(%{password: "hunter2hunter2"})
      assert "can't be blank" in errors_on(cs).email
    end

    test "with duplicate email returns a constraint error" do
      attrs = %{email: "alice@example.com", password: "hunter2hunter2"}
      {:ok, _} = Accounts.create_user(attrs)
      assert {:error, cs} = Accounts.create_user(attrs)
      assert "has already been taken" in errors_on(cs).email
    end
  end
end

This is what most of your test suite looks like. Each test sets up minimal data, calls the function under test, and asserts on the result. No mocking, no fixtures files, no factory boilerplate beyond a small insert! helper.

Common Pitfalls

Forgetting async: true and ending up with a slow suite. The default is false because not every test is safe to parallelize, but for typical context tests with the SQL sandbox, async is safe and faster.

Sharing state across tests via setup_all and database operations. The sandbox doesn't roll back setup_all work the same way it rolls back setup work. Use setup for any data that should be fresh per test.

Asserting on internals (private function output, struct fields that should be opaque). Assert on the public behavior. If you can only test it by reaching into internals, the abstraction is wrong.

Using Process.sleep to wait for async work. The test passes locally, fails on CI under load. Use assert_receive with a generous timeout, or wait on a specific signal (a message, a file existence, a database row).

Calling IO.inspect in production tests. It works, but assert already shows you both sides on failure — once you trust assertions, you stop reaching for inspect.

Key Takeaways

ExUnit ships with Elixir, runs tests in parallel processes by default with async: true, and uses pattern-matching assertions that show you exactly what didn't match on failure. Use setup for per-test data, setup_all only for read-only fixtures. Tag tests for filtering external dependencies. Use start_supervised!/1 instead of manual process cleanup. The combination of fast parallel tests and good failure output is what makes Elixir test suites stay healthy as projects grow.