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:
- Compiles your project
- Loads
test/test_helper.exs - Discovers all files matching
test/**/*_test.exs - 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
endThe 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.
Describing Related Tests with describe
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
enddescribe 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
endThe 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
endSet 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 --failedTagging 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
# ...
endConfigure in test_helper.exs:
ExUnit.start()
ExUnit.configure(exclude: [:integration, :slow])Run only integration tests:
mix test --only integrationDoctest
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
endDoctests 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.