Building software without a safety net is a high-risk endeavor. Bugs creep in, refactoring becomes terrifying, and confidence in your codebase erodes. Test-Driven Development (TDD) provides a disciplined solution.
This guide moves beyond theory. You'll learn the practical TDD workflow for TypeScript projects, see real code patterns, and understand how to write tests that drive better design. Let's build reliable software, one test at a time.
What is Test-Driven Development?
TDD is a software development process where you write tests before you write the implementation code. It's a continuous cycle of:
- Red: Write a failing test that defines a small piece of desired functionality.
- Green: Write the simplest possible code to make that test pass.
- Refactor: Clean up the code, ensuring tests still pass.
This cycle is the core of TDD. It's not just about finding bugs; it's about designing your code through tests.
Key Takeaway: TDD flips the traditional script. Instead of writing code and then testing it, you define the behavior with a test and then write just enough code to satisfy it.
Setting Up Your TypeScript TDD Environment
Before diving into the cycle, a proper environment is crucial. You need a test runner, an assertion library, and seamless integration with TypeScript.
Essential Tools:
- Jest or Vitest: Modern, fast test runners with excellent TypeScript support.
- ts-jest or vite: For seamless TypeScript compilation in tests.
- TypeScript Compiler (tsc): For type checking.
- A Code Editor: With strong testing and TypeScript plugins (e.g., VS Code).
Basic package.json Setup:
{
"scripts": {
"test": "vitest",
"test:watch": "vitest --watch",
"test:coverage": "vitest run --coverage"
},
"devDependencies": {
"vitest": "^1.2.0",
"typescript": "^5.3.3"
}
}With this setup, you can run npm test and see your first (failing) test. Now, let's explore the TDD cycle in detail.
The Red-Green-Refactor Cycle in Action
We'll build a simple ShoppingCart class using TDD. The goal is to calculate the total price of items.
Step 1: Red - Write the Failing Test
Create a file shoppingCart.test.ts. Define the expected behavior.
import { describe, it, expect } from 'vitest';
import { ShoppingCart } from './shoppingCart';
describe('ShoppingCart', () => {
it('should start with a total of zero', () => {
const cart = new ShoppingCart();
expect(cart.getTotal()).toBe(0);
});
});Run the test. It fails with: ReferenceError: ShoppingCart is not defined. This is Red. The test defines our first requirement.
Step 2: Green - Make the Test Pass
Create shoppingCart.ts and implement the minimal code.
export class ShoppingCart {
getTotal(): number {
return 0;
}
}Run the test again. It passes! This is Green. We did the minimum work required.
Step 3: Refactor
The code is trivial, so refactoring isn't needed yet. The cycle repeats.
Next Cycle: Adding Items
Red: Write a test for adding an item.
it('should calculate total for a single item', () => {
const cart = new ShoppingCart();
cart.addItem({ name: 'Shirt', price: 25.99 });
expect(cart.getTotal()).toBe(25.99);
});Green: Update the class with the simplest implementation.
export class ShoppingCart {
private items: Array<{ name: string; price: number }> = [];
addItem(item: { name: string; price: number }): void {
this.items.push(item);
}
getTotal(): number {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
}Refactor: The reduce logic is clean. We might extract a Price type for better semantics. The cycle continues.
Practical Patterns for TypeScript TDD
Effective TDD requires patterns that scale. Here are key patterns for TypeScript.
1. Use Descriptive Test Names
Your test name is documentation. It should read like a specification.
// Bad: 'calculates total'
// Good: 'should apply 10% discount when cart total exceeds $100'2. Leverage TypeScript's Type System
Use types to design interfaces first. This forces you to think about contracts.
// Define the interface in your test file first
interface PriceCalculator {
calculate(items: CartItem[]): Money;
}3. Test Behavior, Not Implementation
Test what the code does, not how it does it. This makes refactoring safe.
// Focus on the output for given inputs
expect(cart.getTotal()).toBe(expectedTotal);4. Use beforeEach for Setup
Avoid duplication by setting up common test state.
let cart: ShoppingCart;
beforeEach(() => {
cart = new ShoppingCart();
cart.addItem({ name: 'Shoes', price: 89.99 });
});These patterns keep your test suite clean, fast, and valuable.
Integrating TDD with Existing Codebases
Adopting TDD in a legacy project can feel daunting. Start small and pragmatic.
The Strangler Fig Pattern:
- Identify a Seam: Find a well-bounded component or a new feature.
- Write a Characterization Test: Test the current behavior to understand it.
- Add New Behavior with TDD: Implement changes using the Red-Green-Refactor cycle.
- Repeat: Gradually replace old code with new, well-tested modules.
Tooling for Integration:
- Use
jest --watchorvitest --watchfor rapid feedback. - Add tests to your CI pipeline early. A failing test blocks a merge.
- Use code coverage reports (e.g.,
vitest run --coverage) to identify untested critical paths.
This approach minimizes risk and builds a safety net incrementally.
Advanced TDD: Mocking and Dependency Injection
Real-world code interacts with databases, APIs, and services. TDD handles this through mocking.
Dependency Injection (DI): Design your classes to accept dependencies via their constructor.
class OrderService {
constructor(private paymentGateway: PaymentGateway) {}
// ...
}In Your Test:
// Create a mock implementation
const mockGateway: PaymentGateway = {
processPayment: vi.fn().mockResolvedValue({ success: true }),
};
// Inject it
const service = new OrderService(mockGateway);Tools for Mocking:
- Vitest/Jest: Built-in
vi.fn()orjest.fn(). - msw (Mock Service Worker): For mocking HTTP requests.
- ts-mockito: For creating mocks from TypeScript types.
This isolates the unit you are testing and makes tests deterministic and fast.
Common Pitfalls and How to Avoid Them
Even with a solid process, pitfalls exist. Avoid these common mistakes.
- Testing Implementation Details: Don't test private methods or internal state. Test the public API.
- Slow Feedback Loop: If your test suite takes minutes, you won't run it often. Keep tests fast and focused.
- Over-Mocking: Mock only external dependencies. Don't mock everything; it makes tests brittle.
- Ignoring Flaky Tests: A test that sometimes passes and sometimes fails erodes trust. Fix or delete it immediately.
- Skipping Refactoring: The "Refactor" step is not optional. It's where you improve the design.
Pro Tip: If a test is hard to write, it's often a sign that your code's design needs improvement. Let the test drive you toward a better structure.
The Benefits Beyond Bug Prevention
The value of TDD extends far beyond catching bugs early.
1. Superior Design: Tests force you to use your own API, leading to cleaner, more modular code.
2. Living Documentation: Your test suite is a runnable specification of how the system should behave.
3. Fearless Refactoring: With a comprehensive test suite, you can change code confidently, knowing you'll catch regressions.
4. Faster Debugging: When a test fails, you know exactly what broke and often why. No more hours of debugging.
5. Better Collaboration: Tests communicate intent. New team members can understand behavior by reading tests.
TDD is an investment that pays dividends throughout the lifecycle of your project.



