Elixir: Testing with ExUnit

ExUnit ships with Elixir, runs tests in parallel by default, and — because data is immutable — tests rarely need the ordering hacks common elsewhere. This lesson covers the everyday test, doctests, process testing, mocks, and property-based testing.

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

  1. Add doctests to a module you wrote earlier and break one example on purpose — read the failure output.
  2. Test a GenServer's :increment and :get calls with async: true, then prove the two tests never interfere.
  3. Write a StreamData property that Frame.decode/1 of encode(frame) always returns the original frames.

Next: IEx, Formatting & Analysis