Experiments
Feature Flags

What is mock testing? A complete guide for developers (2026)

A graphic of a bar chart with an arrow pointing upward.

A mock can make a test fast and deterministic while letting the real integration break unnoticed.

That tension explains both the value and the reputation of mock testing. Replacing a payment API, database, clock, or feature service with a controlled double lets you force success, failure, timeout, and retry paths in milliseconds. But the substitute only behaves as accurately as the test author programmed it to behave.

Mock testing works best at a deliberate boundary. Use a mock when the interaction itself matters, a stub when you need a canned answer, and a fake when a lightweight working implementation makes the test clearer. Then pair those isolated tests with contract and integration coverage so production reality still gets a vote.

This guide uses TypeScript and Vitest examples, but the design choices apply across Jest, pytest, Mockito, Go interfaces, and other testing stacks.

Mock testing controls a collaborator and verifies the conversation

A test double is any non-production object used in place of a real dependency. Martin Fowler's test-double taxonomy distinguishes dummies, fakes, stubs, spies, and mocks. Teams often call all of them “mocks,” but the distinctions clarify what each test proves.

Mocks test observable interactions

A mock is preprogrammed with behavior and records or enforces expectations about calls. It answers questions such as:

  • Did the service publish an event after committing the order?
  • Was the payment gateway called once with the correct idempotency key?
  • Did the retry loop stop after the first successful response?
  • Was no email sent when validation failed?

This is behavior verification. The assertion concerns the messages exchanged with a collaborator, not only the final state of the system under test.

The Vitest mock-function documentation exposes both sides: a vi.fn() can return configured values and retain its call history. Jest provides the same core pattern through 1.

Stubs supply answers; spies observe calls

A stub returns a canned response needed to exercise the unit. It may return an account, throw a timeout, or report that inventory is empty. The test normally asserts the state or return value produced by the system under test.

A spy wraps or replaces behavior while recording how it was called. Framework APIs blur these terms because a single function object can act as stub, spy, or mock depending on the assertion. Name the role in the test: paymentGatewayStub, sendEmailSpy, or clockFake communicates more than mockService.

Fakes implement a simplified working system

A fake has real behavior but takes a shortcut unsuitable for production. An in-memory repository can support insert, query, and uniqueness rules without running Postgres. A fake queue can preserve ordering and retries without a broker.

Fakes often reduce test setup and implementation coupling. The tradeoff is maintenance: the fake must stay behaviorally compatible with production. Android's official test-double guidance recommends checking whether a library supplies supported fakes before inventing one.

DoubleWhat it doesTypical assertionGood use
DummyFills an unused parameterNoneRequired context object
StubReturns configured answersResulting state or valueError and edge cases
SpyRecords calls, often keeping behaviorCall historyTelemetry or callback checks
MockSimulates behavior and verifies interactionsExpected message or callCoordination with side effects
FakeImplements a lightweight working substituteState and behaviorIn-memory repository or clock

Test releases behind flags

Learn how to structure feature flag ownership, observability, and cleanup so testable release controls do not become permanent debt.

Read the Feature Flag Guide

Start with a seam, not a mocking framework

A seam is a place where code can receive another implementation. Constructor parameters, function arguments, interfaces, adapters, and dependency-injection containers all create seams. A clean seam keeps tests focused and makes production dependencies replaceable for reasons beyond testing.

Inject the dependency your unit actually needs

Consider checkout coordination. The use case needs a gateway that can charge a payment. It does not need to know which HTTP client, authentication library, or vendor SDK implements the call.

exporttype Charge = {
  orderId: string;
  amountCents: number;
  idempotencyKey: string;
};

exportinterface PaymentGateway {
  charge(input: Charge): Promise<{ transactionId: string }>;
}

exportasyncfunction completeCheckout(
  gateway: PaymentGateway,
  input: Charge,
) {
  if (input.amountCents <= 0) thrownew Error("invalid amount");
  const result = await gateway.charge(input);
  return { orderId: input.orderId, paid: true, ...result };
}

The interface is small because it describes the capability the use case consumes. It prevents a unit test from mocking an entire vendor SDK, including methods the code never calls.

Configure the smallest behavior needed by the case

Now test the observable result and the critical side-effect contract:

import { expect, it, vi } from"vitest";
import { completeCheckout, type PaymentGateway } from"./checkout";

it("charges once with a stable idempotency key", async () => {
  const charge = vi.fn().mockResolvedValue({ transactionId: "tx_test_42" });
  const gateway: PaymentGateway = { charge };

  const result = await completeCheckout(gateway, {
    orderId: "order_42",
    amountCents: 2500,
    idempotencyKey: "checkout:order_42",
  });

  expect(result).toEqual({
    orderId: "order_42",
    paid: true,
    transactionId: "tx_test_42",
  });
  expect(charge).toHaveBeenCalledOnce();
  expect(charge).toHaveBeenCalledWith({
    orderId: "order_42",
    amountCents: 2500,
    idempotencyKey: "checkout:order_42",
  });
});

The return-value assertion protects the public behavior. The interaction assertion protects a meaningful external contract: a charge must happen once with an idempotency key. Avoid asserting incidental steps, such as which helper formatted the key, unless that detail is itself part of the boundary contract.

Force failures that are unsafe or slow to reproduce

Mocks are particularly useful for rare branches:

it("does not report a paid order when the gateway rejects", async () => {
  const gateway: PaymentGateway = {
    charge: vi.fn().mockRejectedValue(new Error("gateway unavailable")),
  };

  await expect(
    completeCheckout(gateway, {
      orderId: "order_43",
      amountCents: 2500,
      idempotencyKey: "checkout:order_43",
    }),
  ).rejects.toThrow("gateway unavailable");
});

This test needs no real outage and cannot charge a card. Add separate cases for timeouts, duplicate responses, invalid payloads, and retry exhaustion when your production policy distinguishes them.

Mock boundaries, not your own business rules

The best candidates are dependencies whose real behavior makes a focused test slow, flaky, destructive, expensive, or impossible to control.

Good mock targets have operational side effects

Common boundaries include:

  • Payment, email, SMS, and push providers.
  • System clocks, random-number generators, and schedulers.
  • Cloud APIs, object stores, queues, and search services.
  • Network failures, rate limits, timeouts, and malformed responses.
  • Analytics and exposure callbacks whose payload contract matters.
  • Feature evaluation at the edge of application logic.

For HTTP behavior, prefer a network-level tool when the request itself matters. Mock Service Worker intercepts REST and GraphQL requests independently of the application's request client. Playwright API mocking can intercept browser traffic, replay HAR data, and verify UI behavior. These tests exercise serialization and routing that a mocked fetch() wrapper might bypass.

Keep deterministic domain objects real

Value objects, parsers, pricing rules, eligibility policies, and other deterministic domain code are usually cheap to construct. Mocking them replaces the behavior you most need to test. Use real objects and assert meaningful outcomes.

A suite with 8 mocks for one method often signals one of 3 design problems:

  1. The unit coordinates too many responsibilities.
  2. The test boundary is smaller than the behavior anyone cares about.
  3. Global imports or singletons make dependencies hard to substitute.

Vitest's current module-mocking guide explicitly calls out limitations around mocking methods used inside the same module and recommends dependency injection or refactoring. Treat that friction as architecture feedback, not as a puzzle to defeat with more tooling.

Test state when the outcome matters more than the conversation

Interaction assertions couple a test to how work happens. A refactor that preserves behavior but combines 2 repository calls into 1 can break dozens of mock expectations. Prefer state verification when callers care about the result rather than the sequence.

Fowler's classic “Mocks Aren't Stubs” essay frames this as behavior versus state verification and explains the broader mockist and classical testing styles. You do not need to choose a camp. Make the choice per boundary.

Test feature-flagged code at three layers

Feature flags add a decision boundary: the same code path can produce multiple experiences based on attributes, configuration, and environment. Tests need to cover local branch behavior, SDK wiring, and the assembled product experience.

Unit-test branch behavior through a narrow reader

Do not make domain code depend on a global SDK object. Inject the capability it needs:

exportinterface FlagReader {
  enabled(key: string): boolean;
}

exportfunction priceSummary(flags: FlagReader, totalCents: number) {
  if (flags.enabled("compact-checkout")) {
    return`$${(totalCents / 100).toFixed(2)}`;
  }
  return`Order total: $${(totalCents / 100).toFixed(2)}`;
}

A tiny fake is clearer than a framework mock:

import { expect, it } from"vitest";

const flagsOn = { enabled: () => true };
const flagsOff = { enabled: () => false };

it("renders both checkout variants", () => {
  expect(priceSummary(flagsOff, 2500)).toBe("Order total: $25.00");
  expect(priceSummary(flagsOn, 2500)).toBe("$25.00");
});

These tests prove the application's branch logic. They do not prove that production attributes, flag rules, and SDK initialization select the branch correctly.

Integration-test the real evaluation contract

Add tests around your adapter using the real SDK with deterministic local configuration. Cover default values, missing attributes, targeting rules, percentage assignment, and the event or callback that records experiment exposure. The GrowthBook SDK documentation is the source of truth for supported language behavior, while feature flag experiments explain how evaluation becomes measured assignment.

Keep SDK-specific test helpers in the adapter package. When a library changes configuration or evaluation semantics, a small contract suite should fail before dozens of business tests do.

Exercise complete variants before release

Use end-to-end tests for the critical user paths in both states. GrowthBook's DevTools Extension can inspect evaluations, override feature values and attributes, and help developers reproduce specific experiences. This complements automated tests; it does not replace assertions in continuous integration.

The feature flags product supports targeted and gradual releases, while the experimentation workflow measures impact. Test that control exists before relying on either: default behavior, rollback path, exposure logging, and cleanup ownership all need coverage.

Prevent mocks from becoming a second production system

Mock-heavy suites tend to fail in predictable ways. The solution is not banning mocks. It is making their contract and scope explicit.

Reset state and avoid global leakage

Mocks retain implementations and call histories unless the runner restores them. Use lifecycle hooks or runner configuration consistently. Vitest warns developers to clear or restore mock state between tests in its mocking guide, and Jest distinguishes mockClear, mockReset, and mockRestore because they remove different things.

Run tests in random order periodically. A test that only passes after another test configured a global mock is not isolated. Prefer locally constructed dependencies over process-wide replacements.

Keep mock contracts honest

Every mock contains an assumption about production. Protect important assumptions with:

  • Consumer-driven contract tests for service boundaries.
  • Schema validation for recorded fixtures.
  • Integration tests against a disposable database or sandbox.
  • Scheduled refreshes for HAR files and response fixtures.
  • A small smoke suite against real third-party test environments.

If production adds a required field and your mock continues returning the old shape, isolated tests remain green. A contract test should expose the drift.

Assert outcomes before incidental calls

Start each test with the behavior a caller cares about. Add interaction expectations only for externally meaningful effects, ordering, idempotency, security, or compliance. Avoid assertions such as “helper A was called before helper B” when the order has no user-visible or contractual meaning.

Use mutation testing or a deliberate fault to check whether the assertion can fail for the right reason. A mock that returns exactly the value later asserted, without exercising transformation or policy, may test the fixture more than the code.

Escalate to a broader test when setup tells a story

If a unit test needs a page of mock configuration, try an in-memory fake or component test. Fowler's microservice testing guidance notes that too many doubles can signal a concept that should be extracted or a component boundary that would provide more value.

The target is not a particular ratio. It is fast local feedback plus enough real integration coverage to detect false assumptions.

Use mocks where control is valuable and realism is replaceable

Before replacing a dependency, ask 5 questions:

  1. Is the real collaborator slow, nondeterministic, destructive, costly, or hard to force into the needed state?
  2. Does this test care about the collaborator's answer, the interaction, or a larger outcome?
  3. Would a stub or fake express the case with less coupling?
  4. Which contract or integration test will detect drift from production?
  5. Will the test survive an internal refactor that preserves behavior?

Mock testing is successful when it buys control without hiding the system. Keep the seam small, configure only the behavior the case needs, assert externally meaningful outcomes, and verify important assumptions against reality elsewhere in the suite.

For feature-flagged delivery, that means unit-testing both application branches, contract-testing the SDK adapter, and exercising the assembled experiences before expanding traffic. GrowthBook can support the release and measurement layer, but the reliability begins with code that remains testable when every external service is unavailable.

Ship testable changes safely

Start with feature flags and experimentation in one workflow, then expand exposure only after your automated and runtime checks agree.

Start for Free

Table of Contents

Related Articles

See All Articles
Experiments

The SQL behind an A/B test: Writing experiment queries in Snowflake

Sep 9, 2026
x
min read
Experiments
Analytics

A/B testing with Mixpanel data: A practical guide

Sep 8, 2026
x
min read
Experiments
Analytics

How to run A/B tests on your Postgres data

Sep 7, 2026
x
min read

Ready to ship faster?

No credit card required. Start with feature flags, experimentation, and product analytics—free.

Simplified white illustration of a right angle ruler or carpenter's square tool.White checkmark symbol with a scattered pixelated effect around its edges on a transparent background.