In Part 4, you learned behavior-driven mocking with Mox. You defined behaviors with @callback, created mocks with Mox.defmock, and controlled test behavior with expect/3 and stub/3. But there’s a pattern that makes this even more powerful: the complete adapter pattern with reusable stub implementations. This is Part 5 of our 7-part series on Elixir testing patterns, where we organize test doubles systematically.
Setting up expect/3 in every test works, but it gets repetitive. When you have the same mock setup across dozens of tests, you’re duplicating code and making tests brittle. Change the mock’s return value and you update 20 tests. The solution: reusable stub modules that implement the behavior once, then connect to mocks with stub_with/2.
This post corresponds to PR #6: Adapter Pattern and Stub Implementations in ex-test. You’ll see exactly how to build the complete adapter pattern, organize stub variants, and test time-dependent logic deterministically.
Beyond Basic Mocks
Let’s start with the problem that stub implementations solve.
When expect/stub Isn’t Enough
In Part 4, you learned to set up mocks in individual tests:
test "upload stores file" do
expect(ExTest.StorageMock, :upload, fn _file, path ->
{:ok, "https://example.com/#{path}"}
end)
assert {:ok, url} = Storage.upload("contents", "file.txt")
assert url =~ "file.txt"
endThis works for one test. But what about the next test that also needs storage? And the 10 tests after that? You copy-paste the expect call into every test. Soon you have:
test "first test" do
expect(ExTest.StorageMock, :upload, fn _file, path ->
{:ok, "https://example.com/#{path}"}
end)
# test code
end
test "second test" do
expect(ExTest.StorageMock, :upload, fn _file, path ->
{:ok, "https://example.com/#{path}"}
end)
# test code
end
test "third test" do
expect(ExTest.StorageMock, :upload, fn _file, path ->
{:ok, "https://example.com/#{path}"}
end)
# test code
endThis duplication creates maintenance problems:
Change propagation: Need to adjust the mock’s return value? Update every test.
Inconsistency: Different tests might set up the mock slightly differently, causing confusion.
Boilerplate: Tests become cluttered with mock setup instead of focusing on what they’re testing.
Error-prone: Typos in mock setup cause test failures unrelated to your actual code.
Most importantly, you’re testing the same mock behavior over and over. The mock isn’t what you’re testing - it’s infrastructure.
The Case for Reusable Stubs
A stub implementation is a module that implements a behavior with simple, predictable responses. Define it once, reuse it everywhere:
defmodule ExTest.Stubs.StorageStub do
@behaviour ExTest.Storage
@impl true
def upload(_file, path), do: {:ok, "https://stub.test/#{path}"}
@impl true
def download(_path), do: {:ok, "stub file contents"}
@impl true
def delete(_path), do: :ok
endNow in your tests, connect this stub to the mock:
test "first test" do
stub_with(ExTest.StorageMock, ExTest.Stubs.StorageStub)
# test code - storage works automatically
end
test "second test" do
stub_with(ExTest.StorageMock, ExTest.Stubs.StorageStub)
# test code - same stub, no duplication
endEven better, move it to setup:
setup do
stub_with(ExTest.StorageMock, ExTest.Stubs.StorageStub)
:ok
end
test "first test" do
# storage just works
end
test "second test" do
# storage just works
endThe stub provides default behavior for all tests. You define the implementation once. Tests become focused on business logic, not infrastructure.
When you need specific behavior, expect/3 still works and overrides the stub for that test:
test "handles upload failure" do
stub_with(ExTest.StorageMock, ExTest.Stubs.StorageStub)
expect(ExTest.StorageMock, :upload, fn _file, _path ->
{:error, :network_timeout}
end)
assert {:error, :network_timeout} = Storage.upload("data", "file.txt")
endStubs provide defaults. Expectations override when needed. This combination keeps tests clean and maintainable.
The Complete Adapter Pattern
The adapter pattern has three pieces: behavior, facade, and config-based injection. You saw this briefly in Part 4. Now we’ll build the complete pattern.
Behavior (Contract)
The behavior defines the contract - what operations are available and their signatures:
defmodule ExTest.Storage do
@type file_content :: binary()
@type path :: String.t()
@type url :: String.t()
@callback upload(file :: file_content(), path :: path()) ::
{:ok, url()} | {:error, term()}
@callback download(path :: path()) ::
{:ok, file_content()} | {:error, term()}
@callback delete(path :: path()) ::
:ok | {:error, term()}
endThis is pure specification. No implementation, just the contract that any adapter must follow.
Type definitions: @type directives document parameter and return types. This helps with type checking and documentation.
Callbacks: @callback directives define required functions. Any module implementing this behavior must provide upload/2, download/1, and delete/1 with matching signatures.
Return types: Union types (|) specify all possible return values. upload/2 returns either {:ok, url} or {:error, reason}.
Facade (Public API)
The facade sits on top of the behavior, providing a public API that delegates to the configured adapter:
defmodule ExTest.Storage do
@type file_content :: binary()
@type path :: String.t()
@type url :: String.t()
@callback upload(file :: file_content(), path :: path()) ::
{:ok, url()} | {:error, term()}
@callback download(path :: path()) ::
{:ok, file_content()} | {:error, term()}
@callback delete(path :: path()) ::
:ok | {:error, term()}
defp adapter do
Application.get_env(:ex_test, :storage_adapter, ExTest.Storage.S3)
end
def upload(file, path), do: adapter().upload(file, path)
def download(path), do: adapter().download(path)
def delete(path), do: adapter().delete(path)
endNotice this module serves dual purposes:
Behavior definition: The @callback directives define the contract.
Facade: The public functions (upload/2, download/1, delete/1) delegate to the adapter.
This keeps everything in one module. Your application code imports ExTest.Storage and calls Storage.upload(file, path). Behind the scenes, the facade looks up the configured adapter and delegates to it.
Production Adapter
The production adapter is the real implementation that talks to external services:
defmodule ExTest.Storage.S3 do
@behaviour ExTest.Storage
require Logger
@impl true
def upload(file, path) do
Logger.info("S3: Uploading #{byte_size(file)} bytes to #{path}")
{:ok, "https://s3.amazonaws.com/bucket/#{path}"}
end
@impl true
def download(path) do
Logger.info("S3: Downloading #{path}")
{:ok, "file contents for #{path}"}
end
@impl true
def delete(path) do
Logger.info("S3: Deleting #{path}")
:ok
end
endThe @behaviour ExTest.Storage line tells the compiler to verify this module implements all required callbacks. If you forget a function or get the signature wrong, compilation fails. This is compile-time safety.
In a real application, these functions would use the AWS SDK to interact with S3. For this example, they log operations and return simulated responses.
Config-Based Injection
Configuration determines which adapter to use:
# config/config.exs (default for all environments)
config :ex_test, :storage_adapter, ExTest.Storage.S3
# config/test.exs (override for tests)
config :ex_test, :storage_adapter, ExTest.StorageMockThe facade’s adapter/0 function reads this configuration:
defp adapter do
Application.get_env(:ex_test, :storage_adapter, ExTest.Storage.S3)
endApplication.get_env/3 takes three arguments:
- Application name (
:ex_test) - Config key (
:storage_adapter) - Default value (
ExTest.Storage.S3)
In production, it returns ExTest.Storage.S3. In tests, it returns ExTest.StorageMock. Your code never changes - only the configuration does.
This is dependency injection, Elixir-style. No runtime container, no annotations. Just application configuration and module delegation.
Stub Implementations
Now that you understand the complete adapter pattern, let’s build reusable stub implementations.
What Makes a Good Stub
A good stub implementation:
Implements the behavior: Uses @behaviour to ensure compile-time verification.
Returns realistic data: Responses should match production format, just simplified.
Has no side effects: No database writes, no external API calls, no randomness.
Is deterministic: Same inputs always produce same outputs.
Is simple: Stubs should be trivial to understand at a glance.
Stubs are test infrastructure, not production code. Keep them simple. Complexity in stubs makes tests harder to understand.
Creating Success Stubs
A success stub returns successful responses for all operations:
defmodule ExTest.Stubs.StorageStub do
@behaviour ExTest.Storage
@impl true
def upload(_file, path) do
{:ok, "https://stub.test/#{path}"}
end
@impl true
def download(_path) do
{:ok, "stub file contents"}
end
@impl true
def delete(_path), do: :ok
endBreaking this down:
@behaviour directive: Ensures this module implements all required callbacks. If you forget a function, compilation fails.
@impl true: Marks each function as implementing a callback. This provides compiler warnings if you typo the function name.
Underscore prefixes: _file and _path indicate unused variables. The stub doesn’t care about actual values - it just returns canned responses.
Simple returns: Each function returns the simplest successful response. Upload returns a URL. Download returns file contents. Delete returns :ok.
Use this stub when you don’t care about the actual storage operations - you just need storage to work so you can test other logic.
Error Variants
What about testing error scenarios? Create an error variant as a nested module:
defmodule ExTest.Stubs.StorageStub do
@behaviour ExTest.Storage
@impl true
def upload(_file, path), do: {:ok, "https://stub.test/#{path}"}
@impl true
def download(_path), do: {:ok, "stub file contents"}
@impl true
def delete(_path), do: :ok
defmodule Error do
@behaviour ExTest.Storage
@impl true
def upload(_file, _path), do: {:error, :upload_failed}
@impl true
def download(_path), do: {:error, :not_found}
@impl true
def delete(_path), do: {:error, :permission_denied}
end
endThe Error module is nested inside StorageStub. This organization makes it clear that:
StorageStubis the default, happy-path stubStorageStub.Erroris a variant for testing failures
To use the error variant:
setup do
stub_with(ExTest.StorageMock, ExTest.Stubs.StorageStub.Error)
:ok
end
test "handles upload failure" do
assert {:error, :upload_failed} = Storage.upload("data", "file.txt")
endNow all storage operations fail. Tests in this describe block focus on error handling.
This pattern scales to any number of variants. Success stub in the parent module, specialized variants as nested modules. You could have:
StorageStub- successStorageStub.Error- failuresStorageStub.Slow- simulates timeoutsStorageStub.RateLimited- simulates rate limiting
Each variant is a complete behavior implementation. Choose the one that fits your test scenario.
The Clock Pattern
Time-dependent logic is notoriously hard to test. The clock pattern solves this by abstracting time behind a behavior.
Why Abstract Time
Consider this function that finds overdue todos:
def overdue_todos do
today = Date.utc_today()
Repo.all(
from t in Todo,
where: t.due_date < ^today and not t.completed
)
endHow do you test this? The result depends on Date.utc_today(), which returns different values every day. Your test data would need to constantly update to stay relevant.
You could try freezing time with libraries, but that’s complex. Better: abstract time behind a behavior.
Clock Behavior
Define a behavior for time operations:
defmodule ExTest.Clock do
@callback now() :: DateTime.t()
@callback today() :: Date.t()
defp adapter do
Application.get_env(:ex_test, :clock_adapter, ExTest.Clock.System)
end
def now, do: adapter().now()
def today, do: adapter().today()
endThis follows the same adapter pattern as storage. The behavior defines two operations:
now/0returns the current datetimetoday/0returns the current date
The facade delegates to the configured adapter.
The production adapter uses actual system time:
defmodule ExTest.Clock.System do
@behaviour ExTest.Clock
@impl true
def now, do: DateTime.utc_now()
@impl true
def today, do: Date.utc_today()
endIn production, Clock.today() returns the real current date.
Fixed Time Stub
In tests, use a stub that returns fixed time:
defmodule ExTest.Stubs.ClockStub do
@behaviour ExTest.Clock
@fixed_now ~U[2025-06-15 12:00:00Z]
@fixed_today ~D[2025-06-15]
@impl true
def now, do: @fixed_now
@impl true
def today, do: @fixed_today
endModule attributes store the fixed values. Every call to now() returns 2025-06-15 12:00:00Z. Every call to today() returns 2025-06-15.
This makes time-dependent tests deterministic:
setup do
stub_with(ExTest.ClockMock, ExTest.Stubs.ClockStub)
:ok
end
test "finds overdue todos" do
# Clock returns 2025-06-15
# Create todo due on 2025-06-14 (yesterday)
overdue = insert(:todo, due_date: ~D[2025-06-14], completed: false)
# Create todo due on 2025-06-16 (tomorrow)
upcoming = insert(:todo, due_date: ~D[2025-06-16], completed: false)
result = Todos.overdue_todos()
assert overdue in result
refute upcoming in result
endThe test controls time. No matter when you run it, “today” is always June 15, 2025. The overdue todo is always yesterday. The upcoming todo is always tomorrow. Tests are deterministic.
Past and Future Variants
Create variants for different time scenarios:
defmodule ExTest.Stubs.ClockStub do
@behaviour ExTest.Clock
@fixed_now ~U[2025-06-15 12:00:00Z]
@fixed_today ~D[2025-06-15]
@impl true
def now, do: @fixed_now
@impl true
def today, do: @fixed_today
defmodule Past do
@behaviour ExTest.Clock
@impl true
def now, do: ~U[2020-01-01 00:00:00Z]
@impl true
def today, do: ~D[2020-01-01]
end
defmodule Future do
@behaviour ExTest.Clock
@impl true
def now, do: ~U[2030-12-31 23:59:59Z]
@impl true
def today, do: ~D[2030-12-31]
end
endThree variants:
ClockStub- present (mid-2025)ClockStub.Past- past (early 2020)ClockStub.Future- future (end of 2030)
Use Past to test scenarios where everything is overdue:
setup do
stub_with(ExTest.ClockMock, ExTest.Stubs.ClockStub.Past)
:ok
end
test "all todos from 2020 onwards are overdue" do
# Clock returns 2020-01-01
# Any todo due after this date is technically "upcoming" in 2020,
# but if we're testing from a current perspective, they're all in the past
todo_2021 = insert(:todo, due_date: ~D[2021-01-01])
todo_2022 = insert(:todo, due_date: ~D[2022-01-01])
# Your business logic determines what "overdue" means
result = Todos.all_historical_todos()
assert todo_2021 in result
assert todo_2022 in result
endUse Future to test scenarios with dates far ahead:
setup do
stub_with(ExTest.ClockMock, ExTest.Stubs.ClockStub.Future)
:ok
end
test "no todos are overdue in far future" do
# Clock returns 2030-12-31
# Todos from 2025 are all in the past
old_todo = insert(:todo, due_date: ~D[2025-01-01])
result = Todos.overdue_todos()
# From 2030's perspective, this is way overdue
assert old_todo in result
endTime variants let you test edge cases without waiting for the calendar to change. Test subscription expiration, trial periods, deadline enforcement - all deterministically.
Using stub_with/2
stub_with/2 connects Mox mocks to stub implementations. It’s the bridge between the mock and your reusable behavior module.
Connecting Mocks to Stubs
The signature is straightforward:
stub_with(mock_module, stub_module)For example:
stub_with(ExTest.StorageMock, ExTest.Stubs.StorageStub)This tells the mock: “Use this stub module’s implementation for all functions.”
What happens behind the scenes:
- Mox looks at the stub module
- For each function in the behavior, it configures the mock to delegate to the stub
- All calls to the mock now go to the stub
It’s equivalent to manually stubbing each function:
stub(ExTest.StorageMock, :upload, &ExTest.Stubs.StorageStub.upload/2)
stub(ExTest.StorageMock, :download, &ExTest.Stubs.StorageStub.download/1)
stub(ExTest.StorageMock, :delete, &ExTest.Stubs.StorageStub.delete/1)stub_with/2 does this in one call. Much cleaner.
Switching Between Variants
You can switch stubs by calling stub_with/2 again:
describe "success scenarios" do
setup do
stub_with(ExTest.StorageMock, ExTest.Stubs.StorageStub)
:ok
end
test "uploads work" do
assert {:ok, _url} = Storage.upload("data", "file.txt")
end
end
describe "error scenarios" do
setup do
stub_with(ExTest.StorageMock, ExTest.Stubs.StorageStub.Error)
:ok
end
test "uploads fail" do
assert {:error, :upload_failed} = Storage.upload("data", "file.txt")
end
endEach describe block uses a different variant. Tests in the first block get success responses. Tests in the second block get errors.
This organization makes test intent clear. “Success scenarios” describes the happy path. “Error scenarios” describes failure handling. The setup makes the difference obvious.
You can also switch stubs within a single test:
test "retries on failure then succeeds" do
# Start with error stub
stub_with(ExTest.StorageMock, ExTest.Stubs.StorageStub.Error)
# First attempt fails
assert {:error, :upload_failed} = Storage.upload("data", "file.txt")
# Switch to success stub
stub_with(ExTest.StorageMock, ExTest.Stubs.StorageStub)
# Retry succeeds
assert {:ok, _url} = Storage.upload("data", "file.txt")
endThough for testing retry logic, you might prefer expect/3 with chained responses (from Part 4) as it verifies the exact number of calls.
Testing Time-Dependent Logic
Let’s put the clock pattern into practice with realistic examples.
Overdue Items Example
Start with the business logic:
defmodule ExTest.Todos do
alias ExTest.Clock
alias ExTest.Repo
alias ExTest.Todos.Todo
def overdue_todos do
today = Clock.today()
Repo.all(
from t in Todo,
where: t.due_date < ^today and not t.completed
)
end
endNotice the code calls Clock.today(), not Date.utc_today(). This makes it testable.
Now test with the clock stub:
defmodule ExTest.TodosTest do
use ExTest.DataCase, async: true
import Mox
alias ExTest.Todos
setup :verify_on_exit!
describe "overdue_todos/0" do
setup do
stub_with(ExTest.ClockMock, ExTest.Stubs.ClockStub)
:ok
end
test "finds todos with due_date before today" do
# Today is 2025-06-15 (per ClockStub)
overdue = insert(:todo, due_date: ~D[2025-06-14], completed: false)
on_time = insert(:todo, due_date: ~D[2025-06-15], completed: false)
future = insert(:todo, due_date: ~D[2025-06-16], completed: false)
result = Todos.overdue_todos()
assert overdue in result
refute on_time in result
refute future in result
end
test "excludes completed todos even if overdue" do
# Today is 2025-06-15
overdue_completed = insert(:todo,
due_date: ~D[2025-06-14],
completed: true
)
result = Todos.overdue_todos()
refute overdue_completed in result
end
end
endThe clock is frozen at June 15, 2025. Tests create todos relative to that date. No matter when you run the test, the clock stub ensures consistent behavior.
Expiration Testing
Test subscriptions, trials, or any time-based expiration:
defmodule ExTest.Subscriptions do
alias ExTest.Clock
def expired?(subscription) do
today = Clock.today()
Date.compare(subscription.expires_on, today) == :lt
end
endTest with different clock variants:
describe "expired?/1" do
test "with present clock, future subscription is active" do
stub_with(ExTest.ClockMock, ExTest.Stubs.ClockStub)
# Today: 2025-06-15
subscription = %{expires_on: ~D[2025-12-31]}
refute Subscriptions.expired?(subscription)
end
test "with future clock, current subscription is expired" do
stub_with(ExTest.ClockMock, ExTest.Stubs.ClockStub.Future)
# Today: 2030-12-31
subscription = %{expires_on: ~D[2025-12-31]}
assert Subscriptions.expired?(subscription)
end
test "with past clock, all subscriptions are active" do
stub_with(ExTest.ClockMock, ExTest.Stubs.ClockStub.Past)
# Today: 2020-01-01
subscription = %{expires_on: ~D[2025-12-31]}
refute Subscriptions.expired?(subscription)
end
endThree variants test three scenarios without changing the subscription data. The clock does the work.
You can also test edge cases like same-day expiration:
test "subscription expiring today is not yet expired" do
stub_with(ExTest.ClockMock, ExTest.Stubs.ClockStub)
# Today: 2025-06-15
subscription = %{expires_on: ~D[2025-06-15]}
# Depends on your business logic
refute Subscriptions.expired?(subscription)
endThis tests boundary conditions. Does expiration happen at the start of the day or end? Your clock abstraction makes this testable without waiting for midnight.
Stub Organization
As your test suite grows, organize stubs systematically.
Directory Structure
test/
├── support/
│ ├── stubs/
│ │ ├── clock_stub.ex
│ │ ├── storage_stub.ex
│ │ └── email_stub.ex
│ ├── mocks.ex
│ └── data_case.ex
└── ex_test/
└── (test files)
Keep stubs in test/support/stubs/. This separates them from test files and other support code.
One file per behavior: Each stub file corresponds to one behavior. clock_stub.ex implements Clock. storage_stub.ex implements Storage.
Variants as nested modules: Success stubs at the top level, variants nested inside. This makes the default obvious.
Mocks defined separately: Mock definitions stay in test/support/mocks.ex or test/test_helper.exs. Stubs are in their own directory.
Naming Conventions
Follow consistent naming:
Stub modules: Behavior name + “Stub”
- Behavior:
ExTest.Clock - Stub:
ExTest.Stubs.ClockStub
Variants: Nested modules with descriptive names
- Success:
StorageStub(parent module) - Error:
StorageStub.Error - Past:
ClockStub.Past - Future:
ClockStub.Future
Files: Snake case matching module name
- Module:
ExTest.Stubs.ClockStub - File:
test/support/stubs/clock_stub.ex
Consistency makes stubs discoverable. When you need a clock stub, you know to look for test/support/stubs/clock_stub.ex.
Best Practices
Follow these guidelines to keep stubs maintainable.
When to Use Stubs vs expect
Use stubs when:
- Multiple tests need the same mock behavior
- The dependency isn’t what you’re testing
- You want default behavior across a describe block
Use expect when:
- Testing specific interactions with the dependency
- Verifying exact call counts matters
- Testing different return values in one test
- The mock behavior is unique to one test
Rule of thumb: if you’re copying the same expect/3 call across tests, create a stub.
Stubs reduce duplication. Expectations verify specific behavior. Both have their place.
Keeping Stubs Simple
Stubs should be trivial:
# GOOD - simple, obvious
defmodule StorageStub do
@behaviour Storage
@impl true
def upload(_file, path), do: {:ok, "https://stub/#{path}"}
end
# BAD - complex logic in stub
defmodule StorageStub do
@behaviour Storage
@impl true
def upload(file, path) do
if byte_size(file) > 1000 do
{:error, :file_too_large}
else
{:ok, compute_hash(file) <> "/" <> path}
end
end
defp compute_hash(file), do: # complex logic
endIf your stub has conditional logic, you probably need two stubs:
StorageStubfor normal uploadsStorageStub.FileTooLargefor the error case
Each stub should do one thing. Complexity belongs in your actual code, not test infrastructure.
Module Attributes for Fixed Values
Use module attributes for fixed values in stubs:
# GOOD - clear and reusable
defmodule ClockStub do
@behaviour Clock
@fixed_now ~U[2025-06-15 12:00:00Z]
@fixed_today ~D[2025-06-15]
@impl true
def now, do: @fixed_now
@impl true
def today, do: @fixed_today
end
# BAD - hardcoded in each function
defmodule ClockStub do
@behaviour Clock
@impl true
def now, do: ~U[2025-06-15 12:00:00Z]
@impl true
def today, do: ~D[2025-06-15]
endModule attributes make the fixed values obvious and easy to change. They also enable derived values:
@fixed_today ~D[2025-06-15]
@yesterday Date.add(@fixed_today, -1)
@tomorrow Date.add(@fixed_today, 1)This keeps related values consistent.
Summary
You’ve mastered the complete adapter pattern and stub implementations:
- Beyond basic mocks: Stubs eliminate repetitive mock setup across tests
- Complete adapter pattern: Behavior defines contract, facade provides API, config selects implementation
- Reusable stubs: Implement behaviors once, connect with
stub_with/2 - Stub variants: Organize success, error, and specialized cases as nested modules
- Clock abstraction: Test time-dependent logic deterministically with fixed time stubs
- stub_with/2: Bridge between Mox mocks and stub implementations
- Organization: Keep stubs in
test/support/stubs/with consistent naming
ex-test demonstrates these patterns in action. Storage operations and time-dependent logic are fully tested without external dependencies or flaky time-based assertions.
In Part 6, we’ll organize these patterns with centralized test helpers. Learn to build StubHelper, AssertHelper, and SetupHelper modules that make tests clean and composable.
All code examples are available in the ex-test.
Series Navigation
Previous: Part 4 - Mocking with Mox
Next: Part 6 - Centralized Test Helpers
All Parts
- ExUnit Fundamentals
- Database Isolation with Ecto.Sandbox
- Test Data with ExMachina
- Mocking with Mox
- Adapter Pattern and Stubs (You are here)
- Centralized Test Helpers
- Phoenix Controller Testing
