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:
- vitest catches runtime behavior errors
- tsd catches type definition errors
tsc --noEmitcatches type errors across the whole project (including misused@ts-expect-error)
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.