I used to write tests like this:
const mockUser: any = { name: 'Test User' };
const mockFetch = jest.fn() as any;
const mockRouter = { push: jest.fn() } as any;
Every as any was a small betrayal of why I chose TypeScript in the first place. My tests would pass, then production would break because my mocks didn't match reality.
The breaking point came when I refactored a User interface, and every test still passed. TypeScript couldn't help me because I'd told it to look away with any. I spent hours finding runtime failures that should have been compile-time errors.
That's when I learned: tests without type safety are just expensive lies.
Let me show you how to write tests where TypeScript actually helps instead of getting in the way.
The Problem: Type Safety Stops at Test Boundaries
Consider this production code:
// api.ts
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
And this test:
// api.test.ts
test('fetchUser returns user', async () => {
// ❌ Lying to TypeScript
const mockUser: any = {
id: '1',
name: 'Test User',
// Missing email - test passes but production breaks!
};
global.fetch = jest.fn().mockResolvedValue({
json: () => Promise.resolve(mockUser)
}) as any;
const user = await fetchUser('1');
expect(user.name).toBe('Test User');
});
The bug: email is missing, but TypeScript doesn't catch it because of as any. Change the User interface? Tests still pass. Ship to production? Users see errors.
This is the test equivalent of technical debt.
Pattern 1: Type-Safe Mock Factories
Build mocks that TypeScript can verify.
Basic Mock Factory
// testUtils/factories.ts
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
createdAt: Date;
}
// ✅ Type-safe factory with defaults
export function createMockUser(overrides?: Partial<User>): User {
return {
id: '1',
name: 'Test User',
email: 'test@example.com',
role: 'user',
createdAt: new Date('2024-01-01'),
...overrides,
};
}
// Usage in tests
test('displays user name', () => {
const user = createMockUser({ name: 'Alice' });
// TypeScript knows user is User
// All required fields are present
expect(user.name).toBe('Alice');
});
test('handles admin users', () => {
const admin = createMockUser({ role: 'admin' });
// ✅ TypeScript validates 'admin' is valid role
expect(isAdmin(admin)).toBe(true);
});
// ❌ TypeScript prevents invalid data
const badUser = createMockUser({ role: 'superuser' }); // Type error!
Generic Factory Builder
// testUtils/factories.ts
type FactoryFunction<T> = (overrides?: Partial<T>) => T;
function createFactory<T>(defaults: T): FactoryFunction<T> {
return (overrides = {}) => ({
...defaults,
...overrides,
});
}
// Define factories
export const createMockUser = createFactory<User>({
id: '1',
name: 'Test User',
email: 'test@example.com',
role: 'user',
createdAt: new Date('2024-01-01'),
});
export const createMockPost = createFactory<Post>({
id: '1',
title: 'Test Post',
content: 'Test content',
authorId: '1',
published: false,
createdAt: new Date('2024-01-01'),
});
export const createMockComment = createFactory<Comment>({
id: '1',
postId: '1',
authorId: '1',
text: 'Test comment',
createdAt: new Date('2024-01-01'),
});
Advanced: Builder Pattern for Complex Objects
// testUtils/builders.ts
class UserBuilder {
private user: User;
constructor() {
this.user = {
id: '1',
name: 'Test User',
email: 'test@example.com',
role: 'user',
createdAt: new Date('2024-01-01'),
};
}
withId(id: string): this {
this.user.id = id;
return this;
}
withName(name: string): this {
this.user.name = name;
return this;
}
withEmail(email: string): this {
this.user.email = email;
return this;
}
asAdmin(): this {
this.user.role = 'admin';
return this;
}
createdOn(date: Date): this {
this.user.createdAt = date;
return this;
}
build(): User {
return { ...this.user };
}
}
// Usage - fluent API
test('admin users can delete posts', () => {
const admin = new UserBuilder()
.withName('Admin User')
.asAdmin()
.build();
expect(canDelete(admin, post)).toBe(true);
});
// Chain multiple properties
test('specific user scenario', () => {
const user = new UserBuilder()
.withId('user-123')
.withName('Alice')
.withEmail('alice@example.com')
.createdOn(new Date('2023-01-01'))
.build();
// All fields are properly typed
expect(processUser(user)).toBeDefined();
});
Pattern 2: Type-Safe API Mocking with MSW
Mock Service Worker (MSW) intercepts requests at the network level with full type safety.
Setting Up MSW with TypeScript
npm install msw --save-dev
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
}
interface CreateUserRequest {
name: string;
email: string;
role?: 'admin' | 'user';
}
// ✅ Fully typed handlers
export const handlers = [
// GET user
http.get<{ id: string }, never, User>(
'/api/users/:id',
({ params }) => {
const { id } = params;
return HttpResponse.json({
id,
name: 'Test User',
email: 'test@example.com',
role: 'user',
});
}
),
// POST create user
http.post<never, CreateUserRequest, User>(
'/api/users',
async ({ request }) => {
const body = await request.json();
// TypeScript validates request body structure
return HttpResponse.json({
id: '123',
name: body.name,
email: body.email,
role: body.role || 'user',
});
}
),
// DELETE user
http.delete<{ id: string }>(
'/api/users/:id',
({ params }) => {
return HttpResponse.json({ success: true });
}
),
];
MSW Setup for Tests
// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// setupTests.ts (for Jest)
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Using MSW in Tests
// api.test.ts
import { server } from './mocks/server';
import { http, HttpResponse } from 'msw';
test('fetches user successfully', async () => {
const user = await fetchUser('1');
// Mock returns typed data
expect(user).toEqual({
id: '1',
name: 'Test User',
email: 'test@example.com',
role: 'user',
});
});
test('handles user not found', async () => {
// Override handler for this test
server.use(
http.get<{ id: string }, never, { error: string }>(
'/api/users/:id',
() => {
return HttpResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
)
);
await expect(fetchUser('999')).rejects.toThrow('User not found');
});
test('creates user with valid data', async () => {
const newUser = await createUser({
name: 'Alice',
email: 'alice@example.com',
});
expect(newUser.name).toBe('Alice');
});
Type-Safe Mock Data with Factories
// mocks/handlers.ts
import { createMockUser } from '../testUtils/factories';
export const handlers = [
http.get<{ id: string }, never, User>(
'/api/users/:id',
({ params }) => {
// ✅ Factory ensures type safety
const user = createMockUser({ id: params.id });
return HttpResponse.json(user);
}
),
http.get<never, never, User[]>(
'/api/users',
() => {
// ✅ Generate multiple users
const users = [
createMockUser({ id: '1', name: 'Alice' }),
createMockUser({ id: '2', name: 'Bob', role: 'admin' }),
createMockUser({ id: '3', name: 'Charlie' }),
];
return HttpResponse.json(users);
}
),
];
Pattern 3: Mocking Modules with Type Safety
Mocking External Libraries
// __mocks__/axios.ts
import { AxiosInstance, AxiosResponse, AxiosRequestConfig } from 'axios';
type AxiosMethod = 'get' | 'post' | 'put' | 'delete' | 'patch';
interface MockAxios extends AxiosInstance {
mockResponse: <T = any>(data: T, status?: number) => void;
mockError: (error: Error | string, status?: number) => void;
reset: () => void;
}
const createMockAxios = (): MockAxios => {
let mockData: any = null;
let mockError: any = null;
let mockStatus = 200;
const createResponse = <T>(data: T, status = 200): AxiosResponse<T> => ({
data,
status,
statusText: 'OK',
headers: {},
config: {} as AxiosRequestConfig,
});
const axiosMock = jest.fn() as any;
const methods: Record<AxiosMethod, jest.Mock> = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
patch: jest.fn(),
};
// Implement each method
Object.entries(methods).forEach(([method, fn]) => {
fn.mockImplementation(() => {
if (mockError) {
return Promise.reject(mockError);
}
return Promise.resolve(createResponse(mockData, mockStatus));
});
axiosMock[method] = fn;
});
// Helper methods
axiosMock.mockResponse = <T>(data: T, status = 200) => {
mockData = data;
mockStatus = status;
mockError = null;
};
axiosMock.mockError = (error: Error | string, status = 500) => {
mockError = typeof error === 'string' ? new Error(error) : error;
mockStatus = status;
mockData = null;
};
axiosMock.reset = () => {
mockData = null;
mockError = null;
mockStatus = 200;
Object.values(methods).forEach(fn => fn.mockClear());
};
return axiosMock as MockAxios;
};
export default createMockAxios();
Using Type-Safe Axios Mock
// api.test.ts
import axios from 'axios';
// TypeScript knows about mockResponse and mockError
const mockAxios = axios as jest.Mocked<typeof axios> & {
mockResponse: <T>(data: T, status?: number) => void;
mockError: (error: Error | string, status?: number) => void;
reset: () => void;
};
beforeEach(() => {
mockAxios.reset();
});
test('fetches user data', async () => {
const user = createMockUser({ name: 'Alice' });
mockAxios.mockResponse(user);
const result = await fetchUser('1');
expect(result).toEqual(user);
expect(mockAxios.get).toHaveBeenCalledWith('/api/users/1');
});
test('handles API errors', async () => {
mockAxios.mockError('Network error', 500);
await expect(fetchUser('1')).rejects.toThrow('Network error');
});
Pattern 4: Mocking React Hooks
Type-Safe Hook Mocking
// hooks/useAuth.ts
interface User {
id: string;
name: string;
email: string;
}
interface UseAuthReturn {
user: User | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
export function useAuth(): UseAuthReturn {
// Real implementation
const [user, setUser] = useState<User | null>(null);
// ... rest of hook
return { user, isLoading, login, logout };
}
// __mocks__/hooks/useAuth.ts
import { UseAuthReturn } from '../../hooks/useAuth';
let mockAuthState: UseAuthReturn = {
user: null,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
};
export const useAuth = jest.fn((): UseAuthReturn => mockAuthState);
// ✅ Type-safe mock helpers
export const mockUseAuth = {
setAuthState: (state: Partial<UseAuthReturn>) => {
mockAuthState = { ...mockAuthState, ...state };
},
setUser: (user: User | null) => {
mockAuthState = { ...mockAuthState, user, isLoading: false };
},
setLoading: (isLoading: boolean) => {
mockAuthState = { ...mockAuthState, isLoading };
},
reset: () => {
mockAuthState = {
user: null,
isLoading: false,
login: jest.fn(),
logout: jest.fn(),
};
},
};
Using Mocked Hooks in Tests
// components/UserProfile.test.tsx
import { useAuth, mockUseAuth } from '../hooks/useAuth';
jest.mock('../hooks/useAuth');
beforeEach(() => {
mockUseAuth.reset();
});
test('shows user name when logged in', () => {
const user = createMockUser({ name: 'Alice' });
mockUseAuth.setUser(user);
render(<UserProfile />);
expect(screen.getByText('Alice')).toBeInTheDocument();
});
test('shows login button when not authenticated', () => {
mockUseAuth.setUser(null);
render(<UserProfile />);
expect(screen.getByText('Login')).toBeInTheDocument();
});
test('shows loading state', () => {
mockUseAuth.setLoading(true);
render(<UserProfile />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
Pattern 5: Type-Safe Testing Library Utilities
Custom Render with Providers
// testUtils/render.tsx
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
queryClient?: QueryClient;
initialRoute?: string;
}
export function renderWithProviders(
ui: React.ReactElement,
{
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
}),
initialRoute = '/',
...renderOptions
}: CustomRenderOptions = {}
) {
window.history.pushState({}, 'Test page', initialRoute);
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
{children}
</BrowserRouter>
</QueryClientProvider>
);
}
return {
...render(ui, { wrapper: Wrapper, ...renderOptions }),
queryClient,
};
}
// Usage
test('renders user profile with data', async () => {
const { queryClient } = renderWithProviders(<UserProfile userId="1" />);
// Can interact with queryClient if needed
await waitFor(() => {
expect(queryClient.getQueryData(['user', '1'])).toBeDefined();
});
});
Type-Safe Custom Queries
// testUtils/queries.ts
import { screen, within } from '@testing-library/react';
export const customQueries = {
getByDataTestId: (container: HTMLElement, id: string) => {
const element = container.querySelector(`[data-testid="${id}"]`);
if (!element) throw new Error(`Element with data-testid="${id}" not found`);
return element as HTMLElement;
},
getButtonByText: (text: string) => {
const button = screen.getByRole('button', { name: new RegExp(text, 'i') });
return button as HTMLButtonElement;
},
getInputByLabel: (label: string) => {
const input = screen.getByLabelText(new RegExp(label, 'i'));
return input as HTMLInputElement;
},
};
// Usage with types
test('form interaction', () => {
render(<LoginForm />);
const emailInput = customQueries.getInputByLabel('Email');
const submitButton = customQueries.getButtonByText('Submit');
// TypeScript knows these are the correct element types
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
fireEvent.click(submitButton);
});
Pattern 6: Type-Safe Test Fixtures
Fixture Files with Type Safety
// fixtures/users.ts
import { User } from '../types';
export const userFixtures = {
admin: {
id: 'admin-1',
name: 'Admin User',
email: 'admin@example.com',
role: 'admin' as const,
createdAt: new Date('2023-01-01'),
} satisfies User,
regularUser: {
id: 'user-1',
name: 'Regular User',
email: 'user@example.com',
role: 'user' as const,
createdAt: new Date('2023-06-01'),
} satisfies User,
newUser: {
id: 'user-2',
name: 'New User',
email: 'new@example.com',
role: 'user' as const,
createdAt: new Date('2024-01-01'),
} satisfies User,
} as const;
// ✅ TypeScript validates each fixture matches User interface
// ✅ Using 'satisfies' keeps literal types while enforcing structure
Fixture Collections
// fixtures/index.ts
interface Post {
id: string;
title: string;
content: string;
authorId: string;
published: boolean;
}
export const fixtures = {
users: userFixtures,
posts: {
published: {
id: 'post-1',
title: 'Published Post',
content: 'This is published',
authorId: userFixtures.admin.id,
published: true,
} satisfies Post,
draft: {
id: 'post-2',
title: 'Draft Post',
content: 'This is a draft',
authorId: userFixtures.regularUser.id,
published: false,
} satisfies Post,
},
} as const;
// Usage in tests
test('admin can see drafts', () => {
const user = fixtures.users.admin;
const draft = fixtures.posts.draft;
expect(canViewPost(user, draft)).toBe(true);
});
Pattern 7: Mocking Date and Time
Type-Safe Date Mocking
// testUtils/time.ts
export class MockDate {
private originalDate: DateConstructor;
private mockTime: number;
constructor(dateString: string) {
this.originalDate = global.Date;
this.mockTime = new Date(dateString).getTime();
}
install() {
const mockTime = this.mockTime;
global.Date = class extends Date {
constructor(...args: any[]) {
if (args.length === 0) {
super(mockTime);
} else {
super(...args);
}
}
static now() {
return mockTime;
}
} as DateConstructor;
}
restore() {
global.Date = this.originalDate;
}
}
// Usage
test('shows correct date', () => {
const mockDate = new MockDate('2024-01-15T12:00:00Z');
mockDate.install();
const component = render(<DateDisplay />);
expect(screen.getByText('January 15, 2024')).toBeInTheDocument();
mockDate.restore();
});
Pattern 8: Type-Safe Mock Responses
Creating Response Builders
// testUtils/responses.ts
interface ApiResponse<T> {
data: T;
status: number;
message?: string;
}
interface ApiError {
error: string;
code: string;
status: number;
}
export class ResponseBuilder {
static success<T>(data: T, message?: string): ApiResponse<T> {
return {
data,
status: 200,
message,
};
}
static created<T>(data: T): ApiResponse<T> {
return {
data,
status: 201,
};
}
static error(message: string, code: string, status = 400): ApiError {
return {
error: message,
code,
status,
};
}
static notFound(resource: string): ApiError {
return {
error: `${resource} not found`,
code: 'NOT_FOUND',
status: 404,
};
}
static unauthorized(): ApiError {
return {
error: 'Unauthorized',
code: 'UNAUTHORIZED',
status: 401,
};
}
}
// Usage with MSW
http.get('/api/users/:id', ({ params }) => {
const user = createMockUser({ id: params.id });
return HttpResponse.json(ResponseBuilder.success(user));
});
http.get('/api/users/:id/not-found', () => {
return HttpResponse.json(
ResponseBuilder.notFound('User'),
{ status: 404 }
);
});
Pattern 9: Testing React Query with Type Safety
Mocking Query Client
// testUtils/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
});
}
// Pre-populate cache with typed data
export function createQueryClientWithData<T>(
queryKey: unknown[],
data: T
): QueryClient {
const queryClient = createTestQueryClient();
queryClient.setQueryData(queryKey, data);
return queryClient;
}
Testing Components with Queries
// UserProfile.test.tsx
import { createQueryClientWithData } from './testUtils/queryClient';
test('displays cached user data', () => {
const user = createMockUser({ name: 'Alice' });
const queryClient = createQueryClientWithData(['user', '1'], user);
renderWithProviders(<UserProfile userId="1" />, { queryClient });
expect(screen.getByText('Alice')).toBeInTheDocument();
});
test('shows loading state', () => {
const queryClient = createTestQueryClient();
renderWithProviders(<UserProfile userId="1" />, { queryClient });
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
Pattern 10: Keeping Test Types in Sync
Shared Type Definitions
// types/api.ts - Source of truth for API types
export interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'user';
createdAt: Date;
}
export interface CreateUserRequest {
name: string;
email: string;
role?: 'admin' | 'user';
}
export interface UpdateUserRequest {
name?: string;
email?: string;
role?: 'admin' | 'user';
}
// mocks/handlers.ts - Use the same types
import { User, CreateUserRequest } from '../types/api';
import { createMockUser } from '../testUtils/factories';
export const handlers = [
http.post<never, CreateUserRequest, User>(
'/api/users',
async ({ request }) => {
const body = await request.json();
// TypeScript ensures body matches CreateUserRequest
const user = createMockUser({
name: body.name,
email: body.email,
role: body.role,
});
return HttpResponse.json(user);
}
),
];
Type Assertions for Runtime Validation
// testUtils/assertions.ts
import { User } from '../types/api';
export function assertIsUser(value: unknown): asserts value is User {
if (typeof value !== 'object' || value === null) {
throw new Error('Value is not an object');
}
const obj = value as Record<string, unknown>;
if (typeof obj.id !== 'string') {
throw new Error('User.id must be a string');
}
if (typeof obj.name !== 'string') {
throw new Error('User.name must be a string');
}
if (typeof obj.email !== 'string') {
throw new Error('User.email must be a string');
}
if (obj.role !== 'admin' && obj.role !== 'user') {
throw new Error('User.role must be admin or user');
}
}
// Usage in tests
test('API returns valid user', async () => {
const response = await fetch('/api/users/1');
const data = await response.json();
// Runtime validation that also satisfies TypeScript
assertIsUser(data);
// Now TypeScript knows data is User
expect(data.name).toBe('Test User');
});
Pattern 11: Type-Safe Spy Functions
Creating Typed Spies
// testUtils/spies.ts
type SpyFunction<T extends (...args: any[]) => any> = jest.Mock<
ReturnType<T>,
Parameters<T>
>;
export function createSpy<T extends (...args: any[]) => any>(
implementation?: T
): SpyFunction<T> {
return jest.fn(implementation);
}
// Usage
interface Logger {
log: (message: string, level: 'info' | 'error') => void;
error: (error: Error) => void;
}
const mockLogger: Logger = {
log: createSpy<Logger['log']>(),
error: createSpy<Logger['error']>(),
};
test('logs errors correctly', () => {
const error = new Error('Test error');
mockLogger.error(error);
expect(mockLogger.error).toHaveBeenCalledWith(error);
// TypeScript knows the parameter types
const calls = mockLogger.error.mock.calls;
expect(calls[0][0]).toBeInstanceOf(Error);
});
Pattern 12: Integration Test Utilities
Type-Safe Test Database Seeding
// testUtils/database.ts
interface SeedData<T> {
table: string;
data: T[];
}
export class TestDatabase {
async seed<T>(seedData: SeedData<T>): Promise<void> {
// Implementation depends on your database
// TypeScript ensures data matches expected shape
}
async clear(table: string): Promise<void> {
// Clear table
}
async findOne<T>(table: string, id: string): Promise<T | null> {
// Find record
return null;
}
}
// Usage
test('creates user in database', async () => {
const db = new TestDatabase();
await db.seed<User>({
table: 'users',
data: [createMockUser({ id: '1' })],
});
const user = await db.findOne<User>('users', '1');
expect(user?.name).toBe('Test User');
});
Anti-Patterns to Avoid
❌ Don't: Cast Everything to Any
// ❌ Bad - defeats TypeScript
const mockUser = { name: 'Test' } as any;
const mockFetch = jest.fn() as any;
✅ Do: Use Proper Types
// ✅ Good - TypeScript can help
const mockUser = createMockUser({ name: 'Test' });
const mockFetch = jest.fn<Promise<User>, [string]>();
❌ Don't: Duplicate Type Definitions
// ❌ Bad - types can drift
interface User {
id: string;
name: string;
}
// In test file
interface TestUser {
id: string;
name: string;
}
✅ Do: Import Shared Types
// ✅ Good - single source of truth
import { User } from '../types';
const mockUser = createMockUser();
❌ Don't: Ignore Type Errors in Tests
// ❌ Bad - @ts-ignore hides problems
// @ts-ignore
expect(user.nonExistentProperty).toBe(true);
✅ Do: Fix the Root Cause
// ✅ Good - update types or test
const user = createMockUser();
expect(user.name).toBe('Test User');
Complete Example: Type-Safe Test Suite
// UserService.test.ts
import { renderWithProviders } from './testUtils/render';
import { createMockUser } from './testUtils/factories';
import { server } from './mocks/server';
import { http, HttpResponse } from 'msw';
describe('UserService', () => {
beforeEach(() => {
server.resetHandlers();
});
describe('fetchUser', () => {
test('returns user data', async () => {
const mockUser = createMockUser({ name: 'Alice' });
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.json(mockUser);
})
);
const user = await fetchUser('1');
expect(user).toEqual(mockUser);
});
test('handles errors', async () => {
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.json(
{ error: 'Not found' },
{ status: 404 }
);
})
);
await expect(fetchUser('1')).rejects.toThrow('Not found');
});
});
describe('UserProfile component', () => {
test('displays user information', async () => {
const user = createMockUser({ name: 'Alice', email: 'alice@example.com' });
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.json(user);
})
);
renderWithProviders(<UserProfile userId="1" />);
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
});
test('shows loading state', () => {
renderWithProviders(<UserProfile userId="1" />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
});
});
The Benefits
Before type-safe testing:
- Tests with
anyeverywhere - Production breaks after passing tests
- Refactoring breaks tests silently
- No confidence in test coverage
After type-safe testing:
- TypeScript validates mock data
- Tests break when types change (good!)
- Refactoring is safe and guided
- High confidence tests catch real bugs
Quick Reference
// ✅ Mock factories
const user = createMockUser({ name: 'Alice' });
// ✅ MSW with types
http.get<PathParams, RequestBody, ResponseBody>('/api/users/:id', handler);
// ✅ Typed fixtures
export const fixtures = { ... } satisfies Record<string, User>;
// ✅ Custom render
renderWithProviders(<Component />, { queryClient });
// ✅ Typed spies
const spy = createSpy<(arg: string) => void>();
// ❌ Don't use any
const mock = { ... } as any;
Conclusion
Type-safe testing isn't about making tests harder to write. It's about making them impossible to write incorrectly.
When you refactor production code, your tests should break at compile-time, not runtime. When types change, TypeScript should guide you through updating every affected test.
Stop lying to TypeScript with any. Start building tests that actually protect you from bugs.
Your future self—debugging a production issue at 2am—will thank you.
What testing challenges are you facing with TypeScript? Share in the comments!
Top comments (0)