Test-Driven Development in TypeScript: Beyond the Basics
Test-Driven Development (TDD) has evolved significantly with modern TypeScript tooling and frameworks. While most developers understand the basic red-green-refactor cycle, mastering TDD in TypeScript requires understanding advanced patterns, effective mocking strategies, and leveraging the type system for better test design.
Beyond Basic TDD: Advanced Patterns
Type-Driven Test Design
TypeScript’s type system provides unique opportunities to improve test design. Instead of just testing implementation details, we can use types to guide our test structure and ensure comprehensive coverage:
// Define clear interfaces for testability
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<User>;
delete(id: string): Promise<void>;
}
interface EmailService {
sendWelcomeEmail(user: User): Promise<void>;
}
// The service under test
class UserService {
constructor(
private userRepo: UserRepository,
private emailService: EmailService
) {}
async createUser(userData: CreateUserRequest): Promise<User> {
// Implementation details
}
}
This approach makes dependencies explicit and testable while the type system prevents many runtime errors during testing.
Property-Based Testing with TypeScript
Move beyond example-based tests to property-based testing using libraries like fast-check
:
import fc from 'fast-check';
describe('UserValidator', () => {
it('should validate email format for any string input', () => {
fc.assert(
fc.property(fc.string(), (input) => {
const result = UserValidator.isValidEmail(input);
if (result) {
expect(input).toMatch(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
}
return true;
})
);
});
it('should handle edge cases in user data', () => {
const userDataArbitrary = fc.record({
name: fc.string({ minLength: 1, maxLength: 100 }),
age: fc.integer({ min: 0, max: 150 }),
email: fc.emailAddress()
});
fc.assert(
fc.property(userDataArbitrary, (userData) => {
const result = UserValidator.validate(userData);
expect(result.isValid).toBe(true);
})
);
});
});
Advanced Mocking Strategies
Effective mocking in TypeScript goes beyond simple jest.fn(). Create type-safe mocks that evolve with your interfaces:
type MockType<T> = {
[P in keyof T]?: jest.MockedFunction<T[P]>;
};
function createMockRepository(): MockType<UserRepository> {
return {
findById: jest.fn(),
save: jest.fn(),
delete: jest.fn()
};
}
describe('UserService', () => {
let userService: UserService;
let mockUserRepo: MockType<UserRepository>;
let mockEmailService: MockType<EmailService>;
beforeEach(() => {
mockUserRepo = createMockRepository();
mockEmailService = { sendWelcomeEmail: jest.fn() };
userService = new UserService(
mockUserRepo as UserRepository,
mockEmailService as EmailService
);
});
it('should save user and send welcome email', async () => {
const userData = { name: 'John', email: 'john@example.com' };
const savedUser = { id: '123', ...userData };
mockUserRepo.save?.mockResolvedValue(savedUser);
mockEmailService.sendWelcomeEmail?.mockResolvedValue();
const result = await userService.createUser(userData);
expect(mockUserRepo.save).toHaveBeenCalledWith(
expect.objectContaining(userData)
);
expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith(savedUser);
expect(result).toEqual(savedUser);
});
});
Testing Async Patterns
Testing Complex Async Flows
Modern applications involve complex async operations. Structure tests to handle these patterns effectively:
describe('OrderProcessingService', () => {
it('should handle concurrent order processing', async () => {
const orders = Array.from({ length: 5 }, (_, i) =>
createTestOrder({ id: `order-${i}` })
);
// Start all processing concurrently
const processingPromises = orders.map(order =>
orderService.processOrder(order)
);
const results = await Promise.allSettled(processingPromises);
// Verify all succeeded
results.forEach((result, index) => {
expect(result.status).toBe('fulfilled');
if (result.status === 'fulfilled') {
expect(result.value.status).toBe('PROCESSED');
}
});
// Verify side effects
expect(mockPaymentService.charge).toHaveBeenCalledTimes(5);
expect(mockInventoryService.reserve).toHaveBeenCalledTimes(5);
});
});
Testing Error Boundaries
Design tests that verify error handling and recovery:
describe('Resilient Service Operations', () => {
it('should retry on transient failures', async () => {
mockExternalService.processRequest
.mockRejectedValueOnce(new TransientError('Network timeout'))
.mockRejectedValueOnce(new TransientError('Service unavailable'))
.mockResolvedValue({ success: true });
const result = await resilientService.processWithRetry(testData);
expect(result.success).toBe(true);
expect(mockExternalService.processRequest).toHaveBeenCalledTimes(3);
});
it('should fail fast on permanent errors', async () => {
mockExternalService.processRequest
.mockRejectedValue(new PermanentError('Invalid credentials'));
await expect(
resilientService.processWithRetry(testData)
).rejects.toThrow('Invalid credentials');
expect(mockExternalService.processRequest).toHaveBeenCalledTimes(1);
});
});
Integration Testing Strategies
Database Integration Tests
For TypeScript applications using ORMs, create focused integration tests:
describe('User Repository Integration', () => {
let repository: UserRepository;
let testDb: TestDatabase;
beforeAll(async () => {
testDb = await TestDatabase.create();
repository = new TypeORMUserRepository(testDb.connection);
});
afterAll(async () => {
await testDb.cleanup();
});
beforeEach(async () => {
await testDb.clearTables(['users']);
});
it('should persist and retrieve user data correctly', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com',
preferences: { theme: 'dark', notifications: true }
};
const savedUser = await repository.save(userData);
expect(savedUser.id).toBeDefined();
const retrievedUser = await repository.findById(savedUser.id);
expect(retrievedUser).toMatchObject(userData);
expect(retrievedUser?.preferences).toEqual(userData.preferences);
});
});
API Integration Tests
Test your TypeScript APIs with type-safe request/response validation:
import request from 'supertest';
import { app } from '../src/app';
describe('User API Integration', () => {
it('should create and return user with correct types', async () => {
const userData = {
name: 'Jane Doe',
email: 'jane@example.com'
};
const response = await request(app)
.post('/users')
.send(userData)
.expect(201);
// Type-safe response validation
const createdUser: User = response.body;
expect(createdUser.id).toMatch(/^user-[a-z0-9]+$/);
expect(createdUser.name).toBe(userData.name);
expect(createdUser.email).toBe(userData.email);
expect(createdUser.createdAt).toBeDefined();
});
});
Test Organization and Maintenance
Hierarchical Test Structure
Organize tests to reflect your domain model and make them easier to maintain:
describe('User Management Domain', () => {
describe('User Creation', () => {
describe('with valid data', () => {
it('should create user successfully');
it('should send welcome email');
it('should log creation event');
});
describe('with invalid data', () => {
it('should reject missing email');
it('should reject invalid email format');
it('should reject duplicate email');
});
});
describe('User Updates', () => {
describe('profile updates', () => {
it('should update allowed fields');
it('should preserve read-only fields');
});
});
});
Test Data Management
Create maintainable test data factories:
class UserTestDataBuilder {
private user: Partial<User> = {};
withName(name: string): this {
this.user.name = name;
return this;
}
withEmail(email: string): this {
this.user.email = email;
return this;
}
withRole(role: UserRole): this {
this.user.role = role;
return this;
}
build(): User {
return {
id: this.user.id || generateUserId(),
name: this.user.name || 'Test User',
email: this.user.email || 'test@example.com',
role: this.user.role || UserRole.STANDARD,
createdAt: new Date(),
...this.user
};
}
}
// Usage in tests
const testUser = new UserTestDataBuilder()
.withName('Admin User')
.withRole(UserRole.ADMIN)
.build();
Performance and Scalability Testing
Testing Performance Characteristics
Include performance assertions in your test suite:
describe('Performance Requirements', () => {
it('should process large datasets efficiently', async () => {
const largeDataset = Array.from({ length: 1000 }, (_, i) =>
createTestRecord(i)
);
const startTime = performance.now();
const results = await dataProcessor.processAll(largeDataset);
const endTime = performance.now();
expect(endTime - startTime).toBeLessThan(1000); // 1 second max
expect(results).toHaveLength(1000);
expect(results.every(r => r.processed)).toBe(true);
});
});
Conclusion
Advanced TDD in TypeScript leverages the type system to create more robust, maintainable tests. By combining property-based testing, sophisticated mocking strategies, and comprehensive integration testing, you can build confidence in your codebase while maintaining development velocity.
The key is to treat your tests as first-class citizens in your codebase—they should be as well-designed, type-safe, and maintainable as your production code. This investment pays dividends in reduced debugging time, easier refactoring, and higher-quality software.
Next in this series, we’ll explore how to automate code quality enforcement through gates and continuous integration, building on the solid testing foundation we’ve established here.
Comments