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"
end

This 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
end

This 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
end

Now 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
end

Even 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
end

The 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")
end

Stubs 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()}
end

This 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)
end

Notice 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
end

The @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.StorageMock

The facade’s adapter/0 function reads this configuration:

defp adapter do
  Application.get_env(:ex_test, :storage_adapter, ExTest.Storage.S3)
end

Application.get_env/3 takes three arguments:

  1. Application name (:ex_test)
  2. Config key (:storage_adapter)
  3. 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
end

Breaking 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
end

The Error module is nested inside StorageStub. This organization makes it clear that:

  • StorageStub is the default, happy-path stub
  • StorageStub.Error is 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")
end

Now 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 - success
  • StorageStub.Error - failures
  • StorageStub.Slow - simulates timeouts
  • StorageStub.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
  )
end

How 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()
end

This follows the same adapter pattern as storage. The behavior defines two operations:

  • now/0 returns the current datetime
  • today/0 returns 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()
end

In 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
end

Module 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
end

The 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
end

Three 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
end

Use 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
end

Time 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:

  1. Mox looks at the stub module
  2. For each function in the behavior, it configures the mock to delegate to the stub
  3. 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
end

Each 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")
end

Though 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
end

Notice 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
end

The 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
end

Test 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
end

Three 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)
end

This 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
end

If your stub has conditional logic, you probably need two stubs:

  • StorageStub for normal uploads
  • StorageStub.FileTooLarge for 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]
end

Module 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

  1. ExUnit Fundamentals
  2. Database Isolation with Ecto.Sandbox
  3. Test Data with ExMachina
  4. Mocking with Mox
  5. Adapter Pattern and Stubs (You are here)
  6. Centralized Test Helpers
  7. Phoenix Controller Testing

Resources