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.
Related Concepts
- 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.