ExUnit Testing Guide: Writing and Running Elixir Tests

ExUnit Testing Guide: Writing and Running Elixir Tests

Elixir ships with a built-in testing framework called ExUnit. Unlike many languages where testing frameworks are third-party add-ons, ExUnit is part of the Elixir standard library and is the default choice for testing Elixir applications. This guide covers everything you need to write effective tests from day one.

Setting Up ExUnit

In any Mix project, ExUnit is available without any additional dependencies. You start it in your test/test_helper.exs file:

ExUnit.start()

That single line is enough to get tests running. When you run mix test, Mix automatically:

  1. Compiles your project
  2. Loads test/test_helper.exs
  3. Discovers all files matching test/**/*_test.exs
  4. Runs all discovered tests

Writing Your First Test

Test modules use ExUnit.Case:

defmodule MyApp.CalculatorTest do
  use ExUnit.Case

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

  test "subtracts numbers" do
    assert MyApp.Calculator.subtract(10, 4) == 6
  end
end

The test macro takes a name string and a block. Inside the block, you write assertions using assert, refute, and other helpers.

Core Assertions

ExUnit's assertion macros give helpful error messages when tests fail:

# Basic equality
assert result == expected

# Inequality
refute result == unexpected

# Pattern matching
assert {:ok, value} = some_function()

# Exceptions
assert_raise ArgumentError, fn ->
  bad_function_call()
end

# Comparison with messages
assert length(list) > 0, "Expected list to have items"

The assert macro is smart — it introspects the expression and shows you both sides when an equality check fails, making debugging much faster.

Group related tests using describe blocks:

defmodule MyApp.UserTest do
  use ExUnit.Case

  describe "create/1" do
    test "creates a user with valid attrs" do
      assert {:ok, user} = MyApp.User.create(%{name: "Alice", email: "alice@example.com"})
      assert user.name == "Alice"
    end

    test "returns error with missing email" do
      assert {:error, changeset} = MyApp.User.create(%{name: "Bob"})
      assert "can't be blank" in errors_on(changeset).email
    end
  end

  describe "find/1" do
    test "returns user when found" do
      # test body
    end

    test "returns nil when not found" do
      # test body
    end
  end
end

describe blocks don't add any runtime overhead — they're purely organizational.

Setup Callbacks

Use setup to share common state across tests:

defmodule MyApp.OrderTest do
  use ExUnit.Case

  setup do
    user = create_user()
    product = create_product(price: 9.99)
    %{user: user, product: product}
  end

  test "creates order", %{user: user, product: product} do
    assert {:ok, order} = MyApp.Orders.create(user, product)
    assert order.status == :pending
  end

  test "calculates total", %{user: user, product: product} do
    {:ok, order} = MyApp.Orders.create(user, product)
    assert order.total == Decimal.new("9.99")
  end
end

The map returned from setup gets merged into the test context. You access it via pattern matching in the test arguments.

Use setup_all when setup should run once for all tests in a module rather than before each test.

Async Tests

One of ExUnit's best features is built-in support for parallel test execution:

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

  test "processes data" do
    # This runs in parallel with other async tests
    assert MyApp.Process.run(data()) == expected_result()
  end
end

Set async: true for test modules that don't share state or write to a database. For database tests with Ecto, use Ecto.Adapters.SQL.Sandbox to run tests in transactions, which also allows async execution.

Running Tests

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

<span class="hljs-comment"># Run a specific file
mix <span class="hljs-built_in">test <span class="hljs-built_in">test/my_app/calculator_test.exs

<span class="hljs-comment"># Run a specific test by line number
mix <span class="hljs-built_in">test <span class="hljs-built_in">test/my_app/calculator_test.exs:15

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

<span class="hljs-comment"># Run tests excluding a tag
mix <span class="hljs-built_in">test --exclude slow

<span class="hljs-comment"># Show detailed output
mix <span class="hljs-built_in">test --trace

<span class="hljs-comment"># Re-run only failed tests
mix <span class="hljs-built_in">test --failed

Tagging Tests

Tags let you selectively run or skip groups of tests:

@tag :integration
test "calls external API" do
  # ...
end

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

@tag :skip
test "work in progress" do
  # ...
end

Configure in test_helper.exs:

ExUnit.start()
ExUnit.configure(exclude: [:integration, :slow])

Run only integration tests:

mix test --only integration

Doctest

ExUnit can extract and run tests from documentation examples:

defmodule MyApp.Math do
  @doc """
  Computes the square of a number.

  ## Examples

      iex> MyApp.Math.square(4)
      16

      iex> MyApp.Math.square(-3)
      9
  """
  def square(n), do: n * n
end

defmodule MyApp.MathTest do
  use ExUnit.Case
  doctest MyApp.Math
end

Doctests ensure your documentation stays accurate as code evolves — a failing doctest means your example is wrong.

Testing with HelpMeTest

ExUnit handles unit tests well, but end-to-end testing of Phoenix applications benefits from a higher-level approach. HelpMeTest lets you write tests in plain English using Robot Framework:

*** Test Cases ***
User Registration Flow
    Go To    https://yourapp.com/register
    Fill In  Email     test@example.com
    Fill In  Password  secretpassword
    Click    Register
    Page Should Contain    Welcome! Check your email.

These tests run in real browsers, verifying the full stack including your Phoenix routes, controllers, and LiveView components. HelpMeTest runs them continuously with 24/7 monitoring, so you catch regressions before users do.

Best Practices

Keep tests independent. Each test should set up its own state and not depend on other tests running first.

Test behavior, not implementation. Test what a function does, not how it does it. This lets you refactor internals without breaking tests.

Use meaningful test names. "returns error when email is missing" is better than "test 2". Future you will thank present you.

Prefer assert {:ok, result} = function() over assert function() == {:ok, expected} — the former pattern matches and gives you the value to make further assertions.

Keep setup minimal. Only create data that the test actually uses. Excessive setup makes tests slow and hard to understand.

ExUnit's simplicity is its strength. A consistent, readable test suite in Elixir is genuinely achievable, and the tooling makes it easy to maintain as your application grows.

Read more