TypeScript Testing Best Practices
TypeScript's compile-time type safety catches many bugs early - type mismatches, null references, missing properties - but it cannot catch logic errors, edge cases, or runtime behavior issues. Your types might be perfect, but your algorithm could be wrong. Comprehensive testing validates that your code does what it's supposed to do, not just that it compiles. Jest provides the test runner and assertion library, Testing Library enables behavior-driven component testing, and Stryker mutation testing verifies that your tests are actually effective at catching bugs.
Overview
This guide covers TypeScript-specific testing patterns using Jest, Testing Library, and Stryker mutation testing. Testing TypeScript code requires proper configuration to handle TypeScript syntax, understanding how to leverage types in tests for better IDE support, and knowing when to mock versus use real implementations. We'll cover fast unit tests for pure functions, async testing patterns, component testing with Testing Library, and mutation testing with Stryker to ensure your tests are actually catching bugs. This complements Unit Testing and Mutation Testing with TypeScript-specific patterns.
Follow the Testing Honeycomb model: prioritize integration tests with real dependencies, use unit tests for complex business logic, and maintain high mutation test scores (≥80%). See Testing Strategy for the overall approach and guidance on when to use each test type.
Core Principles
- Type-Safe Tests: Leverage TypeScript in tests for better IDE support and refactoring
- Test Behavior: Focus on user-facing behavior, not implementation details
- Mutation Testing: Use Stryker to verify test effectiveness (target: 80%+ mutation score)
- Fast Feedback: Unit tests run in milliseconds; optimize test setup
- Mock Sparingly: Prefer real implementations; mock only external dependencies
Jest Configuration
jest.config.ts
Jest needs configuration to understand TypeScript syntax, resolve module paths, and provide the right test environment. Here's a comprehensive configuration with explanations:
import type { Config } from 'jest';
const config: Config = {
// preset: 'ts-jest' enables TypeScript support
// ts-jest transforms .ts/.tsx files before Jest runs them
preset: 'ts-jest',
// testEnvironment: Simulates browser APIs for frontend tests
// 'jsdom' provides window, document, etc.
// Use 'node' for backend/API tests without DOM dependencies
testEnvironment: 'jsdom',
// roots: Where Jest looks for tests and modules
// Usually just src/ to avoid scanning node_modules
roots: ['<rootDir>/src'],
// Module resolution - maps import paths to file system paths
// This must match your tsconfig.json paths configuration
moduleNameMapper: {
// '@/' maps to 'src/' for clean imports
'^@/(.*)$': '<rootDir>/src/$1',
'^@components/(.*)$': '<rootDir>/src/components/$1',
'^@services/(.*)$': '<rootDir>/src/services/$1',
'^@types/(.*)$': '<rootDir>/src/types/$1',
// Mock CSS imports (they're not JavaScript)
// identity-obj-proxy returns className as-is for testing
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
},
// Coverage configuration
// collectCoverageFrom: Which files to include in coverage reports
collectCoverageFrom: [
'src/**/*.{ts,tsx}', // All TypeScript files in src/
'!src/**/*.d.ts', // Exclude type definition files
'!src/**/*.stories.tsx', // Exclude Storybook stories
'!src/**/__tests__/**', // Exclude test files themselves
'!src/main.tsx' // Exclude entry point
],
// coverageThreshold: Enforce minimum coverage percentages
// Build fails if coverage drops below these thresholds
coverageThreshold: {
global: {
branches: 85, // 85% of if/else branches covered
functions: 85, // 85% of functions called
lines: 85, // 85% of lines executed
statements: 85 // 85% of statements executed
}
},
// Test patterns - which files Jest considers tests
testMatch: [
'**/__tests__/**/*.test.ts?(x)', // Files in __tests__ folders
'**/?(*.)+(spec|test).ts?(x)' // Files ending in .test.ts or .spec.ts
],
// Setup - runs before each test file
// Use for global test configuration (mocks, polyfills, etc.)
setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
// Performance - use half your CPU cores for parallel test execution
// Speeds up test runs significantly
maxWorkers: '50%',
// Transform - how to process files before testing
// ts-jest transforms TypeScript to JavaScript
transform: {
'^.+\\.tsx?$': ['ts-jest', {
// Override tsconfig for tests
// jsx: 'react-jsx' for React 17+ JSX transform
tsconfig: {
jsx: 'react-jsx',
esModuleInterop: true
}
}]
}
};
export default config;
Key Configuration Decisions:
-
preset: 'ts-jest': Essential for TypeScript support. Without this, Jest can't parse TypeScript syntax. -
testEnvironment: 'jsdom': Provides browser APIs (DOM, fetch, localStorage) needed for frontend testing. Use'node'for pure backend code. -
Module Mappers: Must match your
tsconfig.jsonpaths. If they don't match, imports will work in your app but fail in tests. -
Coverage Thresholds: 85% is a good target. Lower thresholds let quality slip; higher thresholds force testing of trivial code.
Test Setup File
// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
// Cleanup after each test
afterEach(() => {
cleanup();
jest.clearAllMocks();
});
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock fetch
global.fetch = jest.fn();
Testing Pure Functions
Basic Testing Pattern
// payment-calculator.ts
export interface Payment {
amount: number;
currency: string;
fee?: number;
}
export function calculateTotal(payments: Payment[]): number {
return payments.reduce((sum, payment) =>
sum + payment.amount + (payment.fee ?? 0), 0
);
}
export function filterByCurrency(
payments: Payment[],
currency: string
): Payment[] {
return payments.filter(p => p.currency === currency);
}
// payment-calculator.test.ts
import { calculateTotal, filterByCurrency } from './payment-calculator';
describe('PaymentCalculator', () => {
describe('calculateTotal', () => {
it('should calculate total for payments without fees', () => {
const payments = [
{ amount: 100, currency: 'USD' },
{ amount: 200, currency: 'USD' }
];
expect(calculateTotal(payments)).toBe(300);
});
it('should include fees in total calculation', () => {
const payments = [
{ amount: 100, currency: 'USD', fee: 5 },
{ amount: 200, currency: 'USD', fee: 10 }
];
expect(calculateTotal(payments)).toBe(315);
});
it('should return 0 for empty array', () => {
expect(calculateTotal([])).toBe(0);
});
});
describe('filterByCurrency', () => {
it('should filter payments by currency', () => {
const payments = [
{ amount: 100, currency: 'USD' },
{ amount: 200, currency: 'EUR' },
{ amount: 150, currency: 'USD' }
];
const usdPayments = filterByCurrency(payments, 'USD');
expect(usdPayments).toHaveLength(2);
expect(usdPayments).toEqual([
{ amount: 100, currency: 'USD' },
{ amount: 150, currency: 'USD' }
]);
});
});
});
Testing Async Code
Promises and Async/Await
// payment-service.ts
export interface PaymentResult {
id: string;
status: 'success' | 'failed';
transactionId?: string;
}
export class PaymentService {
async processPayment(amount: number): Promise<PaymentResult> {
const response = await fetch('/api/payments', {
method: 'POST',
body: JSON.stringify({ amount }),
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error('Payment processing failed');
}
return response.json();
}
}
// payment-service.test.ts
import { PaymentService } from './payment-service';
describe('PaymentService', () => {
let service: PaymentService;
beforeEach(() => {
service = new PaymentService();
jest.clearAllMocks();
});
it('should process payment successfully', async () => {
const mockResponse: PaymentResult = {
id: 'PAY-123',
status: 'success',
transactionId: 'TXN-456'
};
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockResponse
});
const result = await service.processPayment(100);
expect(result).toEqual(mockResponse);
expect(global.fetch).toHaveBeenCalledWith(
'/api/payments',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ amount: 100 })
})
);
});
it('should throw error on failed response', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 500
});
await expect(service.processPayment(100))
.rejects
.toThrow('Payment processing failed');
});
});
Mocking with Jest
Manual Mocks
// services/__mocks__/payment-api.ts
export const mockProcessPayment = jest.fn();
export class PaymentAPI {
processPayment = mockProcessPayment;
}
// payment-handler.ts
import { PaymentAPI } from './services/payment-api';
export class PaymentHandler {
constructor(private api: PaymentAPI) {}
async handle(amount: number): Promise<void> {
const result = await this.api.processPayment(amount);
console.log('Processed:', result);
}
}
// payment-handler.test.ts
import { PaymentHandler } from './payment-handler';
import { PaymentAPI, mockProcessPayment } from './services/payment-api';
jest.mock('./services/payment-api');
describe('PaymentHandler', () => {
it('should call API to process payment', async () => {
const api = new PaymentAPI();
const handler = new PaymentHandler(api);
mockProcessPayment.mockResolvedValue({
id: 'PAY-123',
status: 'success'
});
await handler.handle(100);
expect(mockProcessPayment).toHaveBeenCalledWith(100);
});
});
Mocking Modules
// Mock entire module
jest.mock('@/services/audit-logger', () => ({
AuditLogger: jest.fn().mockImplementation(() => ({
log: jest.fn()
}))
}));
// Partial module mock
jest.mock('@/utils/date-utils', () => ({
...jest.requireActual('@/utils/date-utils'),
getCurrentTimestamp: jest.fn(() => 1234567890)
}));
Testing React Components with Testing Library
Basic Component Testing
// PaymentForm.tsx
interface PaymentFormProps {
onSubmit: (amount: number, currency: string) => void;
}
export function PaymentForm({ onSubmit }: PaymentFormProps) {
const [amount, setAmount] = useState('');
const [currency, setCurrency] = useState('USD');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(Number(amount), currency);
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="amount">Amount</label>
<input
id="amount"
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
<label htmlFor="currency">Currency</label>
<select
id="currency"
value={currency}
onChange={(e) => setCurrency(e.target.value)}
>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
</select>
<button type="submit">Submit Payment</button>
</form>
);
}
// PaymentForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PaymentForm } from './PaymentForm';
describe('PaymentForm', () => {
it('should render form fields', () => {
render(<PaymentForm onSubmit={jest.fn()} />);
expect(screen.getByLabelText(/amount/i)).toBeInTheDocument();
expect(screen.getByLabelText(/currency/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /submit payment/i }))
.toBeInTheDocument();
});
it('should call onSubmit with form values', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<PaymentForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/amount/i), '100');
await user.selectOptions(screen.getByLabelText(/currency/i), 'EUR');
await user.click(screen.getByRole('button', { name: /submit payment/i }));
expect(handleSubmit).toHaveBeenCalledWith(100, 'EUR');
});
});
See React Testing for comprehensive React testing patterns.
Stryker Mutation Testing
Stryker Configuration
Mutation testing answers the question: "Do my tests actually catch bugs?" Stryker modifies (mutates) your code in small ways - changing > to >=, replacing + with -, removing conditionals - and reruns your tests. If your tests still pass after a mutation, the mutation "survived," indicating a gap in your test coverage.
// stryker.config.json
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
// packageManager: Which package manager to use for installing dependencies
"packageManager": "npm",
// testRunner: Which test framework to use
// 'jest' runs your existing Jest tests for each mutation
"testRunner": "jest",
// coverageAnalysis: How to optimize test execution
// 'perTest' tracks which tests cover which code for faster runs
"coverageAnalysis": "perTest",
// mutate: Which files to mutate (and which to skip)
// Include source files, exclude tests and test utilities
"mutate": [
"src/**/*.ts", // Mutate all TypeScript files
"src/**/*.tsx", // Mutate all TypeScript React files
"!src/**/*.test.ts", // Don't mutate test files
"!src/**/*.test.tsx",
"!src/**/*.spec.ts",
"!src/**/*.spec.tsx",
"!src/**/__tests__/**", // Don't mutate test directories
"!src/test/**" // Don't mutate test utilities
],
// thresholds: Mutation score targets
// Mutation score = (killed mutants / total mutants) * 100
"thresholds": {
"high": 85, // Green: 85%+ mutants killed
"low": 70, // Yellow: 70-85% mutants killed
"break": 65 // Red: <65% mutants killed (fails CI)
},
// timeoutMS: Maximum time for a single mutant test run
// Infinite loops created by mutations will timeout
"timeoutMS": 60000,
// concurrency: How many mutations to test in parallel
// Adjust based on your CPU cores and memory
"concurrency": 4,
// plugins: Stryker plugins to load
"plugins": [
"@stryker-mutator/jest-runner", // Run Jest for each mutation
"@stryker-mutator/typescript-checker" // Type-check mutations
],
// checkers: Enable TypeScript type checking
// Skips mutations that would create TypeScript errors
"checkers": ["typescript"],
// tsconfigFile: TypeScript configuration for type checking
"tsconfigFile": "tsconfig.json"
}
How Mutation Testing Works:
-
Generate Mutations: Stryker modifies your code (e.g., changes
amount > 0toamount >= 0). -
Run Tests: For each mutation, Stryker runs your test suite.
-
Check Results:
- Killed: Tests fail → Good! Your tests caught the bug.
- Survived: Tests pass → Bad! Your tests missed the bug.
- No Coverage: No tests run this code → Add tests!
-
Calculate Score:
(killed / total) × 100= mutation score.
Running Stryker
# Install Stryker and plugins
npm install --save-dev @stryker-mutator/core \
@stryker-mutator/jest-runner \
@stryker-mutator/typescript-checker
# Run mutation testing (this takes time - typically 10-50x longer than unit tests)
npx stryker run
# View detailed HTML report showing which mutations survived
open reports/mutation/html/index.html
Improving Mutation Coverage
Mutation testing reveals weak tests that pass despite buggy code. Here's how to strengthen them:
// WEAK: Test doesn't catch boundary mutation
// If isValidAmount has bug: amount > 0 (should be amount >= 0.01)
// This test still passes with 100, so the bug survives
it('should validate positive amounts', () => {
expect(isValidAmount(100)).toBe(true);
});
// STRONG: Tests boundary conditions
// This will catch if someone changes > to >=, or >= to >, etc.
describe('isValidAmount', () => {
it.each([
[0, false], // Boundary: exactly zero (should be invalid)
[0.01, true], // Just above zero (should be valid)
[-0.01, false], // Just below zero (should be invalid)
[100, true], // Normal valid case
[-100, false] // Normal invalid case
])('should return %s for amount %s', (amount, expected) => {
expect(isValidAmount(amount)).toBe(expected);
});
});
Common Surviving Mutations and How to Kill Them:
-
Boundary Conditions: Test exact boundaries (0, -1, 1) not just typical values.
-
Return Values: Test that functions return correct values, not just that they don't throw.
-
Boolean Expressions: Test both
trueandfalsebranches of every conditional. -
Arithmetic: Test edge cases where operators matter (
+vs-,*vs/).
See Mutation Testing for comprehensive Stryker guidance and strategies.
Test Organization
File Structure
src/
├── components/
│ ├── PaymentForm.tsx
│ └── PaymentForm.test.tsx
├── services/
│ ├── payment-service.ts
│ └── payment-service.test.ts
├── utils/
│ ├── validators.ts
│ └── validators.test.ts
└── test/
├── setup.ts
├── factories/
│ └── payment-factory.ts
└── mocks/
└── payment-api.mock.ts
Test Factories
// test/factories/payment-factory.ts
import { Payment } from '@/types/payment';
export class PaymentFactory {
static create(overrides?: Partial<Payment>): Payment {
return {
id: 'PAY-123',
amount: 100,
currency: 'USD',
status: 'pending',
createdAt: new Date('2025-01-01'),
...overrides
};
}
static createMany(count: number, overrides?: Partial<Payment>): Payment[] {
return Array.from({ length: count }, (_, i) =>
PaymentFactory.create({
id: `PAY-${i + 1}`,
...overrides
})
);
}
}
// Usage in tests
const payment = PaymentFactory.create({ amount: 200 });
const payments = PaymentFactory.createMany(5, { currency: 'EUR' });
Further Reading
Internal Documentation
- Testing Strategy - Overall testing approach and when to use each test type
- Unit Testing - General unit testing principles
- Mutation Testing - Stryker configuration
- React Testing - React-specific testing
- TypeScript General - TypeScript best practices
- CI Testing - GitLab CI integration
External Resources
Summary
Key Takeaways
- Configure Jest with TypeScript using ts-jest and proper module resolution
- Test behavior, not implementation - focus on user-facing functionality
- Use Testing Library for React component tests with user-centric queries
- Mock sparingly - prefer real implementations, mock only external dependencies
- Mutation testing with Stryker verifies test effectiveness (target: 80%+)
- Test factories create reusable test data with sensible defaults
- Async testing requires proper mocking of fetch/promises
- Type-safe tests leverage TypeScript for better refactoring and IDE support
- Setup files configure test environment once for all tests
- Organize tests alongside source files for easy discovery
Next Steps: Review React Testing for component testing patterns and Mutation Testing for improving test quality.