Testing
Master testing in Elixir with ExUnit - write unit tests, integration tests, doctests, and build reliable applications.
ExUnit
Elixir's built-in testing framework.
Basic Test
# test/myapp_test.exs
defmodule MyappTest do
use ExUnit.Case
doctest Myapp
test "the truth" do
assert true
end
test "addition" do
assert 1 + 1 == 2
end
end
Run Tests
# Run all tests
mix test
# Run specific file
mix test test/myapp_test.exs
# Run specific test line
mix test test/myapp_test.exs:12
# Watch mode (with mix_test_watch)
mix test.watch
# Run with coverage
mix test --cover
# Run only failed tests
mix test --failed
# Run with trace (no async)
mix test --trace
Assertions
Basic Assertions
# Equality
assert 1 + 1 == 2
refute 1 + 1 == 3
# Truthy/Falsy
assert true
assert "non-empty string"
refute false
refute nil
# Pattern matching
assert {:ok, value} = {:ok, 42}
assert [1, 2, 3] = [1, 2, 3]
# In collection
assert 2 in [1, 2, 3]
refute 4 in [1, 2, 3]
Advanced Assertions
# assert_raise
assert_raise ArithmeticError, fn ->
1 / 0
end
# assert_raise with message
assert_raise RuntimeError, "boom", fn ->
raise "boom"
end
# catch_throw
assert catch_throw(throw(:value)) == :value
# catch_exit
assert catch_exit(exit(:shutdown)) == :shutdown
# assert_received (for process messages)
send(self(), :hello)
assert_received :hello
# refute_received
refute_received :goodbye
Test Structure
Setup and Teardown
defmodule MyTest do
use ExUnit.Case
setup do
# Runs before each test
user = create_user()
{:ok, user: user}
end
setup do
# Multiple setup blocks run in order
on_exit(fn -> cleanup() end)
:ok
end
test "example", %{user: user} do
assert user.name == "Test User"
end
end
Setup All
defmodule MyTest do
use ExUnit.Case
setup_all do
# Runs once before all tests
{:ok, pid} = start_supervised(MyApp.Server)
{:ok, server: pid}
end
setup %{server: server} do
# Runs before each test
reset_server(server)
:ok
end
end
Tags
defmodule MyTest do
use ExUnit.Case
@tag :slow
test "slow operation" do
# ...
end
@tag :external
@tag timeout: 120_000
test "external API call" do
# ...
end
@tag skip: "Not implemented yet"
test "future feature" do
# ...
end
end
Run tagged tests:
# Run only slow tests
mix test --only slow
# Skip slow tests
mix test --exclude slow
# Multiple tags
mix test --only external --exclude slow
Describe Blocks
defmodule CalculatorTest do
use ExUnit.Case
describe "add/2" do
test "adds positive numbers" do
assert Calculator.add(1, 2) == 3
end
test "adds negative numbers" do
assert Calculator.add(-1, -2) == -3
end
end
describe "subtract/2" do
test "subtracts numbers" do
assert Calculator.subtract(5, 3) == 2
end
end
end
Testing Contexts
Basic Context Test
defmodule MyApp.AccountsTest do
use MyApp.DataCase
alias MyApp.Accounts
describe "users" do
test "list_users/0 returns all users" do
user = user_fixture()
assert Accounts.list_users() == [user]
end
test "get_user!/1 returns user with given id" do
user = user_fixture()
assert Accounts.get_user!(user.id) == user
end
test "create_user/1 with valid data creates user" do
attrs = %{name: "Alice", email: "alice@example.com"}
assert {:ok, user} = Accounts.create_user(attrs)
assert user.name == "Alice"
end
test "create_user/1 with invalid data returns error" do
attrs = %{name: nil, email: "invalid"}
assert {:error, changeset} = Accounts.create_user(attrs)
assert %{name: ["can't be blank"]} = errors_on(changeset)
end
end
defp user_fixture(attrs \\ %{}) do
{:ok, user} =
attrs
|> Enum.into(%{name: "Test User", email: "test@example.com"})
|> Accounts.create_user()
user
end
end
Testing with Ecto
DataCase Setup
# 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
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
end
Async Tests with Sandbox
# Enable sandbox in config/test.exs
config :myapp, MyApp.Repo,
pool: Ecto.Adapters.SQL.Sandbox
# Use async in tests
defmodule MyApp.AccountsTest do
use MyApp.DataCase, async: true
# Tests run in parallel
end
Factory Pattern
# test/support/factory.ex
defmodule MyApp.Factory do
alias MyApp.Repo
def build(:user) do
%MyApp.Accounts.User{
name: "Test User",
email: "user#{System.unique_integer()}@example.com"
}
end
def build(factory_name, attributes) do
factory_name |> build() |> struct!(attributes)
end
def insert!(factory_name, attributes \\ []) do
factory_name |> build(attributes) |> Repo.insert!()
end
end
Usage:
import MyApp.Factory
user = insert!(:user)
user = insert!(:user, name: "Custom Name")
Testing Controllers
ConnCase Setup
# test/support/conn_case.ex
defmodule MyAppWeb.ConnCase do
use ExUnit.CaseTemplate
using do
quote do
import Plug.Conn
import Phoenix.ConnTest
import MyAppWeb.ConnCase
alias MyAppWeb.Router.Helpers, as: Routes
@endpoint MyAppWeb.Endpoint
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, conn: Phoenix.ConnTest.build_conn()}
end
end
Controller Tests
defmodule MyAppWeb.PostControllerTest do
use MyAppWeb.ConnCase
test "GET /posts", %{conn: conn} do
conn = get(conn, Routes.post_path(conn, :index))
assert html_response(conn, 200) =~ "Posts"
end
test "creates post with valid data", %{conn: conn} do
attrs = %{title: "Test Post", body: "Content"}
conn = post(conn, Routes.post_path(conn, :create), post: attrs)
assert redirected_to(conn) =~ Routes.post_path(conn, :index)
assert get_flash(conn, :info) =~ "created"
end
test "renders errors when data is invalid", %{conn: conn} do
attrs = %{title: nil}
conn = post(conn, Routes.post_path(conn, :create), post: attrs)
assert html_response(conn, 200) =~ "New Post"
end
end
JSON API Tests
test "lists all users", %{conn: conn} do
user = insert!(:user)
conn = get(conn, Routes.api_user_path(conn, :index))
assert json_response(conn, 200)["data"] == [
%{"id" => user.id, "name" => user.name, "email" => user.email}
]
end
test "creates user with valid data", %{conn: conn} do
attrs = %{name: "Alice", email: "alice@example.com"}
conn = post(conn, Routes.api_user_path(conn, :create), user: attrs)
assert %{"id" => id} = json_response(conn, 201)["data"]
assert is_integer(id)
end
Authenticated Requests
defmodule MyAppWeb.ConnCase do
# ...
def authenticate(conn, user) do
conn
|> Plug.Test.init_test_session(%{})
|> put_session(:user_id, user.id)
end
end
# In tests
test "authenticated user can create post", %{conn: conn} do
user = insert!(:user)
conn = authenticate(conn, user)
conn = post(conn, Routes.post_path(conn, :create), post: %{title: "Test"})
assert redirected_to(conn) =~ Routes.post_path(conn, :index)
end
Testing Channels
ChannelCase
# test/support/channel_case.ex
defmodule MyAppWeb.ChannelCase do
use ExUnit.CaseTemplate
using do
quote do
import Phoenix.ChannelTest
import MyAppWeb.ChannelCase
@endpoint MyAppWeb.Endpoint
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
Channel Tests
defmodule MyAppWeb.RoomChannelTest do
use MyAppWeb.ChannelCase
setup do
{:ok, _, socket} =
MyAppWeb.UserSocket
|> socket("user_id", %{user_id: 123})
|> subscribe_and_join(MyAppWeb.RoomChannel, "room:lobby")
%{socket: socket}
end
test "ping replies with status ok", %{socket: socket} do
ref = push(socket, "ping", %{"hello" => "there"})
assert_reply ref, :ok, %{"hello" => "there"}
end
test "broadcasts are pushed to the client", %{socket: socket} do
broadcast_from!(socket, "broadcast", %{"some" => "data"})
assert_push "broadcast", %{"some" => "data"}
end
test "shout broadcasts to room:lobby", %{socket: socket} do
push(socket, "shout", %{"hello" => "all"})
assert_broadcast "shout", %{"hello" => "all"}
end
end
Testing LiveView
defmodule MyAppWeb.CounterLiveTest do
use MyAppWeb.ConnCase
import Phoenix.LiveViewTest
test "connected mount", %{conn: conn} do
{:ok, view, html} = live(conn, "/counter")
assert html =~ "Counter: 0"
end
test "increment button increases counter", %{conn: conn} do
{:ok, view, _html} = live(conn, "/counter")
assert view |> element("button", "+") |> render_click() =~ "Counter: 1"
assert view |> element("button", "+") |> render_click() =~ "Counter: 2"
end
test "decrement button decreases counter", %{conn: conn} do
{:ok, view, _html} = live(conn, "/counter")
assert view |> element("button", "-") |> render_click() =~ "Counter: -1"
end
test "form submission", %{conn: conn} do
{:ok, view, _html} = live(conn, "/search")
html = view
|> form("#search-form", search: %{query: "elixir"})
|> render_submit()
assert html =~ "Results"
end
end
Doctests
Test code examples in documentation.
Writing Doctests
defmodule Calculator do
@doc """
Adds two numbers.
## Examples
iex> Calculator.add(1, 2)
3
iex> Calculator.add(-1, 1)
0
"""
def add(a, b), do: a + b
@doc """
Divides two numbers.
## Examples
iex> Calculator.divide(10, 2)
{:ok, 5.0}
iex> Calculator.divide(10, 0)
{:error, :division_by_zero}
"""
def divide(_a, 0), do: {:error, :division_by_zero}
def divide(a, b), do: {:ok, a / b}
end
Running Doctests
defmodule CalculatorTest do
use ExUnit.Case
doctest Calculator
end
Multi-line Doctests
@doc """
Example with multiple lines:
iex> list = [1, 2, 3]
iex> Enum.map(list, &(&1 * 2))
[2, 4, 6]
"""
Mocking and Stubbing
Mox
# mix.exs
{:mox, "~> 1.0", only: :test}
# Define behavior
defmodule MyApp.Weather do
@callback get_temperature(String.t()) :: {:ok, integer()} | {:error, term()}
end
# Define mock
# test/test_helper.exs
Mox.defmock(MyApp.WeatherMock, for: MyApp.Weather)
# In application code
defmodule MyApp.ForecastService do
@weather_api Application.compile_env(:myapp, :weather_api)
def forecast(city) do
@weather_api.get_temperature(city)
end
end
# Config
# config/test.exs
config :myapp, weather_api: MyApp.WeatherMock
# In tests
import Mox
test "forecast returns temperature" do
expect(MyApp.WeatherMock, :get_temperature, fn "NYC" ->
{:ok, 72}
end)
assert ForecastService.forecast("NYC") == {:ok, 72}
end
Bypass
For mocking external HTTP APIs:
# mix.exs
{:bypass, "~> 2.1", only: :test}
# In tests
test "fetches user data from API" do
bypass = Bypass.open()
Bypass.expect_once bypass, "GET", "/users/1", fn conn ->
Plug.Conn.resp(conn, 200, ~s<{"id": 1, "name": "Alice"}>)
end
api_url = "http://localhost:#{bypass.port}"
assert {:ok, user} = APIClient.fetch_user(api_url, 1)
assert user.name == "Alice"
end
Testing Concurrency
Process-based Tests
test "GenServer handles concurrent requests" do
{:ok, pid} = MyServer.start_link([])
tasks = for i <- 1..100 do
Task.async(fn ->
MyServer.increment(pid)
end)
end
Task.await_many(tasks)
assert MyServer.get(pid) == 100
end
Testing Message Passing
test "sends notification" do
pid = self()
spawn(fn ->
send(pid, {:notification, "Hello"})
end)
assert_receive {:notification, "Hello"}, 1000
end
test "does not send when condition is false" do
# Code that shouldn't send message
refute_receive {:notification, _}, 100
end
Property-Based Testing
StreamData
# mix.exs
{:stream_data, "~> 0.5", only: :test}
# In tests
use ExUnitProperties
property "list reversal is symmetric" do
check all list <- list_of(integer()) do
assert Enum.reverse(Enum.reverse(list)) == list
end
end
property "string concatenation" do
check all str1 <- string(:alphanumeric),
str2 <- string(:alphanumeric) do
result = str1 <> str2
assert String.starts_with?(result, str1)
assert String.ends_with?(result, str2)
end
end
Practical Examples
Complete Test Suite
defmodule MyApp.Blog.PostTest do
use MyApp.DataCase, async: true
alias MyApp.Blog
alias MyApp.Blog.Post
describe "list_posts/0" do
test "returns all posts" do
post1 = insert!(:post)
post2 = insert!(:post)
assert Blog.list_posts() == [post2, post1] # Ordered by recent
end
test "returns empty list when no posts" do
assert Blog.list_posts() == []
end
end
describe "get_post!/1" do
test "returns post with given id" do
post = insert!(:post)
assert Blog.get_post!(post.id).id == post.id
end
test "raises when post not found" do
assert_raise Ecto.NoResultsError, fn ->
Blog.get_post!(999)
end
end
end
describe "create_post/1" do
test "creates post with valid attrs" do
attrs = %{title: "Test", body: "Content", user_id: 1}
assert {:ok, post} = Blog.create_post(attrs)
assert post.title == "Test"
assert post.body == "Content"
end
test "returns error with invalid attrs" do
attrs = %{title: nil, body: nil}
assert {:error, changeset} = Blog.create_post(attrs)
assert %{title: ["can't be blank"]} = errors_on(changeset)
end
end
describe "update_post/2" do
test "updates post with valid attrs" do
post = insert!(:post)
attrs = %{title: "Updated"}
assert {:ok, updated} = Blog.update_post(post, attrs)
assert updated.title == "Updated"
end
end
describe "delete_post/1" do
test "deletes the post" do
post = insert!(:post)
assert {:ok, _} = Blog.delete_post(post)
assert_raise Ecto.NoResultsError, fn ->
Blog.get_post!(post.id)
end
end
end
end
Test Coverage
# Run with coverage
mix test --cover
# Output:
# Generating cover results ...
# Percentage | Module
# -----------|-------------------------
# 100.0% | MyApp.Accounts
# 85.7% | MyApp.Blog
# 70.0% | MyAppWeb.PageController
# -----------|-------------------------
# 85.2% | Total
Configure Coverage
# mix.exs
def project do
[
test_coverage: [tool: ExCoveralls],
preferred_cli_env: [
coveralls: :test,
"coveralls.detail": :test,
"coveralls.post": :test,
"coveralls.html": :test
]
]
end
Exercises
Write tests for a Calculator module with add, subtract, multiply, divide
Create a context test suite for a User management module
Write controller tests for a RESTful posts resource
Test a GenServer that manages a counter with concurrent operations
Write property-based tests for a string manipulation function
# Solutions
# 1. Calculator tests
defmodule CalculatorTest do
use ExUnit.Case
doctest Calculator
describe "add/2" do
test "adds positive numbers" do
assert Calculator.add(2, 3) == 5
end
test "adds negative numbers" do
assert Calculator.add(-2, -3) == -5
end
end
describe "divide/2" do
test "divides numbers" do
assert Calculator.divide(10, 2) == {:ok, 5.0}
end
test "returns error for division by zero" do
assert Calculator.divide(10, 0) == {:error, :division_by_zero}
end
end
end
# 2. User context tests
defmodule MyApp.AccountsTest do
use MyApp.DataCase, async: true
alias MyApp.Accounts
describe "create_user/1" do
test "creates user with valid data" do
attrs = %{name: "Alice", email: "alice@test.com", age: 30}
assert {:ok, user} = Accounts.create_user(attrs)
assert user.name == "Alice"
end
test "validates email format" do
attrs = %{name: "Bob", email: "invalid", age: 25}
assert {:error, changeset} = Accounts.create_user(attrs)
assert %{email: ["has invalid format"]} = errors_on(changeset)
end
test "requires unique email" do
insert!(:user, email: "taken@test.com")
attrs = %{name: "Bob", email: "taken@test.com", age: 25}
assert {:error, changeset} = Accounts.create_user(attrs)
assert %{email: ["has already been taken"]} = errors_on(changeset)
end
end
end
# 3. Controller tests
defmodule MyAppWeb.PostControllerTest do
use MyAppWeb.ConnCase
setup do
user = insert!(:user)
%{user: user}
end
describe "index" do
test "lists all posts", %{conn: conn} do
conn = get(conn, Routes.post_path(conn, :index))
assert html_response(conn, 200) =~ "Posts"
end
end
describe "create" do
test "creates post with valid data", %{conn: conn, user: user} do
conn = authenticate(conn, user)
attrs = %{title: "Test", body: "Content"}
conn = post(conn, Routes.post_path(conn, :create), post: attrs)
assert redirected_to(conn) =~ Routes.post_path(conn, :index)
assert get_flash(conn, :info) =~ "created"
end
test "renders errors with invalid data", %{conn: conn, user: user} do
conn = authenticate(conn, user)
conn = post(conn, Routes.post_path(conn, :create), post: %{})
assert html_response(conn, 200) =~ "can't be blank"
end
end
end
# 4. GenServer concurrent tests
defmodule MyApp.CounterTest do
use ExUnit.Case
test "handles concurrent increments correctly" do
{:ok, pid} = MyApp.Counter.start_link(0)
tasks = for _ <- 1..1000 do
Task.async(fn -> MyApp.Counter.increment(pid) end)
end
Task.await_many(tasks)
assert MyApp.Counter.get(pid) == 1000
end
end
# 5. Property-based tests
defmodule StringUtilsTest do
use ExUnit.Case
use ExUnitProperties
property "capitalize first letter" do
check all str <- string(:alphanumeric, min_length: 1) do
result = StringUtils.capitalize_first(str)
assert String.at(result, 0) == String.upcase(String.at(str, 0))
end
end
property "reverse twice returns original" do
check all str <- string(:printable) do
assert str |> String.reverse() |> String.reverse() == str
end
end
end
Next Steps
Continue to 14-best-practices.md to learn Elixir idioms, naming conventions, design patterns, and anti-patterns.