Testing
Need robust testing for Elixir applications? ExUnit provides powerful testing with async execution, setup callbacks, and rich assertions.
Prerequisites
- Basic Elixir syntax
- Understanding of modules and functions
- Completed Beginner Tutorial
Problem
Building reliable applications requires comprehensive test coverage across unit tests, integration tests, and property-based tests. You need fast test execution, clear failure messages, and patterns for testing asynchronous code, databases, and external services.
Challenges:
- Testing concurrent and asynchronous code
- Managing test data and database state
- Mocking external dependencies
- Writing maintainable test suites
- Ensuring adequate test coverage
Solution
Use ExUnit for comprehensive test coverage with assertions, setup callbacks, async execution, and integration with tools like Mox for mocking and StreamData for property-based testing.
How It Works
1. Basic Tests
defmodule MathTest do
use ExUnit.Case
describe "addition" do
test "adds two positive numbers" do
assert 1 + 1 == 2
end
test "adds negative numbers" do
assert -1 + -1 == -2
end
test "adds zero" do
assert 5 + 0 == 5
end
end
describe "division" do
test "divides evenly" do
assert div(10, 2) == 5
end
test "raises on division by zero" do
assert_raise ArithmeticError, fn ->
div(1, 0)
end
end
test "raises with specific message" do
assert_raise ArithmeticError, "bad argument in arithmetic expression", fn ->
div(1, 0)
end
end
end
endRun tests:
mix test # All tests
mix test test/math_test.exs # Specific file
mix test test/math_test.exs:10 # Specific line
mix test --trace # Detailed output
mix test --seed 0 # Deterministic order
mix test --max-failures 1 # Stop after first failure2. Setup and Teardown
defmodule UserTest do
use ExUnit.Case
# Runs once before all tests
setup_all do
start_supervised!(MyApp.Repo)
:ok
end
# Runs before each test
setup do
user = %User{name: "Alice", age: 30}
{:ok, user: user}
end
# Context from setup available in test
test "user has name", %{user: user} do
assert user.name == "Alice"
end
test "user has age", %{user: user} do
assert user.age == 30
end
end3. Async Tests
defmodule FastTest do
# Run tests concurrently (no shared state)
use ExUnit.Case, async: true
test "runs in parallel 1" do
assert true
end
test "runs in parallel 2" do
assert 1 + 1 == 2
end
end4. Assertions
defmodule AssertionsTest do
use ExUnit.Case
test "equality assertions" do
assert 1 == 1
refute 1 == 2
end
test "truthiness" do
assert true
assert 1 # Non-nil, non-false
refute false
refute nil
end
test "pattern matching" do
assert {:ok, value} = {:ok, 42}
assert value == 42
end
test "approximate equality" do
assert_in_delta 0.1 + 0.2, 0.3, 0.0001
end
test "exceptions" do
assert_raise RuntimeError, fn ->
raise "error"
end
assert_raise RuntimeError, "specific message", fn ->
raise "specific message"
end
end
test "received messages" do
send(self(), :hello)
assert_received :hello
end
test "will receive messages" do
Task.start(fn ->
Process.sleep(10)
send(self(), :delayed)
end)
assert_receive :delayed, 100 # Wait up to 100ms
end
end5. Testing GenServers
defmodule CounterTest do
use ExUnit.Case
setup do
{:ok, pid} = Counter.start_link(0)
{:ok, counter: pid}
end
test "increments counter", %{counter: pid} do
assert Counter.get(pid) == 0
Counter.increment(pid)
assert Counter.get(pid) == 1
end
test "handles multiple increments", %{counter: pid} do
Counter.increment(pid)
Counter.increment(pid)
Counter.increment(pid)
assert Counter.get(pid) == 3
end
test "resets counter", %{counter: pid} do
Counter.increment(pid)
Counter.reset(pid)
assert Counter.get(pid) == 0
end
end6. Database Testing with Ecto
defmodule MyApp.AccountsTest do
use MyApp.DataCase # Custom test case with Ecto sandbox
alias MyApp.Accounts
alias MyApp.Accounts.User
describe "create_user/1" do
test "creates user with valid data" do
attrs = %{name: "Alice", email: "alice@example.com", age: 30}
assert {:ok, %User{} = user} = Accounts.create_user(attrs)
assert user.name == "Alice"
assert user.email == "alice@example.com"
assert user.age == 30
end
test "returns error with invalid data" do
attrs = %{name: nil, email: "invalid"}
assert {:error, changeset} = Accounts.create_user(attrs)
assert "can't be blank" in errors_on(changeset).name
assert "has invalid format" in errors_on(changeset).email
end
test "enforces unique email" do
attrs = %{name: "Alice", email: "alice@example.com", age: 30}
assert {:ok, _user} = Accounts.create_user(attrs)
assert {:error, changeset} = Accounts.create_user(attrs)
assert "has already been taken" in errors_on(changeset).email
end
end
endCustom DataCase:
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
end7. Mocking with Mox
Add dependency:
{:mox, "~> 1.0", only: :test}Define behavior:
defmodule MyApp.WeatherAPI do
@callback get_temperature(String.t()) :: {:ok, float()} | {:error, term()}
endImplementation:
defmodule MyApp.WeatherAPI.HTTP do
@behaviour MyApp.WeatherAPI
def get_temperature(city) do
# Real HTTP call
HTTPoison.get("https://api.weather.com/#{city}")
end
endMock in test:
Mox.defmock(MyApp.WeatherAPI.Mock, for: MyApp.WeatherAPI)
defmodule MyApp.WeatherTest do
use ExUnit.Case, async: true
import Mox
# Verify mocks are called
setup :verify_on_exit!
test "gets temperature for city" do
expect(MyApp.WeatherAPI.Mock, :get_temperature, fn "London" ->
{:ok, 18.5}
end)
assert {:ok, 18.5} = MyApp.Weather.get_temperature("London")
end
test "handles API errors" do
expect(MyApp.WeatherAPI.Mock, :get_temperature, fn "Mars" ->
{:error, :not_found}
end)
assert {:error, :not_found} = MyApp.Weather.get_temperature("Mars")
end
endConfigure mock in config:
config :my_app, :weather_api, MyApp.WeatherAPI.Mock
config :my_app, :weather_api, MyApp.WeatherAPI.HTTP
@weather_api Application.compile_env(:my_app, :weather_api)
def get_temperature(city) do
@weather_api.get_temperature(city)
end8. Property-Based Testing with StreamData
{:stream_data, "~> 1.0", only: :test}defmodule StringTest do
use ExUnit.Case
use ExUnitProperties
property "reversing a string twice returns original" do
check all str <- string(:alphanumeric) do
assert str == str |> String.reverse() |> String.reverse()
end
end
property "list length is preserved after sorting" do
check all list <- list_of(integer()) do
assert length(list) == length(Enum.sort(list))
end
end
property "addition is commutative" do
check all a <- integer(),
b <- integer() do
assert a + b == b + a
end
end
endCustom generators:
defmodule MyGenerators do
import StreamData
def user do
gen all name <- string(:alphanumeric, min_length: 1),
email <- email(),
age <- integer(18..120) do
%{name: name, email: email, age: age}
end
end
def email do
gen all username <- string(:alphanumeric, min_length: 1),
domain <- string(:alphanumeric, min_length: 1) do
"#{username}@#{domain}.com"
end
end
end
property "user creation" do
check all user <- MyGenerators.user() do
assert {:ok, _} = Accounts.create_user(user)
end
end9. Testing Phoenix Controllers
defmodule MyAppWeb.UserControllerTest do
use MyAppWeb.ConnCase
describe "index" do
test "lists all users", %{conn: conn} do
conn = get(conn, ~p"/users")
assert html_response(conn, 200) =~ "Listing Users"
end
end
describe "create user" do
test "redirects to show when data is valid", %{conn: conn} do
attrs = %{name: "Alice", email: "alice@example.com"}
conn = post(conn, ~p"/users", user: attrs)
assert %{id: id} = redirected_params(conn)
assert redirected_to(conn) == ~p"/users/#{id}"
conn = get(conn, ~p"/users/#{id}")
assert html_response(conn, 200) =~ "Alice"
end
test "renders errors when data is invalid", %{conn: conn} do
conn = post(conn, ~p"/users", user: %{name: nil})
assert html_response(conn, 200) =~ "can't be blank"
end
end
endJSON API testing:
describe "GET /api/users" do
test "returns list of users", %{conn: conn} do
user = insert(:user, name: "Alice")
conn = get(conn, ~p"/api/users")
assert json_response(conn, 200) == %{
"data" => [
%{
"id" => user.id,
"name" => "Alice",
"email" => user.email
}
]
}
end
end10. Testing LiveView
defmodule MyAppWeb.CounterLiveTest do
use MyAppWeb.ConnCase
import Phoenix.LiveViewTest
test "renders initial count", %{conn: conn} do
{:ok, view, html} = live(conn, ~p"/counter")
assert html =~ "Count: 0"
end
test "increments count on button click", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/counter")
# Click increment button
assert view
|> element("button", "Increment")
|> render_click() =~ "Count: 1"
# Click again
assert view
|> element("button", "Increment")
|> render_click() =~ "Count: 2"
end
test "handles form submission", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/new")
# Submit form
assert view
|> form("#user-form", user: %{name: "Alice", email: "alice@example.com"})
|> render_submit()
assert_redirect(view, ~p"/users")
end
endVariations
Test Factories with ExMachina
{:ex_machina, "~> 2.7", only: :test}
defmodule MyApp.Factory do
use ExMachina.Ecto, repo: MyApp.Repo
def user_factory do
%MyApp.Accounts.User{
name: sequence(:name, &"User #{&1}"),
email: sequence(:email, &"user#{&1}@example.com"),
age: 30
}
end
def admin_factory do
struct!(
user_factory(),
%{role: :admin}
)
end
end
user = insert(:user)
admin = insert(:admin)
users = insert_list(3, :user)
params = params_for(:user)Coverage Reports
mix test --cover
mix test --cover --export-coverage default
mix test.coverage
open cover/excoveralls.htmlAdd excoveralls:
{:excoveralls, "~> 0.18", only: :test}
def project do
[
test_coverage: [tool: ExCoveralls],
preferred_cli_env: [
coveralls: :test,
"coveralls.detail": :test,
"coveralls.post": :test,
"coveralls.html": :test
]
]
endAdvanced Patterns
1. Testing Concurrent Code
test "handles concurrent requests" do
{:ok, pid} = Server.start_link()
# Spawn multiple processes
tasks = for i <- 1..100 do
Task.async(fn ->
Server.increment(pid)
end)
end
# Wait for all to complete
Task.await_many(tasks)
assert Server.get(pid) == 100
end2. Testing with Real Time
test "expires cache after TTL" do
Cache.put(:key, :value, ttl: 100) # 100ms
assert Cache.get(:key) == {:ok, :value}
Process.sleep(150)
assert Cache.get(:key) == {:error, :not_found}
end3. Tagging Tests
@tag :slow
test "expensive operation" do
# Long-running test
end
@tag :integration
test "external API call" do
# Integration test
endUse Cases
Unit Tests:
- Pure functions
- Business logic
- Data transformations
Integration Tests:
- Database operations
- External APIs
- Full request/response cycle
Property-Based Tests:
- Edge cases
- Input validation
- Algorithm correctness
Troubleshooting
Tests Hang
@tag timeout: 1000 # milliseconds
test "completes quickly" do
# ...
endDatabase Issues
mix ecto.reset
config :my_app, MyApp.Repo,
pool: Ecto.Adapters.SQL.SandboxFlaky Tests
assert_receive :message, 100 # Wait up to 100ms
Process.sleep(10)Best Practices
Test behavior, not implementation: Focus on what, not how
Use descriptive test names:
test "creates user when all required fields are provided"Arrange-Act-Assert pattern:
test "increments counter" do # Arrange {:ok, pid} = Counter.start_link(0) # Act Counter.increment(pid) # Assert assert Counter.get(pid) == 1 endOne assertion per test (generally): Makes failures clearer
Use setup for common test data: DRY principle
Test edge cases: Nil, empty, negative, maximum values
Mock external dependencies: Tests should be fast and deterministic
Run tests in CI: Catch issues before production
Common Pitfalls
- Shared state in async tests: Use async: false if needed
- Not cleaning up processes: Use start_supervised!
- Testing implementation details: Brittle tests
- Slow tests: Mock expensive operations
- Insufficient coverage: Aim for >80%