🎓 A1Learner
🔍
0 of 46 lessons completed

Elixir Test Doubles

A test double is a small stand-in module that acts like a real module during a test. The test uses the stand-in so the real module's slow or awkward work never runs.

A fake stands in for the real module

The behaviour promises one function, and the fake provides it. The sender takes the module as an argument, so the test passes the fake.

ExUnit.start(seed: 0, auto_run: false, formatters: [])
defmodule Messenger do
  @callback deliver(String.t()) :: String.t()
end
defmodule FakeMessenger do
  @behaviour Messenger
  def deliver(name), do: "Hello, #{name}"
end
defmodule Sender do
  def greet(mod, name), do: mod.deliver(name)
end
defmodule SenderTest do
  use ExUnit.Case

  test "greets through the fake" do
    assert Sender.greet(FakeMessenger, "Ana") == "Hello, Ana"
  end
end
result = ExUnit.run()
IO.puts("passed: #{result.total - result.failures}/#{result.total}")

A fake returns a fixed answer

The fake clock always answers the same day. The reporter uses that answer, and the test checks the full sentence.

ExUnit.start(seed: 0, auto_run: false, formatters: [])
defmodule Clock do
  @callback today() :: String.t()
end
defmodule FakeClock do
  @behaviour Clock
  def today(), do: "Monday"
end
defmodule Reporter do
  def report(mod), do: "Today is #{mod.today()}"
end
defmodule ReporterTest do
  use ExUnit.Case

  test "reports the fixed day" do
    assert Reporter.report(FakeClock) == "Today is Monday"
  end
end
result = ExUnit.run()
IO.puts("passed: #{result.total - result.failures}/#{result.total}")

Two fakes can share one behaviour

Both fakes follow the same behaviour but answer differently. Each test passes a different fake to the same function.

ExUnit.start(seed: 0, auto_run: false, formatters: [])
defmodule Notifier do
  @callback send_message(String.t()) :: String.t()
end
defmodule QuietNotifier do
  @behaviour Notifier
  def send_message(_msg), do: "quiet"
end
defmodule LoudNotifier do
  @behaviour Notifier
  def send_message(_msg), do: "LOUD"
end
defmodule Announcer do
  def announce(mod, msg), do: mod.send_message(msg)
end
defmodule AnnouncerTest do
  use ExUnit.Case

  test "announces quietly" do
    assert Announcer.announce(QuietNotifier, "hi") == "quiet"
  end

  test "announces loudly" do
    assert Announcer.announce(LoudNotifier, "hi") == "LOUD"
  end
end
result = ExUnit.run()
IO.puts("passed: #{result.total - result.failures}/#{result.total}")

Example

ExUnit.start(seed: 0, auto_run: false, formatters: [])
defmodule Store do
  @callback fetch(String.t()) :: String.t()
end
defmodule FakeStore do
  @behaviour Store
  def fetch("Ana"), do: "Ana's card"
  def fetch(_name), do: "unknown card"
end
defmodule Service do
  def lookup(mod, name), do: mod.fetch(name)
end
defmodule ServiceTest do
  use ExUnit.Case

  test "finds a known name" do
    assert Service.lookup(FakeStore, "Ana") == "Ana's card"
  end

  test "handles an unknown name" do
    assert Service.lookup(FakeStore, "Bo") == "unknown card"
  end
end
result = ExUnit.run()
IO.puts("passed: #{result.total - result.failures}/#{result.total}")

Key points

  • Test double: a small stand-in module that acts like a real module during a test.
  • Behaviour: a module's promise of which functions it provides, written with callback lines.
  • Fake: a test double that returns fixed answers instead of doing real work.
  • Injection: passing the module to use as an argument, so the test can swap the fake in.
  • Real work: the fake never touches the network, so the test stays fast and steady.

In this example, the Store behaviour promises a fetch function, and the fake store answers with a fixed card for a known name and an unknown card otherwise. Two tests pass the fake store to the service and check both answers.

Note

a fake must follow the same behaviour as the real module, or the test proves nothing about the real code.

Try it Yourself

Edit the code, then press Run.

Running is simulated

Exercise

Why is the module passed as an argument to the function under test?