Elixir: Testing with ExUnit
The Everyday Test
defmodule StatsTest do
# async: true lets this test file run concurrently with others.
# Safe here because tests share no mutable state.
use ExUnit.Case, async: true
# setup runs before EACH test; its return value lands in `context`.
setup do
{:ok, numbers: [2, 4, 6]}
end
test "summarize reports total and average", %{numbers: nums} do
assert Stats.summarize(nums) =~ "total=12"
end
test "empty input degrades gracefully" do
assert Stats.rms([]) == 0.0 # edge case, stated explicitly
end
test "invalid input raises" do
# assert_raise converts a crash into a passing test.
assert_raise ArgumentError, ~r/insufficient/, fn ->
Withdraw.run(10, 20)
end
end
end
Run with mix test; filter with mix test test/stats_test.exs:12 for one
line, or mix test --failed to re-run only failures. Coverage via
mix test --cover.
Doctests
The ## Examples blocks from @doc become tests — documentation that
cannot rot.
defmodule Greet do
@doc """
## Examples
iex> Greet.hello("Ada")
"Hello, Ada!"
"""
def hello(name), do: "Hello, #{name}!"
end
defmodule GreetTest do
use ExUnit.Case, async: true
doctest Greet # runs every example in the docs
end
Testing Processes
GenServers and other processes are tested through their public API — start one in
setup, interact, assert on responses. Because each test process is isolated, flaky
sleeps are rarely needed: calls are synchronous by design.
defmodule CounterTest do
use ExUnit.Case, async: true
setup do
# start_link! fails the test immediately if the server cannot start.
{:ok, pid} = Counter.start_link(name: nil) # unique per test
{:ok, counter: pid}
end
test "increments are serialized", %{counter: counter} do
assert GenServer.call(counter, :increment) == 1
assert GenServer.call(counter, :increment) == 2
end
end
Mocks with Mox
Mox (by the Dashbit team) mocks behaviours, not modules, and keeps them explicit: the test defines expectations, and anything not expected fails. The production code depends on an interface (behaviour), never on a concrete adapter.
# test_helper.exs — define the mock once:
# Mox.defmock(EmailMock, for: Notifier)
# Application.put_env(:my_app, :notifier, EmailMock)
defmodule SignupTest do
use ExUnit.Case, async: true
import Mox
setup :verify_on_exit! # unmet expectations fail the test
test "signup notifies the user" do
# Expect exactly one call with these arguments.
expect(EmailMock, :send_message, fn "a@x.io", %{text: t} ->
assert t =~ "welcome"
:ok
end)
Signup.run(%{email: "a@x.io"})
end
end
Property-Based Testing
StreamData generates hundreds of inputs from a specification and checks properties that must hold for all of them — the counterpart of hypothesis/quickcheck in other ecosystems.
defmodule CodecTest do
use ExUnit.Case, async: true
use ExUnitProperties
property "encode then decode returns the original term" do
check all map <- map_of(atom(:alphanumeric), integer()) do
encoded = JSON.encode!(map)
assert JSON.decode!(encoded) == map # roundtrip property
end
end
end
What to Test
- Public functions of each context — including the
{:error, reason}paths, not only successes. - Edge cases explicitly: empty lists, zero, unicode strings, missing keys.
- Process contracts: what a caller sees when the server dies mid-call (covered in GenServer).
- Doctests for pure helpers — free documentation checks.
- Not private functions: test behaviour through the public API; if a private function is complex enough to need its own test, it wants to be a module.
Practice
- Add doctests to a module you wrote earlier and break one example on purpose — read the failure output.
- Test a GenServer's
:incrementand:getcalls withasync: true, then prove the two tests never interfere. - Write a StreamData property that
Frame.decode/1ofencode(frame)always returns the original frames.