Typing Mocks & Test Doubles

Advanced · 18 min read · ▶ live playground · ✦ checkpoint

Mocks are test doubles with memory: they can stand in for a dependency, return controlled values, and record how they were called. In TypeScript, the useful rule is simple: a mock should keep the same shape as the thing it replaces, or it stops protecting the test.

This is another tool-shaped lesson. Vitest is not installed in this page's playground, so every Vitest example below is read-only reference code for a real project.

Typed mock functions with vi.fn

Use vi.fn when you need a standalone function double: a callback, an injected dependency, or a small service function passed into the code under test. Give the mock the same function type as the dependency it replaces.

Here is a small production module, shown as read-only reference code:

export type User = {
  id: string;
  email: string;
  name: string;
  plan: "free" | "pro";
};
 
export type EmailMessage = {
  to: string;
  subject: string;
  body: string;
};
 
export type DeliveryReceipt = {
  id: string;
  accepted: boolean;
};
 
export type SendEmail = (
  message: EmailMessage,
) => Promise<DeliveryReceipt>;
 
export async function sendWelcomeEmail(
  user: User,
  sendEmail: SendEmail,
): Promise<string> {
  const receipt = await sendEmail({
    to: user.email,
    subject: "Welcome, " + user.name,
    body: "Your " + user.plan + " workspace is ready.",
  });
 
  return receipt.id.toUpperCase();
}

The test can mock only the dependency boundary. Read-only reference code:

import { expect, test, vi } from "vitest";
import {
  sendWelcomeEmail,
  type SendEmail,
  type User,
} from "./email";
 
test("sends a welcome email through the injected sender", async () => {
  const sendEmail = vi.fn<SendEmail>();
 
  sendEmail.mockResolvedValue({
    id: "msg_123",
    accepted: true,
  });
 
  const user: User = {
    id: "user_1",
    email: "mina@example.com",
    name: "Mina",
    plan: "pro",
  };
 
  const receiptId = await sendWelcomeEmail(user, sendEmail);
 
  expect(receiptId).toBe("MSG_123");
  expect(sendEmail).toHaveBeenCalledWith({
    to: "mina@example.com",
    subject: "Welcome, Mina",
    body: "Your pro workspace is ready.",
  });
});

The generic matters because it connects every mock helper back to SendEmail. mockResolvedValue must resolve to a DeliveryReceipt. Calls to sendEmail must use an EmailMessage. The test double is still fake at runtime, but its contract stays real at compile time.

If the real dependency is easy to inject, prefer this shape. It is boring, local, and hard to misunderstand: the function under test receives a dependency, and the test passes a typed double.

Mock modules without losing types

Sometimes the code under test imports a module directly instead of receiving a function parameter. Vitest can mock that module, and vi.mocked lets you work with the imported function as a mocked function without throwing away its original type.

Read-only reference code:

import { afterEach, expect, test, vi } from "vitest";
import { createSignup } from "./signup";
import { sendEmail } from "./mailer";
 
vi.mock(import("./mailer"), () => ({
  sendEmail: vi.fn(),
}));
 
afterEach(() => {
  vi.clearAllMocks();
});
 
test("emails the new user after signup", async () => {
  vi.mocked(sendEmail).mockResolvedValue({
    id: "msg_456",
    accepted: true,
  });
 
  await createSignup({
    email: "ada@example.com",
    name: "Ada",
    plan: "free",
  });
 
  expect(sendEmail).toHaveBeenCalledWith({
    to: "ada@example.com",
    subject: "Welcome, Ada",
    body: "Your free workspace is ready.",
  });
});

Read that in three steps.

First, vi.mock(import("./mailer"), ...) tells Vitest to replace the module before the test imports are evaluated. Vitest's module mocks are hoisted, so keep the factory self-contained.

Second, the factory returns the exports the module should provide for this test file. Here the real sendEmail export becomes vi.fn().

Third, vi.mocked(sendEmail) gives TypeScript a mocked view of the imported function, so mockResolvedValue is checked against the real function's promise result. You get mock helper methods without widening the dependency to "whatever the test feels like today."

Use module mocks for code that really imports a dependency. Use injected typed functions when the design already gives you a seam. The goal is not "mock everything"; the goal is "replace the slow, external, or hard-to-trigger dependency while keeping its contract."

The unsafe cast mock

The worst mock is the one that silences TypeScript to make a test compile. You will see this in real code:

Read-only reference code:

import { expect, test, vi } from "vitest";
import {
  sendWelcomeEmail,
  type SendEmail,
  type User,
} from "./email";
 
test("unsafe mock hides a broken receipt shape", async () => {
  const unsafeSendEmail = vi
    .fn()
    .mockResolvedValue({ messageId: "msg_999" }) as unknown as SendEmail;
 
  const user: User = {
    id: "user_2",
    email: "grace@example.com",
    name: "Grace",
    plan: "pro",
  };
 
  await expect(sendWelcomeEmail(user, unsafeSendEmail)).rejects.toThrow(
    "Cannot read properties of undefined",
  );
});

That cast is not "extra TypeScript." It is the checker being told to stop asking questions twice: first into unknown, then into the target type. In test code, this is especially costly because tests are supposed to document how collaborators behave. A lying mock documents the wrong collaborator.

Prefer one of the typed shapes instead:

  • vi.fn<SendEmail>() for a standalone function double.
  • vi.mocked(sendEmail) after a module mock.
  • A small object checked with satisfies when replacing a service-shaped object.

If the real shape is painful to satisfy, that is a design signal. Either the dependency surface is too wide for the unit you are testing, or the test should move up a level and exercise more real code.

Builder factories for test data

Mocks replace behavior. Factories build data. Keep those jobs separate.

The builder pattern from 16.1 becomes more useful when tests need several valid users with tiny differences. Read-only reference code:

import { expect, test, vi } from "vitest";
import {
  sendWelcomeEmail,
  type SendEmail,
  type User,
} from "./email";
 
const baseUser = {
  id: "user_1",
  email: "mina@example.com",
  name: "Mina",
  plan: "free",
} satisfies User;
 
function makeUser(overrides: Partial<User> = {}): User {
  return {
    ...baseUser,
    ...overrides,
  };
}
 
test("mentions the user's current plan", async () => {
  const sendEmail = vi.fn<SendEmail>().mockResolvedValue({
    id: "msg_777",
    accepted: true,
  });
 
  const user = makeUser({
    email: "ada@example.com",
    name: "Ada",
    plan: "pro",
  });
 
  await sendWelcomeEmail(user, sendEmail);
 
  expect(sendEmail).toHaveBeenCalledWith({
    to: "ada@example.com",
    subject: "Welcome, Ada",
    body: "Your pro workspace is ready.",
  });
});

baseUser satisfies User keeps the default fixture honest. Partial<User> says each test may override only the fields it cares about. The return type User means callers receive a complete, valid value.

This is also why the helper is named makeUser, not mockUser. A user object is data. It does not record calls, script behavior, or stand in for a dependency. Precise names help tests stay readable as the suite grows.

Next lesson tests the type layer itself. You will use Vitest's type-testing helpers to lock public API shapes and deliberately assert that bad calls stay bad.

Checkpoint

Answer all three to mark this lesson complete

+50 XP on completion