Chapter 23

Testing TypeScript: vitest Mocks and tsd Type Tests

Two Dimensions of Testing in TypeScript

TypeScript projects need two kinds of tests. Most teams only do one.

Kind 1: Runtime tests — verify that code behaves correctly at runtime. This is traditional testing (unit tests, integration tests). vitest is currently the best runtime test framework for TypeScript projects.

Kind 2: Type-level tests — verify that type definitions themselves are correct. Whether utility types (DeepPartial<T>, NonNullable<T> etc.), function overload types, and generic constraints work as expected — none of this shows up in runtime tests, because types are erased at runtime.

If you write a type utility, running tests alone is not enough:

// Custom utility type
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};

// Runtime tests cannot verify whether the type is correct — types don't exist in JS
// You need a type-level testing tool

Runtime Testing: Typed Mocks in vitest

Installation

npm install -D vitest @vitest/coverage-v8

vi.fn() basic types

import { vi, describe, it, expect } from 'vitest';

// No type specified: fn is Mock<[], void> — no type information
const nakedMock = vi.fn();
nakedMock('any', 'arguments', 42);  // accepts anything

// Typed: vi.fn<ArgumentTypesArray, ReturnType>
const typedMock = vi.fn<[name: string, age: number], string>();
typedMock('Alice', 30);       // correct
// typedMock(123, 'Alice');   // compile error: wrong argument types
// typedMock('Alice');        // compile error: missing age argument

// More common: pass an implementation; types are inferred
const computeDiscount = vi.fn((price: number, rate: number): number => price * (1 - rate));
// Inferred type: Mock<[price: number, rate: number], number>

computeDiscount(100, 0.1);  // correct, returns number

vi.mocked(): creating typed mocks for existing functions

// An actual API module
// api/users.ts
export async function fetchUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

export async function createUser(data: CreateUserData): Promise<User> {
  const res = await fetch('/api/users', { method: 'POST', body: JSON.stringify(data) });
  return res.json();
}
// users.test.ts
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { fetchUser, createUser } from '../api/users';
import { UserService } from '../services/user-service';

vi.mock('../api/users');

describe('UserService', () => {
  // vi.mocked() converts the function type from
  // (id: string) => Promise<User>  to  Mock<[id: string], Promise<User>>
  // unlocking all mock methods with correct argument types
  const mockedFetchUser = vi.mocked(fetchUser);
  const mockedCreateUser = vi.mocked(createUser);

  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('should return user when found', async () => {
    const mockUser: User = {
      id: '1',
      name: 'Alice',
      email: '[email protected]',
      createdAt: new Date('2024-01-01'),
    };

    // mockResolvedValue argument type must match the function's return type: User
    mockedFetchUser.mockResolvedValue(mockUser);
    // mockedFetchUser.mockResolvedValue({ id: 1 })  // compile error: id must be string

    const service = new UserService();
    const result = await service.getUser('1');

    expect(result.name).toBe('Alice');
    expect(mockedFetchUser).toHaveBeenCalledWith('1');
  });

  it('should handle not found error', async () => {
    mockedFetchUser.mockRejectedValue(new Error('User not found'));

    const service = new UserService();
    await expect(service.getUser('999')).rejects.toThrow('User not found');
  });
});

Typing mock return values

interface User { id: string; name: string; email: string }

const mockFn = vi.fn<[id: string], Promise<User>>();

// mockReturnValue: synchronous return
const syncMock = vi.fn<[], number>();
syncMock.mockReturnValue(42);          // argument type: number
// syncMock.mockReturnValue('hello'); // compile error

// mockResolvedValue: Promise resolve value
mockFn.mockResolvedValue({ id: '1', name: 'Alice', email: '[email protected]' });
// argument type: User (not Promise<User> — vitest handles the wrapping)

// mockResolvedValueOnce: resolves once, then the next call follows
mockFn.mockResolvedValueOnce({ id: '1', name: 'Alice', email: '[email protected]' });
mockFn.mockResolvedValueOnce({ id: '2', name: 'Bob', email: '[email protected]' });
// first call → Alice, second call → Bob

// mockRejectedValue: Promise reject
mockFn.mockRejectedValue(new Error('fetch failed'));

// mockImplementation: full typed implementation
mockFn.mockImplementation(async (id: string): Promise<User> => {
  if (id === '404') throw new Error('Not found');
  return { id, name: 'Test User', email: '[email protected]' };
});

// mockImplementationOnce: one-shot implementation
mockFn.mockImplementationOnce(async (id) => ({ id, name: 'First call', email: '[email protected]' }));

vi.spyOn(): observing existing object methods

import { vi, it, expect } from 'vitest';

class EmailService {
  async sendWelcomeEmail(email: string): Promise<void> {
    await smtp.send({ to: email, subject: 'Welcome' });
  }

  async sendResetEmail(email: string, token: string): Promise<void> {
    await smtp.send({ to: email, subject: 'Reset password', body: token });
  }
}

it('should send welcome email after registration', async () => {
  const emailService = new EmailService();

  // spyOn type: inferred from EmailService method signature
  // spy type: SpyInstance<[email: string], Promise<void>>
  const spy = vi.spyOn(emailService, 'sendWelcomeEmail').mockResolvedValue(undefined);

  await registerUser({ email: '[email protected]' }, emailService);

  // toHaveBeenCalledWith arguments are type-checked
  expect(spy).toHaveBeenCalledWith('[email protected]');
  expect(spy).toHaveBeenCalledTimes(1);
});

Mocking entire modules with typed factory functions

// vi.mock without factory: auto-mock, all exports become vi.fn()
vi.mock('../services/email');

// vi.mock with factory: manual control over mock implementation, types preserved
vi.mock('../services/email', () => {
  // Return value structure must match the module's actual exports
  return {
    EmailService: vi.fn().mockImplementation(() => ({
      sendWelcomeEmail: vi.fn().mockResolvedValue(undefined),
      sendResetEmail: vi.fn().mockResolvedValue(undefined),
    })),
    sendBulkEmail: vi.fn().mockResolvedValue({ sent: 0, failed: 0 }),
  };
});

Testing async hooks

import { renderHook, act } from '@testing-library/react';
import { vi, it, expect } from 'vitest';

it('useFetch should return data on success', async () => {
  const mockUser: User = { id: '1', name: 'Alice', email: '[email protected]' };

  const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
    new Response(JSON.stringify(mockUser), { status: 200 })
  );

  const { result } = renderHook(() => useFetch<User>('/api/users/1'));

  await act(async () => {
    await new Promise(resolve => setTimeout(resolve, 0));
  });

  expect(result.current.data).toEqual(mockUser);
  expect(result.current.loading).toBe(false);
  expect(result.current.error).toBeNull();

  fetchSpy.mockRestore();
});

Type-Level Testing: Verifying Types with tsd

Runtime tests verify behavior. Type-level tests verify type contracts.

Installing tsd

npm install -D tsd

Configure in package.json:

{
  "scripts": {
    "test:types": "tsd"
  },
  "tsd": {
    "directory": "src/types/__tests__"
  }
}

tsd basics

// src/types/__tests__/utility-types.test-d.ts
// Files ending in .test-d.ts are recognized by tsd
import { expectType, expectNotType, expectError, expectAssignable } from 'tsd';

import type { DeepPartial, DeepReadonly } from '../utility-types';

// ============ Testing DeepPartial ============

interface Address {
  street: string;
  city: string;
  zip: number;
}

interface User {
  id: string;
  name: string;
  age: number;
  address: Address;
}

// Test: all fields (including nested) are optional
declare const partialUser: DeepPartial<User>;

expectType<string | undefined>(partialUser.id);
expectType<string | undefined>(partialUser.name);
expectType<string | undefined>(partialUser.address?.street);
expectType<number | undefined>(partialUser.address?.zip);

// Test: DeepPartial<User> is assignable to Partial<User>
expectAssignable<Partial<User>>(partialUser);

Testing cases that should fail with @ts-expect-error

declare const readonlyUser: DeepReadonly<User>;

// Test: assigning to a readonly field should fail
// @ts-expect-error — next line must produce a compile error
readonlyUser.id = 'new-id';

// @ts-expect-error — nested fields are also readonly
readonlyUser.address.street = 'new street';

// Important: if the line after @ts-expect-error does NOT produce an error,
// tsd reports the @ts-expect-error itself as an error.
// This ensures your type constraints are actually enforced.

Testing utility types — complete example

// src/types/utility-types.ts

export type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

export type Flatten<T> = T extends Array<infer U> ? U : T;

export type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
  Pick<T, Exclude<keyof T, Keys>> &
  { [K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>> }[Keys];

export type ValueOf<T> = T[keyof T];
// src/types/__tests__/utility-types.test-d.ts
import { expectType, expectAssignable } from 'tsd';
import type { DeepPartial, Flatten, RequireAtLeastOne, ValueOf } from '../utility-types';

// ============ Testing Flatten ============
expectType<string>(null as unknown as Flatten<string[]>);
expectType<number>(null as unknown as Flatten<number[]>);
expectType<string>(null as unknown as Flatten<string>); // non-array passes through

// ============ Testing RequireAtLeastOne ============
interface FilterOptions {
  name?: string;
  email?: string;
  id?: string;
}

type AtLeastOneFilter = RequireAtLeastOne<FilterOptions>;

// An object with at least one field is assignable
const withName: AtLeastOneFilter = { name: 'Alice' };
expectAssignable<AtLeastOneFilter>(withName);

// An empty object is NOT assignable
// @ts-expect-error
const empty: AtLeastOneFilter = {};

// ============ Testing ValueOf ============
interface StatusMap {
  active: 'ACTIVE';
  inactive: 'INACTIVE';
  pending: 'PENDING';
}

expectType<'ACTIVE' | 'INACTIVE' | 'PENDING'>(null as unknown as ValueOf<StatusMap>);

Testing function overload types

// The function under test
function parse(input: string): number;
function parse(input: number): string;
function parse(input: string | number): number | string {
  if (typeof input === 'string') return parseInt(input, 10);
  return input.toString();
}
// Type tests
import { expectType } from 'tsd';

// string input → number output
expectType<number>(parse('42'));

// number input → string output
expectType<string>(parse(42));

// @ts-expect-error — boolean is not accepted
parse(true);

Lightweight Alternative: @ts-expect-error Without tsd

If you prefer not to add tsd, TypeScript's built-in @ts-expect-error directive handles simple type testing:

// type-tests.ts — compiled by tsc, which checks the assertions

function identity<T>(value: T): T { return value; }

// Correct usage should compile
const s: string = identity('hello');   // OK
const n: number = identity(42);        // OK

// Wrong usage should be caught
// @ts-expect-error — cannot assign string to number variable
const wrong: number = identity('hello');

// If the line after @ts-expect-error does NOT produce an error,
// TypeScript itself reports:
// "Unused '@ts-expect-error' directive."
// This tells you a previously-failing type check now passes
// (possibly because the type definition changed)
# Run in CI
tsc --noEmit
# Any @ts-expect-error with no actual error underneath becomes a compiler error

CI Integration: Type Errors as Test Failures

// package.json
{
  "scripts": {
    "test": "vitest run",
    "test:types": "tsd && tsc --noEmit",
    "test:all": "npm run test && npm run test:types",
    "ci": "npm run test:all"
  }
}
# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - name: Run runtime tests
        run: npm test
      - name: Run type tests
        run: npm run test:types
      - name: Type check
        run: npx tsc --noEmit

With this configuration, any type or behavior error blocks the merge:

Complete Example: Testing the DeepPartial<T> Utility Type

Implementation

// src/types/deep-partial.ts

export type DeepPartial<T> = T extends object
  ? { [P in keyof T]?: DeepPartial<T[P]> }
  : T;

export function mergeWithDefaults<T extends object>(
  defaults: T,
  overrides: DeepPartial<T>
): T {
  const result = { ...defaults };
  for (const key in overrides) {
    const k = key as keyof T;
    const override = overrides[k];
    if (override !== undefined) {
      if (typeof override === 'object' && !Array.isArray(override)) {
        (result[k] as object) = mergeWithDefaults(
          result[k] as object,
          override as DeepPartial<object>
        );
      } else {
        result[k] = override as T[keyof T];
      }
    }
  }
  return result;
}

Runtime tests (vitest)

// src/types/__tests__/deep-partial.test.ts
import { describe, it, expect } from 'vitest';
import { mergeWithDefaults } from '../deep-partial';

interface Config {
  server: { host: string; port: number; tls: boolean };
  database: { url: string; poolSize: number };
  debug: boolean;
}

const defaultConfig: Config = {
  server: { host: 'localhost', port: 3000, tls: false },
  database: { url: 'postgresql://localhost/dev', poolSize: 10 },
  debug: false,
};

describe('mergeWithDefaults', () => {
  it('should merge top-level properties', () => {
    const result = mergeWithDefaults(defaultConfig, { debug: true });
    expect(result.debug).toBe(true);
    expect(result.server.port).toBe(3000);  // unchanged
  });

  it('should merge nested properties', () => {
    const result = mergeWithDefaults(defaultConfig, {
      server: { port: 8080 },
    });
    expect(result.server.port).toBe(8080);
    expect(result.server.host).toBe('localhost');  // unchanged
    expect(result.server.tls).toBe(false);         // unchanged
  });

  it('should return full config with no overrides', () => {
    const result = mergeWithDefaults(defaultConfig, {});
    expect(result).toEqual(defaultConfig);
  });
});

Type tests (tsd)

// src/types/__tests__/deep-partial.test-d.ts
import { expectType, expectAssignable } from 'tsd';
import type { DeepPartial } from '../deep-partial';
import { mergeWithDefaults } from '../deep-partial';

interface Config {
  server: { host: string; port: number; tls: boolean };
  database: { url: string; poolSize: number };
  debug: boolean;
}

// ---- Test the DeepPartial type itself ----

declare const partial: DeepPartial<Config>;

// Top-level fields are optional
expectType<boolean | undefined>(partial.debug);

// Nested fields are also optional
expectType<string | undefined>(partial.server?.host);
expectType<number | undefined>(partial.server?.port);

// Empty object is assignable
expectAssignable<DeepPartial<Config>>({});
expectAssignable<DeepPartial<Config>>({ debug: true });
expectAssignable<DeepPartial<Config>>({ server: { port: 8080 } });

// Full Config is assignable to DeepPartial<Config>
const fullConfig: Config = {
  server: { host: 'localhost', port: 3000, tls: false },
  database: { url: 'db://localhost', poolSize: 10 },
  debug: false,
};
expectAssignable<DeepPartial<Config>>(fullConfig);

// ---- Test mergeWithDefaults return type ----

const result = mergeWithDefaults(fullConfig, { debug: true });
// Return type should be full Config, not DeepPartial<Config>
expectType<Config>(result);
expectType<string>(result.server.host);  // string, not string | undefined

// ---- Test error cases ----

// @ts-expect-error — field type must match
const wrongPartial: DeepPartial<Config> = { debug: 'not-a-boolean' };

// @ts-expect-error — non-existent fields are not allowed
const extraField: DeepPartial<Config> = { unknownField: true };

Anti-Patterns

Anti-pattern Problem Correct approach
Mock function with no type mockResolvedValue accepts anything; wrong types go undetected vi.fn<[Params], ReturnType>() or use vi.mocked()
Testing implementation details (internal state) instead of contracts (inputs/outputs) Tests break on refactors even when behavior is correct Test the public API's inputs and outputs
Writing type utilities without type tests Type behavior changes after refactoring with no test to catch it Use tsd or @ts-expect-error to test type contracts
@ts-expect-error on code that is actually correct When the code is fixed, the directive becomes wrong Only use it on code that genuinely must fail to compile
CI only runs vitest, not tsc --noEmit Type errors surface only during local development Run both tsc --noEmit and tsd in CI

Summary

Testing dimension Tool What it verifies
Runtime behavior vitest Function outputs, side effects, async logic
Mock type safety vi.fn<Params, Return>() / vi.mocked() Mock arguments and return values match the real function
Type correctness tsd / @ts-expect-error Utility types, generic constraints, function overloads
Full type check tsc --noEmit Zero type errors across the whole project
CI integration All of the above combined Any type or behavior error blocks the merge

Runtime tests + type-level tests + CI integration — all three together are what keeps a TypeScript project's type contracts intact through continuous iteration.

Rate this chapter
4.8  / 5  (6 ratings)

💬 Comments