Testing TypeScript
Testing TypeScript code requires special considerations beyond regular JavaScript testing. You need to handle type checking, configure test runners for TypeScript, type your mocks and test utilities, and ensure your tests benefit from TypeScript's type safety. In this lesson, we'll explore testing TypeScript with Jest, ts-jest configuration, typing mocks, creating type-safe test utilities, and even testing types themselves.
Setting Up Jest with TypeScript
Jest is the most popular testing framework for JavaScript and TypeScript. Here's how to configure it properly:
// Install dependencies
npm install --save-dev jest @types/jest ts-jest typescript
// Create jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
transform: {
'^.+\.ts$': 'ts-jest'
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/__tests__/**'
],
globals: {
'ts-jest': {
tsconfig: {
esModuleInterop: true,
allowSyntheticDefaultImports: true
}
}
}
};
// Add to package.json scripts
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
// tsconfig.json for testing
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["jest", "node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Note: The ts-jest preset handles TypeScript compilation automatically, so you can write tests in TypeScript with full type checking.
Writing Type-Safe Tests
TypeScript enables you to write tests with complete type safety:
// src/math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function divide(a: number, b: number): number {
if (b === 0) {
throw new Error('Division by zero');
}
return a / b;
}
export class Calculator {
private result: number = 0;
add(value: number): this {
this.result += value;
return this;
}
subtract(value: number): this {
this.result -= value;
return this;
}
getResult(): number {
return this.result;
}
reset(): void {
this.result = 0;
}
}
// src/__tests__/math.test.ts
import { add, divide, Calculator } from '../math';
describe('Math Functions', () => {
describe('add', () => {
it('should add two positive numbers', () => {
const result = add(2, 3);
expect(result).toBe(5);
// TypeScript knows result is number
expect(typeof result).toBe('number');
});
it('should add negative numbers', () => {
expect(add(-5, -3)).toBe(-8);
});
// TypeScript catches type errors at compile time
// it('should fail with strings', () => {
// add('2', '3'); // Error: Argument of type 'string' is not assignable
// });
});
describe('divide', () => {
it('should divide two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
it('should throw error when dividing by zero', () => {
expect(() => divide(10, 0)).toThrow('Division by zero');
});
});
});
describe('Calculator', () => {
let calculator: Calculator;
beforeEach(() => {
calculator = new Calculator();
});
it('should start with zero', () => {
expect(calculator.getResult()).toBe(0);
});
it('should support method chaining', () => {
const result = calculator
.add(10)
.subtract(3)
.add(5)
.getResult();
expect(result).toBe(12);
});
it('should reset to zero', () => {
calculator.add(100);
calculator.reset();
expect(calculator.getResult()).toBe(0);
});
});
Mocking with TypeScript
TypeScript provides excellent support for creating type-safe mocks:
// src/api.ts
export interface User {
id: number;
name: string;
email: string;
}
export interface ApiClient {
getUser(id: number): Promise<User>;
createUser(data: Omit<User, 'id'>): Promise<User>;
deleteUser(id: number): Promise<void>;
}
export class HttpApiClient implements ApiClient {
constructor(private readonly baseUrl: string) {}
async getUser(id: number): Promise<User> {
const response = await fetch(\`${this.baseUrl}/users/${id}\`);
return response.json();
}
async createUser(data: Omit<User, 'id'>): Promise<User> {
const response = await fetch(\`${this.baseUrl}/users\`, {
method: 'POST',
body: JSON.stringify(data)
});
return response.json();
}
async deleteUser(id: number): Promise<void> {
await fetch(\`${this.baseUrl}/users/${id}\`, { method: 'DELETE' });
}
}
// src/userService.ts
import { ApiClient, User } from './api';
export class UserService {
constructor(private readonly api: ApiClient) {}
async getUserById(id: number): Promise<User | null> {
try {
return await this.api.getUser(id);
} catch (error) {
console.error('Failed to fetch user:', error);
return null;
}
}
async registerUser(name: string, email: string): Promise<User> {
const user = await this.api.createUser({ name, email });
console.log(\`User registered: ${user.id}\`);
return user;
}
}
// src/__tests__/userService.test.ts
import { UserService } from '../userService';
import { ApiClient, User } from '../api';
// Type-safe mock creation
const createMockApiClient = (): jest.Mocked<ApiClient> => ({
getUser: jest.fn(),
createUser: jest.fn(),
deleteUser: jest.fn()
});
describe('UserService', () => {
let apiClient: jest.Mocked<ApiClient>;
let userService: UserService;
beforeEach(() => {
apiClient = createMockApiClient();
userService = new UserService(apiClient);
});
describe('getUserById', () => {
it('should return user when found', async () => {
const mockUser: User = {
id: 1,
name: 'John Doe',
email: 'john@example.com'
};
apiClient.getUser.mockResolvedValue(mockUser);
const user = await userService.getUserById(1);
expect(user).toEqual(mockUser);
expect(apiClient.getUser).toHaveBeenCalledWith(1);
expect(apiClient.getUser).toHaveBeenCalledTimes(1);
});
it('should return null when API fails', async () => {
apiClient.getUser.mockRejectedValue(new Error('Network error'));
const user = await userService.getUserById(1);
expect(user).toBeNull();
});
});
describe('registerUser', () => {
it('should create and return user', async () => {
const mockUser: User = {
id: 1,
name: 'Jane Doe',
email: 'jane@example.com'
};
apiClient.createUser.mockResolvedValue(mockUser);
const user = await userService.registerUser('Jane Doe', 'jane@example.com');
expect(user).toEqual(mockUser);
expect(apiClient.createUser).toHaveBeenCalledWith({
name: 'Jane Doe',
email: 'jane@example.com'
});
});
});
});
Tip: Use jest.Mocked<T> to create type-safe mocks that preserve all the type information of the original interface.
Testing Async Code
TypeScript makes testing asynchronous code type-safe and clear:
// src/dataService.ts
export interface DataService {
fetchData(): Promise<string>;
}
export async function processData(service: DataService): Promise<string> {
const data = await service.fetchData();
return data.toUpperCase();
}
export async function fetchWithRetry(
fn: () => Promise<string>,
retries: number = 3
): Promise<string> {
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (error) {
if (i === retries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, 100));
}
}
throw new Error('All retries failed');
}
// src/__tests__/dataService.test.ts
import { processData, fetchWithRetry, DataService } from '../dataService';
describe('Async Operations', () => {
describe('processData', () => {
it('should process data correctly', async () => {
const mockService: DataService = {
fetchData: jest.fn().mockResolvedValue('hello')
};
const result = await processData(mockService);
expect(result).toBe('HELLO');
});
it('should handle errors', async () => {
const mockService: DataService = {
fetchData: jest.fn().mockRejectedValue(new Error('Fetch failed'))
};
await expect(processData(mockService)).rejects.toThrow('Fetch failed');
});
});
describe('fetchWithRetry', () => {
it('should succeed on first try', async () => {
const mockFn = jest.fn().mockResolvedValue('success');
const result = await fetchWithRetry(mockFn);
expect(result).toBe('success');
expect(mockFn).toHaveBeenCalledTimes(1);
});
it('should retry on failure', async () => {
const mockFn = jest
.fn()
.mockRejectedValueOnce(new Error('Fail 1'))
.mockRejectedValueOnce(new Error('Fail 2'))
.mockResolvedValue('success');
const result = await fetchWithRetry(mockFn, 3);
expect(result).toBe('success');
expect(mockFn).toHaveBeenCalledTimes(3);
});
it('should throw after all retries fail', async () => {
const mockFn = jest.fn().mockRejectedValue(new Error('Always fails'));
await expect(fetchWithRetry(mockFn, 3)).rejects.toThrow('Always fails');
expect(mockFn).toHaveBeenCalledTimes(3);
});
});
});
Type-Safe Test Utilities
Create reusable, type-safe utilities for your tests:
// src/__tests__/utils/testHelpers.ts
// Generic mock builder
export function createMock<T>(overrides?: Partial<T>): jest.Mocked<T> {
const mock = {} as jest.Mocked<T>;
if (overrides) {
Object.assign(mock, overrides);
}
return mock;
}
// Type-safe assertion helpers
export function assertDefined<T>(value: T | undefined | null): asserts value is T {
expect(value).toBeDefined();
expect(value).not.toBeNull();
}
export function assertType<T>(value: unknown): asserts value is T {
// Runtime check would go here
}
// Custom matchers with type safety
export const customMatchers = {
toBeWithinRange(received: number, floor: number, ceiling: number) {
const pass = received >= floor && received <= ceiling;
return {
pass,
message: () =>
pass
? \`Expected ${received} not to be within range ${floor} - ${ceiling}\`
: \`Expected ${received} to be within range ${floor} - ${ceiling}\`
};
}
};
// Extend Jest matchers
declare global {
namespace jest {
interface Matchers<R> {
toBeWithinRange(floor: number, ceiling: number): R;
}
}
}
// Factory functions for test data
export function createTestUser(overrides?: Partial<User>): User {
return {
id: 1,
name: 'Test User',
email: 'test@example.com',
...overrides
};
}
export function createTestUsers(count: number): User[] {
return Array.from({ length: count }, (_, i) =>
createTestUser({ id: i + 1, name: \`User ${i + 1}\` })
);
}
// Async test helpers
export async function waitFor<T>(
fn: () => T | Promise<T>,
options: { timeout?: number; interval?: number } = {}
): Promise<T> {
const { timeout = 5000, interval = 50 } = options;
const startTime = Date.now();
while (true) {
try {
return await fn();
} catch (error) {
if (Date.now() - startTime >= timeout) {
throw error;
}
await new Promise(resolve => setTimeout(resolve, interval));
}
}
}
// Usage in tests
import { createMock, assertDefined, createTestUser, customMatchers } from './utils/testHelpers';
expect.extend(customMatchers);
describe('Test Utilities', () => {
it('should create type-safe mocks', () => {
const mockApi = createMock<ApiClient>({
getUser: jest.fn().mockResolvedValue(createTestUser())
});
expect(mockApi.getUser).toBeDefined();
});
it('should use custom matchers', () => {
expect(15).toBeWithinRange(10, 20);
expect(25).not.toBeWithinRange(10, 20);
});
it('should create test data', () => {
const user = createTestUser({ name: 'Custom Name' });
expect(user.name).toBe('Custom Name');
expect(user.email).toBe('test@example.com');
});
});
Note: Type-safe test utilities reduce boilerplate and ensure your test code follows the same type safety principles as your production code.
Testing Types
TypeScript allows you to test that your types are correct using specialized libraries:
// Install type testing library
npm install --save-dev tsd
// src/types.ts
export type Optional<T> = T | undefined;
export type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
export function identity<T>(value: T): T {
return value;
}
// src/__tests__/types.test-d.ts (using tsd)
import { expectType, expectError, expectAssignable } from 'tsd';
import { Optional, DeepReadonly, identity } from '../types';
// Test Optional type
expectType<Optional<string>>('hello');
expectType<Optional<string>>(undefined);
expectError<Optional<string>>(null);
expectError<Optional<string>>(123);
// Test DeepReadonly type
type Person = {
name: string;
address: {
street: string;
city: string;
};
};
const person: DeepReadonly<Person> = {
name: 'John',
address: { street: 'Main St', city: 'NYC' }
};
expectError(person.name = 'Jane');
expectError(person.address.city = 'LA');
// Test generic function
expectType<string>(identity('hello'));
expectType<number>(identity(42));
expectType<boolean>(identity(true));
// Alternative: Using @ts-expect-error for type tests
// src/__tests__/typeTests.ts
// This should compile
const validString: Optional<string> = 'hello';
const validUndefined: Optional<string> = undefined;
// @ts-expect-error - null is not valid
const invalidNull: Optional<string> = null;
// @ts-expect-error - number is not valid
const invalidNumber: Optional<string> = 123;
// Test that readonly prevents mutations
const readonlyPerson: DeepReadonly<Person> = {
name: 'John',
address: { street: 'Main St', city: 'NYC' }
};
// @ts-expect-error - cannot modify readonly property
readonlyPerson.name = 'Jane';
// @ts-expect-error - cannot modify nested readonly property
readonlyPerson.address.city = 'LA';
Warning: Type tests are checked at compile time, not runtime. Use tsd or @ts-expect-error comments to ensure your type definitions work as expected.
Testing with React and TypeScript
Testing React components with TypeScript requires additional configuration:
// Install dependencies
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
// src/components/Button.tsx
import React from 'react';
export interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
variant?: 'primary' | 'secondary';
}
export const Button: React.FC<ButtonProps> = ({
label,
onClick,
disabled = false,
variant = 'primary'
}) => {
return (
<button
onClick={onClick}
disabled={disabled}
className={\`btn btn-${variant}\`}
>
{label}
</button>
);
};
// src/components/__tests__/Button.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { Button, ButtonProps } from '../Button';
describe('Button', () => {
const defaultProps: ButtonProps = {
label: 'Click me',
onClick: jest.fn()
};
it('should render with label', () => {
render(<Button {...defaultProps} />);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('should call onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button {...defaultProps} onClick={handleClick} />);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('should be disabled when disabled prop is true', () => {
render(<Button {...defaultProps} disabled />);
expect(screen.getByText('Click me')).toBeDisabled();
});
it('should apply correct variant class', () => {
const { rerender } = render(<Button {...defaultProps} variant="primary" />);
expect(screen.getByText('Click me')).toHaveClass('btn-primary');
rerender(<Button {...defaultProps} variant="secondary" />);
expect(screen.getByText('Click me')).toHaveClass('btn-secondary');
});
});
Exercise:
- Set up a TypeScript project with Jest and ts-jest, including proper configuration for code coverage.
- Write comprehensive tests for a UserRepository class that performs CRUD operations, using type-safe mocks for the database layer.
- Create a suite of type-safe test utilities including factory functions, custom matchers, and assertion helpers.
- Implement type tests for a complex generic utility type library using tsd or @ts-expect-error comments.
- Build a tested React form component with TypeScript, including validation logic and async submission handling.
Summary
Testing TypeScript code combines the best of both worlds: the runtime verification of tests with the compile-time safety of types. By using ts-jest, creating type-safe mocks, building reusable test utilities, and even testing types themselves, you can ensure your code works correctly at both the type level and runtime. This comprehensive testing approach catches more bugs earlier and makes your codebase more maintainable and reliable.