TypeScript Testing Patterns
Write type-safe tests with typed mocks, test doubles, utility types for testing, type assertion tests, and patterns for testing TypeScript-specific features.
Overview
Key Points
- •Typed mocks: `Partial<Service>` for test doubles; `as jest.Mock` only for jest-specific assertions
- •`// @ts-expect-error` asserts the next line must be a type error - fails if the line compiles
- •`tsd` and `expect-type` provide `expectType<T>(value)` compile-time type assertions
- •Test factory functions: `makeUser(overrides?: Partial<User>): User` for creating test data
- •Generic test helpers: test a generic function by instantiating it with a specific type
- •Snapshot types: `type Assert<T extends true> = T` with `type TestA = Assert<Equal<A, B>>`
- •Test the type of JSX components by checking `React.ComponentProps<typeof MyComp>`
Examples
Type-safe mocks and test data factories
// Instead of 'any', use Partial<T> to build test doubles
interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
delete(id: string): Promise<void>;
count(): Promise<number>;
}
interface User {
id: string;
name: string;
email: string;
admin: boolean;
createdAt: Date;
}
// Type-safe factory for test data - all required fields have defaults
function makeUser(overrides: Partial<User> = {}): User {
return {
id: overrides.id ?? 'test-user-id',
name: overrides.name ?? 'Test User',
email: overrides.email ?? 'test@example.com',
admin: overrides.admin ?? false,
createdAt: overrides.createdAt ?? new Date('2024-01-01'),
...overrides,
};
}
// Type-safe mock repository - only implement what the test needs
function makeUserRepo(overrides: Partial<UserRepository> = {}): UserRepository {
const defaultUser = makeUser();
return {
findById: async (id) => id === defaultUser.id ? defaultUser : null,
findByEmail: async () => defaultUser,
save: async () => {},
delete: async () => {},
count: async () => 1,
...overrides,
};
}
// Usage in tests (framework-agnostic)
async function testRegisterUser() {
const savedUsers: User[] = [];
const repo = makeUserRepo({
save: async (user) => { savedUsers.push(user); },
});
const svc = new UserService(repo, new NoOpEmailService(), new ConsoleLogger());
const newUser = await svc.registerUser('Alice', 'alice@example.com');
console.assert(newUser.name === 'Alice', 'Name should be Alice');
console.assert(savedUsers.length === 1, 'Should have saved one user');
console.assert(savedUsers[0].email === 'alice@example.com', 'Email should match');
}Testing types with @ts-expect-error and type assertions
// Test that invalid usage is rejected by the type checker
// @ts-expect-error - asserts the NEXT LINE must be a type error
// If the line is valid TS, the @ts-expect-error itself becomes an error
function acceptString(s: string): void {}
// @ts-expect-error
acceptString(42); // Error: number is not assignable to string
// @ts-expect-error
acceptString(null); // Error: null is not assignable to string
acceptString('hello'); // No @ts-expect-error needed - this is valid
// Type equality testing with a helper
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends
(<T>() => T extends B ? 1 : 2) ? true : false;
type Assert<T extends true> = T;
// These compile successfully - the types ARE equal
type _1 = Assert<Equal<string, string>>;
type _2 = Assert<Equal<{ a: number }, { a: number }>>;
// These fail to compile - the types are NOT equal (catches regressions)
// type _3 = Assert<Equal<string, number>>; // Error
// type _4 = Assert<Equal<string[], number[]>>; // Error
// Test that a utility type produces the correct output
type MyPartial<T> = { [K in keyof T]?: T[K] };
type UserPartial = MyPartial<User>;
// Verify specific properties are optional in the result
type _5 = Assert<Equal<UserPartial['name'], string | undefined>>;
type _6 = Assert<Equal<UserPartial['admin'], boolean | undefined>>;
// Verify the keys are the same
type _7 = Assert<Equal<keyof UserPartial, keyof User>>;Testing generic functions and complex type utilities
// Testing a generic function by instantiating with concrete types
function first<T>(arr: T[]): T | undefined {
return arr[0];
}
// Type tests - these fail to compile if the types are wrong
const strResult = first(['a', 'b', 'c']);
type _strResult = Assert<Equal<typeof strResult, string | undefined>>;
const numResult = first([1, 2, 3]);
type _numResult = Assert<Equal<typeof numResult, number | undefined>>;
const emptyResult = first([]);
type _emptyResult = Assert<Equal<typeof emptyResult, never | undefined>>;
// Test conditional types produce the correct results
type Flatten<T> = T extends (infer U)[] ? U : T;
type _f1 = Assert<Equal<Flatten<string[]>, string>>;
type _f2 = Assert<Equal<Flatten<number[][]>, number[]>>;
type _f3 = Assert<Equal<Flatten<string>, string>>; // Non-array passes through
// Test discriminated union narrowing behavior
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'square'; side: number }
| { kind: 'triangle'; base: number; height: number };
function area(s: Shape): number {
switch (s.kind) {
case 'circle': return Math.PI * s.radius ** 2;
case 'square': return s.side ** 2;
case 'triangle': return 0.5 * s.base * s.height;
}
}
// Test exhaustiveness: if a new Shape is added without updating area(),
// the switch is no longer exhaustive and TypeScript errors on the function
// Runtime test to complement the type test
console.assert(area({ kind: 'square', side: 4 }) === 16, 'Square area');
console.assert(
Math.abs(area({ kind: 'circle', radius: 1 }) - Math.PI) < 0.001,
'Circle area'
);Common Mistakes
- Using `as any` for test doubles: this defeats type checking in tests. When you change the production interface, the mock silently becomes out of date. Use `Partial<T>` and proper factories instead
- Leaving `// @ts-expect-error` comments without removing them when the type error is fixed: TypeScript errors on unused `@ts-expect-error` (there is no error to suppress). This is a feature - it catches when your invalid-usage tests start passing by accident
- Testing implementation details instead of behavior: test what the function does (given these inputs, the output is X), not how it does it (it calls method Y with argument Z). Type-level tests should check output types, not internal intermediate types
- Not running `tsc --noEmit` in CI: type tests are only checked during type checking. If your CI only runs tests with `tsx` or `ts-jest`, type errors in test files might be silently ignored
Note
The `expect-type` library (from Simone Busoli) provides the most ergonomic type assertion API: `expectTypeOf(value).toEqualTypeOf<ExpectedType>()`. It gives good error messages when types do not match. For small projects, `// @ts-expect-error` and the `Equal<A, B>` helper are enough. For packages you publish, add `tsd` to your CI to catch type regressions across versions.
Exercises
Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.
Completed: 0 / 2 exercises
Knowledge Check
Question 1 of 4