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()}
endLet’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()}
endThis 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:
- Define types for complex parameters using
@type - Define callbacks using
@callbackwith type specifications - 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}
]
endInstall dependencies:
mix deps.getDefining 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:
- Creates a new module with the given name (
ExTest.EmailMock) - Implements all callbacks from the specified behavior (
ExTest.Email) - Makes the mock functions controllable via
expect/3andstub/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)
endThis 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.EmailMockNow 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.SendgridOr 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
endBreaking 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
endThe 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)
endThe 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
endThe 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)
endThe :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
| Feature | expect | stub |
|---|---|---|
| Verification | Must be called | Not verified |
| Call count | Exact (default 1) | Any number (0+) |
| Use case | Assert interactions | Provide defaults |
| Test failure | Fails if not called | Never 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
endThe 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:
- Test runs with
expect/3setting up expectations - Your code calls (or doesn’t call) the mocked functions
- Test assertions pass or fail
verify_on_exit!callback runs- 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!
endTest 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
endYou 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
endThis 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)
endThis 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
endEach 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)
endThe 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
endBehavior-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
@callbackto define explicit interfaces - Compile-time safety: Implementations must match the behavior or compilation fails
- The adapter pattern: Use
Application.get_env/3to swap implementations between production and test - expect vs stub: Use
expect/3for verified interactions,stub/3for 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
- ExUnit Fundamentals
- Database Isolation with Ecto.Sandbox
- Test Data with ExMachina
- Mocking with Mox (You are here)
- Adapter Pattern and Stubs
- Centralized Test Helpers
- Phoenix Controller Testing
