05 Module mocks 6 chapters

Theme 03 · Anti-Incidental Complexity

Module mocks

Explanation

Module mocks

Plain Human Explanation

Mocking a whole module can make a test pass by replacing the exact wiring you meant to trust. The test proves your mock was called, but not that the real code still connects correctly.

This matters most when the module boundary carries product behavior: email content, payment calls, telemetry events, storage writes, or webhook parsing. If the test removes the boundary, it may miss the bug users would feel.

Technical Explanation

In TypeScript test suites, module mocks often replace imported files with vi.mock, jest.mock, or a hand-built object that does not share the real contract. That can hide renamed exports, changed payload shapes, and incorrect integration order.

Prefer a small explicit dependency when the code needs a seam. A seam is the place where production code accepts a dependency that tests can replace. Good seams are narrow and typed, such as EmailSender or PaymentGateway, rather than replacing a whole module namespace.

Why It Matters

  • User impact: tests catch broken emails, payment requests, and telemetry payloads before customers or support teams do.
  • Product behavior: tests exercise the real composition of the workflow.
  • Risk: broad module mocks can drift away from production code and create false confidence.
  • Decision point: mock the smallest outside effect you need; keep the workflow and payload-building code real.

The Core Move

Replace whole-module mocks with small typed seams, local fakes, or real lightweight substitutes. The test should verify the behavior your users depend on, not only that a mock function was called.

Small Example

Module mocks: Small Example

Bad TypeScript Example

import { sendEvent } from "./telemetry";

export function trackSignup(userId: string) {
  sendEvent("signup", { userId });
}

vi.mock("./telemetry", () => ({
  sendEvent: vi.fn(),
}));

it("tracks signup", () => {
  trackSignup("user_123");
  expect(sendEvent).toHaveBeenCalled();
});
import { sendEvent } from "./telemetry";

export function trackSignup(
  userId: string,
) {
  sendEvent("signup", {
    userId,
  });
}

vi.mock("./telemetry", () => ({
  sendEvent: vi.fn(),
}));

it("tracks signup", () => {
  trackSignup("user_123");

  expect(sendEvent).toHaveBeenCalled();
});

Good TypeScript Example

type SignupEvent = {
  name: "signup";
  userId: string;
};

type Telemetry = {
  track(event: SignupEvent): void;
};

export function trackSignup(userId: string, telemetry: Telemetry) {
  telemetry.track({ name: "signup", userId });
}

it("tracks the signup payload", () => {
  const events: SignupEvent[] = [];

  trackSignup("user_123", {
    track(event) {
      events.push(event);
    },
  });

  expect(events).toEqual([{ name: "signup", userId: "user_123" }]);
});
type SignupEvent = {
  name: "signup";
  userId: string;
};

type Telemetry = {
  track(event: SignupEvent): void;
};

export function trackSignup(
  userId: string,
  telemetry: Telemetry,
) {
  telemetry.track({
    name: "signup",
    userId,
  });
}

it("tracks the signup payload", () => {
  const events: SignupEvent[] = [];

  trackSignup("user_123", {
    track(event) {
      events.push(event);
    },
  });

  expect(events).toEqual([
    {
      name: "signup",
      userId: "user_123",
    },
  ]);
});

What Changed

  • The bad test replaces the telemetry module and only checks that something was called.
  • The good version passes a tiny telemetry dependency and asserts the actual event payload.
  • The workflow code remains real, so a renamed event or missing user id fails the test.

Realistic Example

Module mocks: Realistic Example

This example uses receipt emails. A whole-module mock can let the test pass even when the real email payload becomes unhelpful.

Bad TypeScript Example

import { sendEmail } from "./email";

type PaidOrder = {
  id: string;
  customerEmail: string;
  totalCents: number;
};

export async function sendReceipt(order: PaidOrder) {
  await sendEmail(order.customerEmail, "Order update", String(order.totalCents));
}

vi.mock("./email", () => ({ sendEmail: vi.fn() }));

it("sends a receipt", async () => {
  await sendReceipt({ id: "ord_1", customerEmail: "a@example.com", totalCents: 2500 });
  expect(sendEmail).toHaveBeenCalled();
});
import { sendEmail } from "./email";

type PaidOrder = {
  id: string;
  customerEmail: string;
  totalCents: number;
};

export async function sendReceipt(
  order: PaidOrder,
) {
  await sendEmail(
    order.customerEmail,
    "Order update",
    String(order.totalCents),
  );
}

vi.mock("./email", () => ({
  sendEmail: vi.fn(),
}));

it("sends a receipt", async () => {
  await sendReceipt({
    id: "ord_1",
    customerEmail: "a@example.com",
    totalCents: 2500,
  });

  expect(sendEmail).toHaveBeenCalled();
});

Good TypeScript Example

type PaidOrder = {
  id: string;
  customerEmail: string;
  totalCents: number;
};

type ReceiptEmail = {
  to: string;
  subject: string;
  body: string;
};

type EmailSender = {
  send(message: ReceiptEmail): Promise<void>;
};

function formatUsd(cents: number) {
  return `$${(cents / 100).toFixed(2)}`;
}

export async function sendReceipt(order: PaidOrder, email: EmailSender) {
  await email.send({
    to: order.customerEmail,
    subject: `Receipt for order ${order.id}`,
    body: `Your total was ${formatUsd(order.totalCents)}.`,
  });
}

it("sends the receipt content customers need", async () => {
  const sent: ReceiptEmail[] = [];

  await sendReceipt(
    { id: "ord_1", customerEmail: "a@example.com", totalCents: 2500 },
    { async send(message) { sent.push(message); } },
  );

  expect(sent).toEqual([
    {
      to: "a@example.com",
      subject: "Receipt for order ord_1",
      body: "Your total was $25.00.",
    },
  ]);
});
type PaidOrder = {
  id: string;
  customerEmail: string;
  totalCents: number;
};

type ReceiptEmail = {
  to: string;
  subject: string;
  body: string;
};

type EmailSender = {
  send(
    message: ReceiptEmail,
  ): Promise<void>;
};

function formatUsd(cents: number) {
  return `$${(cents / 100).toFixed(2)}`;
}

export async function sendReceipt(
  order: PaidOrder,
  email: EmailSender,
) {
  await email.send({
    to: order.customerEmail,
    subject: `Receipt for order ${order.id}`,
    body: `Your total was ${formatUsd(order.totalCents)}.`,
  });
}

it("sends the receipt content customers need", async () => {
  const sent: ReceiptEmail[] = [];

  await sendReceipt(
    {
      id: "ord_1",
      customerEmail: "a@example.com",
      totalCents: 2500,
    },
    {
      async send(message) {
        sent.push(message);
      },
    },
  );

  expect(sent).toEqual([
    {
      to: "a@example.com",
      subject: "Receipt for order ord_1",
      body: "Your total was $25.00.",
    },
  ]);
});

What Changed

  • The bad test would still pass if the subject became vague or the body lost the formatted total.
  • The good version keeps the email-building code real and replaces only the final send effect.
  • The fake captures the customer-facing message, which is the behavior the product cares about.

System Example

Module mocks: System Example

At system scale, module mocks can hide broken integration between payment calls, local persistence, and webhooks. A small gateway seam keeps the real workflow testable.

Larger System-Level Bad TypeScript Example

import * as paymentProvider from "./paymentProvider";

type CheckoutRequest = {
  userId: string;
  priceId: string;
};

type CheckoutStore = {
  savePendingCheckout(row: { userId: string; sessionId: string }): Promise<void>;
};

export async function startCheckout(request: CheckoutRequest, store: CheckoutStore) {
  const session = await paymentProvider.createCheckoutSession(request.userId, request.priceId);
  await store.savePendingCheckout({ userId: request.userId, sessionId: session.id });
  return session.url;
}

vi.mock("./paymentProvider", () => ({
  createCheckoutSession: vi.fn().mockResolvedValue({ id: "sess_1", url: "/checkout" }),
}));

it("starts checkout", async () => {
  const store = { savePendingCheckout: vi.fn() };
  await expect(startCheckout({ userId: "user_1", priceId: "pro" }, store)).resolves.toBe("/checkout");
});
import * as paymentProvider from "./paymentProvider";

type CheckoutRequest = {
  userId: string;
  priceId: string;
};

type CheckoutStore = {
  savePendingCheckout(row: {
    userId: string;
    sessionId: string;
  }): Promise<void>;
};

export async function startCheckout(
  request: CheckoutRequest,
  store: CheckoutStore,
) {
  const session =
    await paymentProvider.createCheckoutSession(
      request.userId,
      request.priceId,
    );

  await store.savePendingCheckout({
    userId: request.userId,
    sessionId: session.id,
  });

  return session.url;
}

vi.mock("./paymentProvider", () => ({
  createCheckoutSession: vi
    .fn()
    .mockResolvedValue({
      id: "sess_1",
      url: "/checkout",
    }),
}));

it("starts checkout", async () => {
  const store = {
    savePendingCheckout: vi.fn(),
  };

  await expect(
    startCheckout(
      {
        userId: "user_1",
        priceId: "pro",
      },
      store,
    ),
  ).resolves.toBe("/checkout");
});

Larger System-Level Good TypeScript Example

type CheckoutRequest = {
  userId: string;
  priceId: string;
};

type CheckoutSession = {
  id: string;
  url: string;
};

type PaymentGateway = {
  createCheckoutSession(request: CheckoutRequest): Promise<CheckoutSession>;
};

type CheckoutStore = {
  savePendingCheckout(row: { userId: string; sessionId: string }): Promise<void>;
};

export async function startCheckout(request: CheckoutRequest, payment: PaymentGateway, store: CheckoutStore) {
  const session = await payment.createCheckoutSession(request);
  await store.savePendingCheckout({ userId: request.userId, sessionId: session.id });
  return session.url;
}

it("stores the checkout session returned by the gateway", async () => {
  const saved: Array<{
    userId: string;
    sessionId: string;
  }> = [];
  const payment: PaymentGateway = {
    async createCheckoutSession() {
      return { id: "sess_1", url: "/checkout" };
    },
  };
  const store: CheckoutStore = {
    async savePendingCheckout(row) {
      saved.push(row);
    },
  };

  const url = await startCheckout({ userId: "user_1", priceId: "pro" }, payment, store);

  expect(url).toBe("/checkout");
  expect(saved).toEqual([{ userId: "user_1", sessionId: "sess_1" }]);
});
type CheckoutRequest = {
  userId: string;
  priceId: string;
};

type CheckoutSession = {
  id: string;
  url: string;
};

type PaymentGateway = {
  createCheckoutSession(
    request: CheckoutRequest,
  ): Promise<CheckoutSession>;
};

type CheckoutStore = {
  savePendingCheckout(row: {
    userId: string;
    sessionId: string;
  }): Promise<void>;
};

export async function startCheckout(
  request: CheckoutRequest,
  payment: PaymentGateway,
  store: CheckoutStore,
) {
  const session =
    await payment.createCheckoutSession(
      request,
    );

  await store.savePendingCheckout({
    userId: request.userId,
    sessionId: session.id,
  });

  return session.url;
}

it("stores the checkout session returned by the gateway", async () => {
  const saved: Array<{
    userId: string;
    sessionId: string;
  }> = [];

  const payment: PaymentGateway = {
    async createCheckoutSession() {
      return {
        id: "sess_1",
        url: "/checkout",
      };
    },
  };

  const store: CheckoutStore = {
    async savePendingCheckout(row) {
      saved.push(row);
    },
  };

  const url = await startCheckout(
    {
      userId: "user_1",
      priceId: "pro",
    },
    payment,
    store,
  );

  expect(url).toBe("/checkout");

  expect(saved).toEqual([
    {
      userId: "user_1",
      sessionId: "sess_1",
    },
  ]);
});

What Changed

  • The bad test mocks the whole payment module and mostly proves the mock setup works.
  • The good version defines the gateway contract the workflow actually needs.
  • The test keeps checkout orchestration real and verifies that the returned session is persisted.

When To Use It

Module mocks: When To Use It

Use This When

  • A test mocks the same module that contains the behavior you are trying to trust.
  • The mock only verifies that a function was called, not the payload or user-visible result.
  • The real module boundary has drift risk, such as payments, email, telemetry, storage, or webhooks.

Avoid This When

  • A third-party SDK is slow, flaky, or requires credentials and is already covered by a smaller adapter contract.
  • The mock is at the outermost edge and the workflow payload is still built by real code.
  • A local fake would be more complex than the behavior under test.

Tradeoffs

Small seams take a little design work, but they keep tests honest. The seam should be narrower than the module it replaces. If the fake has to reimplement half the system, the boundary is too large.

  • Shallow abstractions
  • Vague helpers
  • Mega-services

Practice Prompt

Module mocks: Practice Prompt

Beginner Exercise

Find a test that uses a whole-module mock. Write down the real behavior the test is supposed to prove.

Intermediate Exercise

Replace one module mock with a small typed dependency passed into the function under test. Capture calls in a local fake.

Stretch Exercise

Add an assertion on the actual payload or persisted state, not just that a mock function was called.

Reflection Question

Would this test fail if the production wiring changed in a way customers would notice?

Suggest an edit

Leave a private editorial note. This creates a GitHub issue for this curriculum page.