Skip to content

Mocking & Type Safety

Unit testing in TypeScript often involves mocking: replacing real implementations with controlled substitutes so you can isolate behavior.

However, mocking in TypeScript introduces an additional requirement: mocks must remain type‑correct.

A mock that behaves correctly at runtime but violates the real function’s type signature undermines the entire purpose of using TypeScript.

This section explains how to create typed mocks and how to ensure your tests continue to validate type behavior, not just runtime behavior.


1. Why Type‑Safe Mocking Matters

In JavaScript, a mock only needs to “act like” the real function.

In TypeScript, a mock must also type‑check like the real function.

If the real function is:

export function fetchUser(id: string): Promise<User> { ... }

then a mock must:

  • accept a string
  • return a Promise<User>
  • preserve generic inference (if any)
  • fail to compile if the real function’s signature changes

Without type‑safe mocks, your tests may pass while your type definitions silently drift out of sync with the real API.

This is especially dangerous in refactoring: mocks often outlive the code they imitate.


2. Typed Mocks with Vitest or Jest

Both Vitest and Jest allow you to create typed mocks using generics.

The goal is to ensure the mock’s parameters and return type match the real function.

2.1 Basic typed mock

Real function:

export function add(a: number, b: number): number {
  return a + b
}

Typed mock:

const mockAdd = vi.fn<[number, number], number>((a, b) => a + b)

Explanation:

  • The first generic argument ([number, number]) defines the parameter types.
  • The second generic argument (number) defines the return type.
  • If the real function changes, this mock will no longer type‑check.
  • If the mock returns the wrong type, TypeScript reports an error.

This ensures the mock remains aligned with the real API.

2.2 Typed async mock

const mockFetch = vi.fn<[string], Promise<User>>(async id => ({
  id,
  name: 'Test User',
}))

If the real function later changes to return Promise<User | null>, this mock will fail until updated.

This is the desired behavior: mocks should not hide API changes.


3. Mocking Entire Modules with Type Safety

When mocking modules, the same principle applies: the mock must match the module’s exported types.

Example module:

// api.ts
export async function getUser(id: string): Promise<User> { ... }

Typed module mock:

vi.mock('./api', () => ({
  getUser: vi.fn<[string], Promise<User>>(async id => ({
    id,
    name: 'Mock User',
  })),
}))

If getUser’s signature changes, the mock becomes invalid.

This prevents the common problem where mocks silently diverge from real code.


4. Ensuring Test Coverage of Types

Mocking can unintentionally weaken type guarantees.

For example, an untyped mock may return any, causing type errors to disappear.

To ensure your tests continue to validate type behavior, combine:

  1. Typed mocks — enforce correct signatures.
  2. Type assertions (expectTypeOf) — verify inference and return types.
  3. Negative type tests (@ts-expect-error) — ensure invalid usage remains invalid.

4.1 Verifying inference with mocks

Real function:

export function wrap<T>(value: T) {
  return { value }
}

Typed mock:

const mockWrap = vi.fn(<T>(value: T) => ({ value }))

Type test:

expectTypeOf(mockWrap('hello')).toEqualTypeOf<{ value: string }>()

If the mock is later changed to return an incorrect type, the type test fails.

4.2 Ensuring mocks do not widen types

A common mistake:

const mock = vi.fn(() => null) // inferred as () => any

This erases type safety.

Correct version:

const mock = vi.fn<[], User | null>(() => null)

Now TypeScript enforces:

  • correct return type
  • correct nullability
  • correct usage in tests

This prevents “mock‑driven type erosion”.


5. Negative Type Tests for Mocks

Negative tests ensure that incorrect usage remains incorrect.

// @ts-expect-error
mockFetch(123)

If the mock becomes too permissive (e.g., accepts any), this line stops producing an error, and the test suite fails.

This is how you enforce strictness even when mocking.


6. Summary

Mocking in TypeScript requires more than simulating behavior.

It requires maintaining the type integrity of the system.

Key principles:

  • A mock must match the real function’s signature.
  • A mock must preserve inference and generic behavior.
  • A mock must not widen types to any.
  • Type assertions (expectTypeOf) ensure mocks do not hide type regressions.
  • Negative tests (@ts-expect-error) ensure invalid usage remains invalid.

Typed mocks + type tests give you a test suite that protects both runtime behavior and compile‑time correctness — the full promise of TypeScript.


If you want, I can continue with the same formal style for:

  • 10.4 Integration Testing (Supertest, Playwright)
  • 10.5 End‑to‑End Testing with Type Safety

Just tell me which module you want next.