Definition

A mock is a test double -- an object or module that replaces a real dependency during testing with a controlled substitute that returns pre-programmed responses and optionally records how it was called. Mocks enable isolated unit testing by decoupling the code under test from its dependencies: a test for a service that calls an external API can use a mock API client that returns predictable responses without network access. This isolation makes tests faster, more deterministic, and independent of external system availability.

However, mocks carry significant risks. They can diverge from real implementation behavior, creating tests that pass against mocks but fail against real dependencies ("mock drift"). They couple tests to implementation details (verifying that specific methods were called in specific order) rather than behavior (verifying that the output is correct). Over-reliance on mocks can produce test suites with high coverage but low confidence -- tests that verify interactions with fakes rather than correctness of behavior.

Overview

The Test Double Taxonomy

The term "mock" is frequently used as a catch-all for test doubles, but the testing literature distinguishes several distinct types, each serving different purposes:

TypeBehaviorRecords CallsVerificationUse Case
DummyDoes nothing, satisfies type requirementsNoNoneFilling required parameters
StubReturns fixed, pre-configured responsesNoState-basedProviding canned data
SpyDelegates to real implementationYesInteraction + StateVerifying side effects
MockReturns pre-programmed responsesYesInteraction-basedVerifying communication
FakeSimplified working implementationNoState-basedIn-memory databases, sandboxes

The distinction matters because each type creates different coupling between tests and implementation:

  • State-based verification (stubs, fakes): Tests assert on outputs. Refactoring that changes internal communication but preserves outputs doesn't break tests.
  • Interaction-based verification (mocks, spies): Tests assert on method calls. Any refactoring that changes how components communicate breaks tests, even if behavior is preserved.

The Fundamental Critique

The fundamental critique of mocking (articulated in "Mocks Aren't Stubs" by Martin Fowler and in the testing literature) is that mocks verify interactions (how code communicates with dependencies) while assertions verify outcomes (what code produces). Interaction testing is brittle: refactoring that changes how a result is computed (but not what result is produced) breaks mock-based tests unnecessarily.

Consider a function that fetches user data and formats a greeting:

# Interaction test (mock-based, brittle):
expect(MockUserRepo, :get_by_id, fn 42 -> %{name: "Alice"} end)
assert "Hello, Alice!" == Greeter.greet(42)
# Breaks if Greeter refactors to use get_by_email instead

# Outcome test (state-based, resilient):
insert_user(%{id: 42, name: "Alice"})
assert "Hello, Alice!" == Greeter.greet(42)
# Survives internal refactoring as long as output is correct

This doesn't mean mocks are never appropriate -- they're valuable at system boundaries where real implementations are impractical (third-party APIs, payment processors, email services). The key is using them at the right granularity.

Technical Deep Dive

Mox: The Elixir Standard

In the Elixir ecosystem, Mox is the standard mocking library, designed by Jose Valim. It enforces a critical constraint: you can only mock behaviours. This "mock only what you own" principle ensures that mock boundaries align with architectural boundaries. You cannot mock arbitrary modules or functions -- you must first define a behaviour (callback specification), then mock that behaviour.

# Step 1: Define the behaviour (the contract)
defmodule MyApp.HTTPClient do
  @callback get(String.t(), keyword()) :: {:ok, map()} | {:error, term()}
  @callback post(String.t(), map(), keyword()) :: {:ok, map()} | {:error, term()}
end

# Step 2: Define the mock (in test_helper.exs)
Mox.defmock(MyApp.MockHTTPClient, for: MyApp.HTTPClient)

# Step 3: Configure expectations in tests
expect(MyApp.MockHTTPClient, :get, fn url ->
  assert url =~ "api.example.com"
  {:ok, %{status: 200, body: %{"data" => "value"}}}
end)

Mox's design has several properties that mitigate common mocking pitfalls:

  1. Behaviour enforcement: Mocks must implement the same callbacks as real modules, preventing mock/real API divergence at the type level
  2. Process isolation: Each test process gets its own mock expectations, enabling concurrent test execution
  3. Verification: verify_on_exit! ensures all expected calls were made, catching under-specification
  4. Strict expectations: By default, unexpected calls raise errors, catching over-specification
  5. Allowances: allow/3 shares mock configuration across processes for async tests

Mock Drift: The Silent Killer

Mock drift occurs when a mock's behavior diverges from the real implementation it replaces. This creates tests that pass against the mock but fail against reality. Common causes:

Drift TypeExampleDetection
API driftReal API adds a required header; mock doesn't checkContract tests
Behavior driftReal implementation returns {:error, :timeout}; mock only returns {:ok, _}Error path testing
Schema driftReal response adds new fields; mock returns old schemaSchema validation
Performance driftReal implementation is slow; mock is instantIntegration tests
Side effect driftReal implementation writes logs; mock doesn'tObservability tests

The Prismatic Platform mitigates mock drift through contract tests -- tests that verify the mock's behaviour specification matches the real implementation's actual behavior:

# Contract test: verifies real and mock implement the same behaviour
defmodule MyApp.HTTPClientContractTest do
  use ExUnit.Case

  # Test against real implementation
  describe "Tesla implementation" do
    @tag :integration
    test "get/2 returns {:ok, map} for valid URLs" do
      assert {:ok, %{status: _}} = MyApp.HTTPClient.Tesla.get("https://httpbin.org/get")
    end

    test "get/2 returns {:error, _} for invalid URLs" do
      assert {:error, _} = MyApp.HTTPClient.Tesla.get("https://nonexistent.invalid")
    end
  end

  # Same tests against mock -- if these pass but real fails, you have drift
end

Alternatives to Mocking

The testing literature offers several strategies that reduce or eliminate the need for mocks:

1. Behaviour-Based Dependency Injection

The most common Prismatic Platform pattern. Define a behaviour, implement it with a real module and a test-friendly module, and swap via configuration:

# The behaviour (contract)
defmodule PrismaticOsintCore.HTTPClient.Behaviour do
  @callback get(String.t(), keyword()) :: {:ok, map()} | {:error, term()}
  @callback post(String.t(), map(), keyword()) :: {:ok, map()} | {:error, term()}
end

# Real implementation
defmodule PrismaticOsintCore.HTTPClient.Tesla do
  @behaviour PrismaticOsintCore.HTTPClient.Behaviour

  @impl true
  def get(url, opts \\ []) do
    case Tesla.get(url, opts) do
      {:ok, %Tesla.Env{status: status, body: body}} ->
        {:ok, %{status: status, body: body}}
      {:error, reason} ->
        {:error, {:http_error, reason}}
    end
  end

  @impl true
  def post(url, body, opts \\ []) do
    case Tesla.post(url, body, opts) do
      {:ok, %Tesla.Env{status: status, body: body}} ->
        {:ok, %{status: status, body: body}}
      {:error, reason} ->
        {:error, {:http_error, reason}}
    end
  end
end

# Test-friendly sandbox (a fake, not a mock)
defmodule PrismaticOsintCore.HTTPClient.Sandbox do
  @behaviour PrismaticOsintCore.HTTPClient.Behaviour

  @impl true
  def get(url, _opts) do
    case url do
      "https://api.example.com/healthy" ->
        {:ok, %{status: 200, body: %{"status" => "ok"}}}
      "https://api.example.com/error" ->
        {:ok, %{status: 500, body: %{"error" => "internal"}}}
      _ ->
        {:error, :not_found}
    end
  end

  @impl true
  def post(_url, _body, _opts) do
    {:ok, %{status: 201, body: %{"created" => true}}}
  end
end

Configuration swap:

# config/config.exs
config :prismatic_osint_core, :http_client, PrismaticOsintCore.HTTPClient.Tesla

# config/test.exs
config :prismatic_osint_core, :http_client, PrismaticOsintCore.HTTPClient.Sandbox

2. Property-Based Testing

Property-based testing with StreamData generates hundreds of random inputs, providing stronger guarantees than mock-based scenario tests for pure functions:

property "JSON round-trip preserves data" do
  check all data <- StreamData.map_of(StreamData.string(:alphanumeric), StreamData.integer()) do
    assert {:ok, ^data} = data |> Jason.encode!() |> Jason.decode!() |> then(&{:ok, &1})
  end
end

3. Ecto Sandbox

For database interactions, Ecto's SQL Sandbox provides real database access in tests with automatic rollback, eliminating the need to mock the repository:

setup do
  :ok = Ecto.Adapters.SQL.Sandbox.checkout(Prismatic.Repo)
end

test "creates an entity" do
  # Uses real database, rolled back after test
  assert {:ok, entity} = Entities.create(%{name: "Test Corp", type: "company"})
  assert entity.name == "Test Corp"
end

4. Process-Based Isolation

The BEAM's process model enables a unique testing pattern: start a real GenServer with test-specific configuration rather than mocking it:

test "worker processes messages correctly" do
  {:ok, worker} = Worker.start_link(
    source: :in_memory,
    batch_size: 1,
    handler: self()  # Send results to test process
  )

  Worker.enqueue(worker, %{type: "test_event"})

  assert_receive {:processed, %{type: "test_event"}}, 1000
end

Usage in Prismatic Platform

Zero-Mocks-in-Production Policy

The Prismatic Platform's Forbidden Patterns Enforcement explicitly blocks Mox.defmock in production code (lib/ directories). The pre-commit pipeline scans for mock-related patterns and rejects commits that introduce them outside test directories. This policy reflects the platform's NMND doctrine: production code must use real implementations, not simulated substitutes.

# Pre-commit check (Phase 8: Forbidden Patterns)
# Scans lib/ for mock-related patterns:
# - Mox.defmock
# - :meck (Erlang mocking library)
# - mock_* function definitions in lib/

Approved Testing Patterns

Instead of mocks, the platform uses three approved patterns:

  1. Behaviour-based injection: Modules depend on behaviour specifications, and the real implementation is configured via application environment or function parameters
  2. Real lightweight implementations: For external APIs, the platform provides sandbox modules that hit real test endpoints rather than simulated responses
  3. Property-based testing: StreamData generators exercise code with hundreds of random inputs, providing stronger guarantees than mock-based scenario tests

Where Mox Is Permitted

In test code (test/ directories), Mox usage is permitted for testing modules that interact with external systems where real integration would be impractical:

  • Third-party OSINT APIs with rate limits
  • Payment processors (no sandbox available)
  • Email delivery services
  • External webhook consumers
  • Rate-limited government registries (ARES, Justice.cz)

Even in these cases, the platform requires contract tests that verify the mock's behaviour specification matches the real implementation's actual behavior.

Quality Floor Enforcement

The Quality Floor Guardian monitors mock usage across the test suite. A module with more than 50% of its tests relying on mocks triggers an advisory warning. The rationale: if most tests for a module need mocks, the module likely has too many external dependencies and should be refactored to separate pure logic from I/O.

# Quality Floor mock ratio check
defmodule PrismaticSafety.MockAuditor do
  @moduledoc """
  Audits test files for mock dependency ratio.
  Warns when >50% of tests in a file use Mox expectations.
  """

  @spec audit_file(String.t()) :: {:ok, float()} | {:warning, float(), String.t()}
  def audit_file(test_file_path) do
    content = File.read!(test_file_path)
    total_tests = Regex.scan(~r/\btest\b/, content) |> length()
    mock_tests = Regex.scan(~r/\bexpect\(|stub\(/, content) |> length()

    ratio = if total_tests > 0, do: mock_tests / total_tests, else: 0.0

    if ratio > 0.5 do
      {:warning, ratio, "#{test_file_path}: #{round(ratio * 100)}% mock-dependent tests"}
    else
      {:ok, ratio}
    end
  end
end

Code Examples

Complete Behaviour-Based Architecture

# 1. Define the behaviour (contract)
defmodule PrismaticOsintCore.Source.Behaviour do
  @moduledoc """
  Behaviour for OSINT data sources.
  All adapters must implement this contract.
  """

  @callback search(map()) :: {:ok, list(map())} | {:error, term()}
  @callback health_check() :: :ok | {:error, term()}
  @callback rate_limit_status() :: {:ok, map()}
end

# 2. Real implementation
defmodule PrismaticOsintSources.CzechAres do
  @moduledoc """
  Czech ARES business registry adapter.
  """
  @behaviour PrismaticOsintCore.Source.Behaviour

  @impl true
  def search(%{query: query}) do
    http_client = Application.get_env(:prismatic_osint_core, :http_client)

    case http_client.get("https://ares.gov.cz/api/v1/search?q=#{URI.encode(query)}") do
      {:ok, %{status: 200, body: body}} ->
        {:ok, parse_results(body)}
      {:ok, %{status: status}} ->
        {:error, {:api_error, status}}
      {:error, reason} ->
        {:error, {:network_error, reason}}
    end
  end

  @impl true
  def health_check do
    http_client = Application.get_env(:prismatic_osint_core, :http_client)

    case http_client.get("https://ares.gov.cz/api/v1/health") do
      {:ok, %{status: 200}} -> :ok
      _ -> {:error, :unhealthy}
    end
  end

  @impl true
  def rate_limit_status do
    {:ok, %{remaining: 100, reset_at: DateTime.utc_now() |> DateTime.add(60, :second)}}
  end

  defp parse_results(body) when is_map(body) do
    Map.get(body, "results", [])
  end
end

# 3. Test with Mox (in test/ only)
defmodule PrismaticOsintSources.CzechAresTest do
  use ExUnit.Case
  import Mox

  setup :verify_on_exit!

  describe "search/1" do
    test "returns parsed results on success" do
      expect(MockHTTPClient, :get, fn url ->
        assert url =~ "ares.gov.cz"
        {:ok, %{status: 200, body: %{"results" => [%{"ico" => "12345"}]}}}
      end)

      assert {:ok, [%{"ico" => "12345"}]} =
        PrismaticOsintSources.CzechAres.search(%{query: "test"})
    end

    test "returns error on API failure" do
      expect(MockHTTPClient, :get, fn _url ->
        {:ok, %{status: 500, body: %{}}}
      end)

      assert {:error, {:api_error, 500}} =
        PrismaticOsintSources.CzechAres.search(%{query: "test"})
    end

    test "handles network errors" do
      expect(MockHTTPClient, :get, fn _url ->
        {:error, :timeout}
      end)

      assert {:error, {:network_error, :timeout}} =
        PrismaticOsintSources.CzechAres.search(%{query: "test"})
    end
  end
end

Anti-Pattern: What NOT to Do

# ❌ FORBIDDEN: Mock in production code
defmodule PrismaticWeb.SomeController do
  # This would be caught by pre-commit Phase 8
  @http_client Mox.defmock(MockClient, for: HTTPClient)  # ❌ NMND violation

  def index(conn, _params) do
    # ...
  end
end

# ❌ ANTI-PATTERN: Mocking internal modules
# Don't mock modules you own -- refactor instead
expect(MockUserService, :get_user, fn id -> %{id: id, name: "Test"} end)
# If you need to mock UserService, it's a sign that UserService
# does too much (violates single responsibility)

# ❌ ANTI-PATTERN: Over-specified mock expectations
expect(MockDB, :query, fn "SELECT * FROM users WHERE id = $1", [42] ->
  {:ok, [%{id: 42, name: "Alice"}]}
end)
# This test breaks if you change the SQL, even if the result is correct
# Prefer testing the output, not the query

# ✅ CORRECT: Test the behavior, not the implementation
insert_user(%{id: 42, name: "Alice"})
assert %{name: "Alice"} = UserService.get_user(42)

When Mocking Is Justified

Despite the platform's strong anti-mock stance, there are legitimate cases where mocking is the pragmatic choice:

ScenarioWhy MockAlternative (if available)
Third-party API with rate limitsCan't hit real API in CIRecord/replay (VCR cassettes)
Payment processorCan't charge real cardsProcessor's test mode
Email deliveryDon't want to send real emailsBamboo test adapter
Clock/timeNeed deterministic timestampsClock behaviour
Random number generationNeed reproducible testsSeeded PRNG
External webhooksCan't receive real webhooksLocal webhook receiver
Government APIs (ARES, OR)Rate-limited, occasionally downCached responses + contract tests

The decision framework: mock at system boundaries, test with real implementations within boundaries. If the dependency is inside your system (another module, a GenServer, a database), prefer real implementations. If it's outside your system (external HTTP API, email service, payment processor), mocking is acceptable.

Historical Context

The debate over mocking has a long history in software testing:

  • 2004: Martin Fowler publishes "Mocks Aren't Stubs," distinguishing classicist (state-based) and mockist (interaction-based) testing schools
  • 2007: Growing Object-Oriented Software, Guided by Tests (GOOS) popularizes mock-driven TDD in Java
  • 2010s: The pendulum swings back -- "Don't Mock What You Don't Own" becomes accepted wisdom
  • 2017: Jose Valim publishes "Mocks and explicit contracts" for Elixir, establishing the behaviour-first pattern that Mox implements
  • 2020s: The Elixir community largely converges on behaviour injection + Ecto Sandbox + property testing as the preferred testing strategy, with Mox reserved for system boundaries

The Prismatic Platform's anti-mock stance reflects the mature Elixir community consensus: the BEAM's process model, Ecto's sandbox, and behaviour-based injection make most mocking unnecessary. The remaining cases (external APIs) are handled by Mox with contract testing to prevent drift.

Best Practices

Use behaviour-based dependency injection as the primary mechanism for swappable dependencies -- this enables testing without mocks while maintaining clean architecture. When mocks are necessary in tests, mock only at architectural boundaries (HTTP clients, database adapters), never at internal module boundaries.

Verify mock expectations with verify_on_exit! to ensure expected calls actually occur. Write contract tests that verify mock specifications match real implementation behavior. Prefer property-based tests over mock-based scenario tests for pure functions.

Never mock time -- use a Clock behaviour with a real implementation and a controllable test implementation. Never mock the database -- use Ecto SQL Sandbox for real database access with automatic rollback.

Keep mock expectations minimal: specify only what the test needs to verify. Over-specified expectations (exact argument matching, call order assertions) make tests brittle. Prefer stub/3 over expect/3 when you don't need to verify the call was made.

Monitor your mock-to-real ratio: if more than 30% of your test assertions involve mocks, the codebase likely needs architectural refactoring to reduce external dependencies in business logic.

  • Behaviour -- Elixir callback specification that defines mockable contracts
  • Property Test -- testing approach preferred over mocking for pure functions
  • Mutation Testing -- test quality assessment that reveals mock-masking issues
  • Quality Floor -- quality standard that forbids production mocks
  • ExUnit -- Elixir's testing framework that integrates with Mox
  • Test Coverage -- metric that high-mock tests inflate artificially
  • Integration Test -- tests that exercise real dependencies, catching what mocks miss
  • Adapter Pattern -- pattern that enables behaviour-based injection
  • Dependency Injection -- design pattern that makes mocking possible
  • GenServer -- OTP pattern tested with process isolation rather than mocks
  • Protocol -- Elixir dispatch mechanism that serves as an alternative to behaviour-based mocking
  • Assertion -- the state-based verification alternative to mock-based interaction testing
  • Sandbox -- test-friendly environment that replaces mocking for database access
  • Ecto -- database wrapper whose SQL Sandbox eliminates database mocking

See Also


Connect & Contribute

Created by Tomas Korcak (korczis) | Open Source under GHL