In Parts 1-3, you learned ExUnit fundamentals, database isolation with Ecto.Sandbox, and factory patterns with ExMachina. Now you can create isolated tests with realistic data. But there’s still a challenge: testing code that depends on external services. This is Part 4 of our 7-part series on Elixir testing patterns, where we solve the external dependency problem with behavior-driven mocking.

Email services, payment processors, third-party APIs - these dependencies make testing difficult. You can’t hit a real email service in tests (it would send actual emails). You can’t charge real credit cards. And you can’t make hundreds of API calls to external services during your test suite. Traditional solutions involve monkey-patching or runtime code swapping, but Elixir offers a better approach: behavior-based mocking with Mox.

This post corresponds to PR #5: Behavior-Driven Mocking with Mox in ex-test. You’ll see exactly how to define behaviors, create mocks, and verify interactions without touching real external services.

The Problem with Traditional Mocking

Let’s start with why most mocking approaches fail in Elixir.

Why Monkey-Patching Fails

In dynamic languages like Ruby or JavaScript, you can replace functions at runtime:

// JavaScript - monkey-patching
const emailService = {
  send: (email) => { /* real implementation */ }
};
 
// In test, replace the function
emailService.send = jest.fn(() => Promise.resolve());

This works in dynamic languages but creates several problems:

No compile-time safety: If you typo the function name or change the function signature, you won’t know until the test runs.

Global state: Monkey-patched code affects all tests, creating ordering dependencies.

Unclear contracts: What functions does the email service actually provide? You have to dig through implementation code to find out.

Difficult debugging: When a test fails, is it because your code is wrong or because your mock is wrong?

Elixir doesn’t allow this kind of runtime modification. Functions are defined at compile time, and you can’t just replace them. This is actually a feature - it prevents the problems above.

Elixir’s Better Way: Behaviors

Instead of monkey-patching, Elixir uses behaviors - explicit contracts that define what functions a module must implement. Think of behaviors as interfaces in object-oriented languages, but simpler and more explicit.

A behavior defines callbacks (function signatures), and modules implement those callbacks. Your code depends on the behavior, not a specific implementation. At runtime, you configure which implementation to use.

This pattern provides:

Compile-time verification: If an implementation doesn’t match the behavior, compilation fails.

Explicit contracts: The behavior documentation shows exactly what functions are available.

Swappable implementations: Use the real implementation in production, a mock in tests.

Type safety: Callbacks include type specifications, catching type errors early.

Mox builds on this foundation, making it easy to create mock implementations of behaviors specifically for testing.

Behaviors: Defining Contracts

Before you can mock something, you need to define what that something does. Behaviors provide the contract.

The @callback Directive

A behavior is a module that defines callbacks using the @callback directive:

defmodule ExTest.Email do
  @type todo :: %{required(:title) => String.t(), optional(atom()) => any()}
  @type user :: %{required(:email) => String.t(), optional(atom()) => any()}
 
  @callback send_reminder(todo :: todo()) :: :ok | {:error, term()}
  @callback send_welcome(user :: user()) :: :ok | {:error, term()}
end

Let’s break this down:

@type definitions: Define type aliases for function parameters. The todo() type is any map with a :title key containing a string. The user() type is any map with an :email key. These types are used in the callback specifications.

@callback directives: Define function signatures that implementing modules must provide. Each callback specifies the function name, parameter types, and return type.

Return types: :: :ok | {:error, term()} means the function returns either the atom :ok or a tuple {:error, reason} where reason can be anything.

This behavior doesn’t contain any implementation code. It’s purely a contract: “Any module implementing this behavior must provide send_reminder/1 and send_welcome/1 functions with these exact signatures.”

Creating Your First Behavior

Let’s create a behavior for a payment service:

defmodule MyApp.PaymentService do
  @type amount :: pos_integer()
  @type currency :: String.t()
  @type charge_result :: {:ok, String.t()} | {:error, :insufficient_funds | :invalid_card}
 
  @callback charge(amount :: amount(), currency :: currency()) :: charge_result()
  @callback refund(charge_id :: String.t()) :: :ok | {:error, term()}
end

This defines a payment behavior with two operations: charging a card and refunding a charge. Any implementation must provide both functions with matching signatures.

The pattern:

  1. Define types for complex parameters using @type
  2. Define callbacks using @callback with type specifications
  3. Document expected return values with union types (|)

Behaviors serve as documentation. A developer can look at this module and immediately understand what a payment service does, without reading implementation code.

Setting Up Mox

Mox is the standard mocking library for Elixir. It creates mock implementations of behaviors that you can control in tests.

Installation

Add Mox to your dependencies in mix.exs:

defp deps do
  [
    {:mox, "~> 1.2", only: :test}
  ]
end

Install dependencies:

mix deps.get

Defining Mocks

Mocks are defined in your test support files. Create or update test/support/mocks.ex:

Mox.defmock(ExTest.EmailMock, for: ExTest.Email)

This single line creates a module called ExTest.EmailMock that implements the ExTest.Email behavior. The mock module has all the functions defined in the behavior’s callbacks, but their implementations are controlled by your tests.

The Mox.defmock/2 macro:

  1. Creates a new module with the given name (ExTest.EmailMock)
  2. Implements all callbacks from the specified behavior (ExTest.Email)
  3. Makes the mock functions controllable via expect/3 and stub/3

You define mocks once, then configure them per-test using Mox’s testing functions.

Test Configuration

Add Mox configuration to your test/test_helper.exs:

Mox.defmock(ExTest.EmailMock, for: ExTest.Email)
ExUnit.start()

If you have multiple mocks, define them all here or in a dedicated test/support/mocks.ex file that you require from test_helper.exs.

The Adapter Pattern

With behaviors and mocks defined, you need a way to switch between the real implementation and the mock. The adapter pattern solves this.

Config-Based Injection

The pattern uses application configuration to determine which module to use:

defmodule ExTest.Email do
  @type todo :: %{required(:title) => String.t(), optional(atom()) => any()}
  @type user :: %{required(:email) => String.t(), optional(atom()) => any()}
 
  @callback send_reminder(todo :: todo()) :: :ok | {:error, term()}
  @callback send_welcome(user :: user()) :: :ok | {:error, term()}
 
  defp adapter do
    Application.get_env(:ex_test, :email_adapter, ExTest.Email.Sendgrid)
  end
 
  def send_reminder(todo), do: adapter().send_reminder(todo)
  def send_welcome(user), do: adapter().send_welcome(user)
end

This creates a facade module that delegates to the configured adapter. Let’s understand each piece:

Behavior definition: The @callback directives define the contract. This module is both a behavior definition and a facade.

adapter/0 function: Reads the application configuration to determine which implementation to use. The third argument to Application.get_env/3 is the default - in production, use the real Sendgrid implementation.

Facade functions: send_reminder/1 and send_welcome/1 are public API functions that delegate to the adapter. Your application code calls these functions, not the adapter directly.

The key insight: your application code calls ExTest.Email.send_reminder(todo), which delegates to whatever module is configured. In production, it’s the real implementation. In tests, it’s the mock.

Facade Module Pattern

The facade pattern creates a clean separation:

Your Code → Facade Module → Adapter (configurable)
                                ↓
                    Production: Real Implementation
                    Test: Mock Implementation

Your code never imports the real implementation directly. It only imports the facade. This makes testing trivial - just configure the mock as the adapter.

Configure the mock in config/test.exs:

config :ex_test, :email_adapter, ExTest.EmailMock

Now when tests run, ExTest.Email.send_reminder/1 delegates to ExTest.EmailMock.send_reminder/1, which you control with Mox’s expect/3 and stub/3 functions.

In production (config/prod.exs), use the real implementation:

config :ex_test, :email_adapter, ExTest.Email.Sendgrid

Or rely on the default in the adapter/0 function. The facade pattern keeps configuration flexible.

expect vs stub: The Core Distinction

Mox provides two ways to define mock behavior: expect/3 and stub/3. Understanding the difference is crucial.

expect - Must Be Called

The expect/3 function sets an expectation that must be fulfilled for the test to pass:

describe "expect/3 - basic expectations" do
  test "expects send_reminder to be called once" do
    todo = insert(:todo, title: "Important Task")
 
    expect(ExTest.EmailMock, :send_reminder, fn received_todo ->
      assert received_todo.title == "Important Task"
      :ok
    end)
 
    assert :ok = Email.send_reminder(todo)
  end
end

Breaking this down:

First argument: The mock module (ExTest.EmailMock).

Second argument: The function name as an atom (:send_reminder).

Third argument: An anonymous function that implements the mock behavior. This function receives the same arguments as the real function would.

Assertions inside the mock: You can assert on the arguments passed to the mock. This verifies your code calls the dependency correctly.

Expectation fulfillment: The mock function must be called exactly once. If it’s never called, or called more than once, the test fails.

Use expect/3 when you want to assert that an interaction happens. For example, testing that creating a user sends a welcome email.

expect with Multiple Calls

By default, expect/3 expects exactly one call. Use expect/4 to specify a different count:

describe "expect/4 - multiple call expectations" do
  test "expects function to be called exactly N times" do
    todo = insert(:todo)
    expect(ExTest.EmailMock, :send_reminder, 3, fn _todo -> :ok end)
 
    assert :ok = Email.send_reminder(todo)
    assert :ok = Email.send_reminder(todo)
    assert :ok = Email.send_reminder(todo)
  end
end

The fourth argument (before the function) specifies the expected call count. This test expects send_reminder/1 to be called exactly three times. Fewer or more calls cause the test to fail.

Chaining Different Responses

You can chain multiple expect/3 calls to return different values on successive calls:

test "can chain different expectations" do
  todo = insert(:todo)
 
  expect(ExTest.EmailMock, :send_reminder, fn _todo -> :ok end)
  expect(ExTest.EmailMock, :send_reminder, fn _todo -> {:error, :rate_limited} end)
  expect(ExTest.EmailMock, :send_reminder, fn _todo -> :ok end)
 
  assert :ok = Email.send_reminder(todo)
  assert {:error, :rate_limited} = Email.send_reminder(todo)
  assert :ok = Email.send_reminder(todo)
end

The first call returns :ok, the second returns {:error, :rate_limited}, the third returns :ok again. This pattern tests retry logic or failure handling.

Expectations are fulfilled in order. The first call uses the first expectation, the second call uses the second expectation, and so on.

stub - Optional Calls

The stub/3 function provides default behavior without verification:

describe "stub/3 - default behavior" do
  test "stub allows any number of calls (including zero)" do
    todo = insert(:todo)
    stub(ExTest.EmailMock, :send_reminder, fn _todo -> :ok end)
 
    assert :ok = Email.send_reminder(todo)
    assert :ok = Email.send_reminder(todo)
  end
end

The signature is identical to expect/3, but the semantics are different:

No verification: The stub function can be called zero times, once, or many times. The test doesn’t care.

Default behavior: Stubs provide fallback implementations when you don’t care about the exact number of calls.

Use case: Stub functions that your code might call but aren’t the focus of the test.

Use stub/3 when the interaction isn’t what you’re testing. For example, if you’re testing todo creation logic and the email is a side effect you don’t care about in this specific test.

Combining stub and expect

You can use both in the same test:

test "stub can be combined with expect" do
  todo = insert(:todo)
  user = %{email: "[email protected]", name: "Test"}
 
  stub(ExTest.EmailMock, :send_reminder, fn _todo -> :ok end)
  expect(ExTest.EmailMock, :send_welcome, fn _user -> :ok end)
 
  Email.send_reminder(todo)
  Email.send_reminder(todo)
  Email.send_welcome(user)
end

The :send_reminder function is stubbed - it can be called any number of times without failing the test. The :send_welcome function has an expectation - it must be called exactly once.

This pattern lets you focus on what matters. Stub the noise, expect the signal.

The Key Difference

Featureexpectstub
VerificationMust be calledNot verified
Call countExact (default 1)Any number (0+)
Use caseAssert interactionsProvide defaults
Test failureFails if not calledNever fails

Think of expect as “this interaction MUST happen” and stub as “if this interaction happens, here’s what to return.”

verify_on_exit!: Automatic Verification

Mox expectations must be fulfilled, but how does Mox check? The verify_on_exit!/1 function handles this automatically.

Automatic Verification

Add verify_on_exit! to your test setup:

defmodule ExTest.NotificationsTest do
  use ExTest.DataCase, async: true
  import Mox
 
  setup :verify_on_exit!
 
  describe "expect/3 - basic expectations" do
    test "expects send_reminder to be called once" do
      todo = insert(:todo, title: "Important Task")
 
      expect(ExTest.EmailMock, :send_reminder, fn received_todo ->
        assert received_todo.title == "Important Task"
        :ok
      end)
 
      assert :ok = Email.send_reminder(todo)
    end
  end
end

The setup :verify_on_exit! line registers a callback that runs after each test completes. This callback checks that all expectations were fulfilled.

What it does:

  1. Test runs with expect/3 setting up expectations
  2. Your code calls (or doesn’t call) the mocked functions
  3. Test assertions pass or fail
  4. verify_on_exit! callback runs
  5. If expectations weren’t fulfilled, the test fails with a clear error message

You set up verification once per test module, then forget about it. Mox handles the rest.

What Happens When Expectations Fail

If an expectation isn’t fulfilled, you get a clear error:

test "unfulfilled expectation" do
  todo = insert(:todo)
 
  expect(ExTest.EmailMock, :send_reminder, fn _todo -> :ok end)
 
  # Oops, we never call Email.send_reminder!
end

Test output:

1) test unfulfilled expectation (ExTest.NotificationsTest)
   test/ex_test/notifications_test.exs:15
   expected ExTest.EmailMock.send_reminder/1 to be called once but it was called 0 times

The error tells you exactly what expectation failed and how many times the function was actually called.

If you call the function too many times:

test "called too many times" do
  todo = insert(:todo)
 
  expect(ExTest.EmailMock, :send_reminder, fn _todo -> :ok end)
 
  Email.send_reminder(todo)
  Email.send_reminder(todo)  # Second call fails
end

You get an immediate error:

** (Mox.UnexpectedCallError) expected ExTest.EmailMock.send_reminder/1 to be called once but it has been called 2 times

Mox catches the error as soon as the unexpected call happens, making debugging easy.

Advanced Patterns

Once you understand the basics, these advanced patterns unlock more complex testing scenarios.

Assertions Inside Mocks

You can assert on the arguments passed to mock functions:

describe "assertions within mocks" do
  test "validates arguments passed to mock" do
    specific_todo = insert(:todo, title: "Specific Title", completed: false)
 
    expect(ExTest.EmailMock, :send_reminder, fn todo ->
      assert todo.title == "Specific Title"
      assert todo.id == specific_todo.id
      assert todo.completed == false
      :ok
    end)
 
    Email.send_reminder(specific_todo)
  end
end

This pattern verifies that your code calls the dependency with the correct data. The mock function receives the actual arguments, so you can assert on structure, values, or types.

Use this to test that:

  • Required fields are present
  • Data is properly formatted
  • IDs match expected records
  • Transformations are applied correctly

The mock function must still return a value matching the behavior’s return type. Assertions run before the return.

Mock Can Return Errors

Mocks aren’t limited to success cases. Test error handling by returning error tuples:

test "mock can return error tuples" do
  todo = insert(:todo)
 
  expect(ExTest.EmailMock, :send_reminder, fn _todo ->
    {:error, :smtp_connection_failed}
  end)
 
  assert {:error, :smtp_connection_failed} = Email.send_reminder(todo)
end

This tests how your code handles failures from external dependencies. Does it log the error? Retry? Return a specific error to the user?

Common error testing patterns:

# Network failure
expect(EmailMock, :send_reminder, fn _todo -> {:error, :timeout} end)
 
# Rate limiting
expect(EmailMock, :send_reminder, fn _todo -> {:error, :rate_limited} end)
 
# Invalid data
expect(EmailMock, :send_reminder, fn _todo -> {:error, :invalid_email} end)

Test both the happy path (return :ok) and error paths (return {:error, reason}).

Testing Retry Logic

Chain expectations to test retry behavior:

test "retries on failure then succeeds" do
  todo = insert(:todo)
 
  # First two attempts fail
  expect(ExTest.EmailMock, :send_reminder, fn _todo -> {:error, :timeout} end)
  expect(ExTest.EmailMock, :send_reminder, fn _todo -> {:error, :timeout} end)
  # Third attempt succeeds
  expect(ExTest.EmailMock, :send_reminder, fn _todo -> :ok end)
 
  # Your code retries until success
  result = MyApp.Notifications.send_with_retry(todo, max_attempts: 3)
  assert result == :ok
end

Each expect/3 call queues up a response. The first call uses the first expectation, the second call uses the second expectation, and so on. This simulates transient failures followed by success.

You can also verify exponential backoff or other retry strategies by tracking when calls happen.

Pattern Matching in Mocks

Use pattern matching to return different values based on input:

test "returns different responses based on input" do
  expect(ExTest.EmailMock, :send_reminder, fn
    %{title: "High Priority"} -> :ok
    %{title: "Low Priority"} -> {:error, :rate_limited}
    _other -> :ok
  end)
 
  high_priority = build(:todo, title: "High Priority")
  low_priority = build(:todo, title: "Low Priority")
  normal = build(:todo, title: "Normal Task")
 
  assert :ok = Email.send_reminder(high_priority)
  assert {:error, :rate_limited} = Email.send_reminder(low_priority)
  assert :ok = Email.send_reminder(normal)
end

The mock function uses pattern matching on its arguments to determine the return value. This tests different code paths based on input characteristics.

Best Practices

Mox is powerful but requires discipline. Follow these patterns to keep tests maintainable.

Organization Tips

One mocks file: Define all mocks in test/support/mocks.ex or in test/test_helper.exs. Don’t scatter mock definitions across test files.

# test/support/mocks.ex
Mox.defmock(ExTest.EmailMock, for: ExTest.Email)
Mox.defmock(ExTest.PaymentMock, for: ExTest.PaymentService)
Mox.defmock(ExTest.SMSMock, for: ExTest.SMSService)

Always use verify_on_exit!: Add setup :verify_on_exit! to every test module using Mox. This catches unfulfilled expectations automatically.

Import Mox explicitly: Add import Mox to test modules using mocks. Makes it clear which tests use mocking.

defmodule MyApp.NotificationsTest do
  use MyApp.DataCase, async: true
  import Mox
 
  setup :verify_on_exit!
 
  # Tests using expect/stub
end

Behavior-first design: Define the behavior before writing implementations. This forces you to think about the contract, not implementation details.

Common Pitfalls

Mocking internal modules: Only mock external dependencies (email services, APIs, payment processors). Don’t mock your own application modules - that’s testing implementation details, not behavior.

# BAD - mocking internal context
defmock(MyApp.TodosMock, for: MyApp.Todos)
 
# GOOD - mocking external service
defmock(MyApp.EmailMock, for: MyApp.Email)

Over-specifying expectations: Don’t assert on every argument field unless it matters for the test. Focus on what’s relevant.

# BAD - asserting on too much
expect(EmailMock, :send_reminder, fn todo ->
  assert todo.title != nil
  assert todo.description != nil
  assert todo.completed != nil
  assert todo.due_date != nil
  assert todo.inserted_at != nil
  assert todo.updated_at != nil
  :ok
end)
 
# GOOD - asserting on what matters
expect(EmailMock, :send_reminder, fn todo ->
  assert todo.title == "Important Task"
  :ok
end)

Forgetting verify_on_exit!: Without this, unfulfilled expectations don’t fail the test. Always add it to your setup.

Mixing expect and stub for the same function: Choose one. Either you care about the call count (use expect) or you don’t (use stub).

# CONFUSING - which takes precedence?
stub(EmailMock, :send_reminder, fn _todo -> :ok end)
expect(EmailMock, :send_reminder, fn _todo -> :ok end)
 
# CLEAR - stub for optional behavior
stub(EmailMock, :send_reminder, fn _todo -> :ok end)

Not testing error paths: Don’t only test success cases. Mock errors to verify your error handling works.

Brittle assertions: Asserting on exact strings or implementation details makes tests fragile. Assert on structure and required fields instead.

When Not to Use Mox

Database operations: Use Ecto.Sandbox for database isolation, not mocks. Mocking your Repo makes tests brittle.

Internal business logic: Don’t mock context modules or internal functions. Test them directly with real implementations.

Simple transformations: Functions that transform data without side effects don’t need mocking. Test them with inputs and assert on outputs.

Over-mocking: If you find yourself creating 10 mocks for a single test, you might have a design problem. Consider refactoring to reduce dependencies.

Use Mox for external dependencies that are:

  • Expensive (API calls, payment processing)
  • Non-deterministic (email sending, SMS)
  • Outside your control (third-party services)

Summary

You’ve mastered behavior-driven mocking with Mox:

  • Behaviors as contracts: Use @callback to define explicit interfaces
  • Compile-time safety: Implementations must match the behavior or compilation fails
  • The adapter pattern: Use Application.get_env/3 to swap implementations between production and test
  • expect vs stub: Use expect/3 for verified interactions, stub/3 for optional defaults
  • verify_on_exit!: Automatic expectation verification after each test
  • Advanced patterns: Assertions in mocks, error testing, retry logic, and pattern matching

ex-test demonstrates these patterns across all notification tests. No external services are called during tests. Everything is isolated, fast, and deterministic.

In Part 5, we’ll take this pattern further by exploring adapter pattern variations and stub implementations.

All code examples are available in the ex-test.


Series Navigation

Previous: Part 3 - Test Data with ExMachina

Next: Part 5 - Adapter Pattern and Stubs

All Parts

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

Resources