Prerequisites
- Basic understanding of JavaScript ๐
- TypeScript installation โก
- VS Code or preferred IDE ๐ป
What you'll learn
- Understand TDD fundamentals and the Red-Green-Refactor cycle ๐ฏ
- Apply TDD methodology in TypeScript projects ๐๏ธ
- Debug common TDD issues and maintain test quality ๐
- Write type-safe tests and testable code โจ
๐ฏ Introduction
Welcome to the exciting world of Test-Driven Development with TypeScript! ๐ In this guide, weโll explore how TDD transforms your development process from a chaotic scramble into a confident, systematic approach.
Youโll discover how TDD can revolutionize your TypeScript development experience. Whether youโre building web applications ๐, server-side APIs ๐ฅ๏ธ, or libraries ๐, understanding TDD is essential for writing robust, maintainable code with confidence.
By the end of this tutorial, youโll feel confident implementing TDD in your own TypeScript projects! Letโs dive in! ๐โโ๏ธ
๐ Understanding Test-Driven Development
๐ค What is Test-Driven Development?
TDD is like building a house with a blueprint first ๐๏ธ. Think of it as writing the โwhat should happenโ before writing the โhow it happensโ - you define your expectations, then make them come true!
In TypeScript terms, TDD means writing tests that fail, then writing just enough code to make them pass, then improving the code while keeping tests green ๐ข. This means you can:
- โจ Catch bugs before they happen
- ๐ Refactor fearlessly with confidence
- ๐ก๏ธ Build exactly whatโs needed, nothing more
๐ก Why Use TDD?
Hereโs why developers love TDD:
- Confidence ๐: Your tests act as a safety net
- Better Design ๐ป: Forces you to think about interfaces first
- Living Documentation ๐: Tests show how code should behave
- Regression Prevention ๐ง: Changes wonโt break existing functionality
Real-world example: Imagine building a shopping cart ๐. With TDD, youโd first write tests for โadding items should increase the totalโ, then implement the functionality to make those tests pass!
๐ง Basic TDD Cycle: Red-Green-Refactor
๐ The Sacred Cycle
TDD follows a simple but powerful cycle:
// ๐ด RED: Write a failing test
describe("Calculator", () => {
it("should add two numbers correctly", () => {
const calc = new Calculator();
expect(calc.add(2, 3)).toBe(5); // ๐ฅ This will fail - Calculator doesn't exist!
});
});
// ๐ข GREEN: Write minimal code to pass
class Calculator {
add(a: number, b: number): number {
return a + b; // โ
Just enough to make it pass!
}
}
// ๐ต REFACTOR: Improve the code while keeping tests green
class Calculator {
// ๐จ Add type safety and validation
add(a: number, b: number): number {
if (typeof a !== 'number' || typeof b !== 'number') {
throw new Error('๐ซ Only numbers allowed!');
}
return a + b;
}
}
๐ก Explanation: Notice how we start with the test, then build the minimum code to pass it, then improve! This ensures we only write what we need.
๐ฏ Setting Up Your TypeScript Testing Environment
Letโs set up a modern testing environment:
# ๐ฆ Install Jest and TypeScript support
npm install --save-dev jest @types/jest ts-jest typescript
# ๐ง Initialize Jest configuration
npx ts-jest config:init
// ๐ jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts', '**/*.test.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
],
};
๐ก Practical Examples
๐ Example 1: TDD Shopping Cart
Letโs build a shopping cart using TDD:
// ๐ด RED: Start with the test
describe("ShoppingCart", () => {
it("should start empty", () => {
const cart = new ShoppingCart();
expect(cart.getItemCount()).toBe(0);
expect(cart.getTotal()).toBe(0);
});
it("should add items correctly", () => {
const cart = new ShoppingCart();
const item = { id: "1", name: "TypeScript Book ๐", price: 29.99 };
cart.addItem(item);
expect(cart.getItemCount()).toBe(1);
expect(cart.getTotal()).toBe(29.99);
});
});
// ๐ข GREEN: Implement to make tests pass
interface CartItem {
id: string;
name: string;
price: number;
}
class ShoppingCart {
private items: CartItem[] = [];
addItem(item: CartItem): void {
this.items.push(item);
}
getItemCount(): number {
return this.items.length;
}
getTotal(): number {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
}
// ๐ต REFACTOR: Add more functionality through TDD
describe("ShoppingCart advanced features", () => {
it("should remove items correctly", () => {
const cart = new ShoppingCart();
const item = { id: "1", name: "Coffee โ", price: 4.99 };
cart.addItem(item);
cart.removeItem(item.id);
expect(cart.getItemCount()).toBe(0);
expect(cart.getTotal()).toBe(0);
});
it("should handle quantities", () => {
const cart = new ShoppingCart();
const item = { id: "1", name: "Cookies ๐ช", price: 2.99 };
cart.addItem(item, 3); // ๐ฏ Add quantity parameter
expect(cart.getItemCount()).toBe(3);
expect(cart.getTotal()).toBe(8.97);
});
});
๐ฏ Try it yourself: Add a discount system that applies percentage discounts!
๐ฎ Example 2: TDD Game Score System
Letโs create a game scoring system:
// ๐ด RED: Define what we want first
describe("GameScore", () => {
it("should track player scores", () => {
const game = new GameScore();
game.addPlayer("Alice ๐ฉ");
game.addPoints("Alice ๐ฉ", 100);
expect(game.getScore("Alice ๐ฉ")).toBe(100);
});
it("should determine the winner", () => {
const game = new GameScore();
game.addPlayer("Alice ๐ฉ");
game.addPlayer("Bob ๐จ");
game.addPoints("Alice ๐ฉ", 100);
game.addPoints("Bob ๐จ", 150);
expect(game.getWinner()).toBe("Bob ๐จ");
});
it("should handle achievements", () => {
const game = new GameScore();
game.addPlayer("Charlie ๐ง");
game.addPoints("Charlie ๐ง", 1000); // ๐ Big achievement!
expect(game.getAchievements("Charlie ๐ง")).toContain("๐ High Scorer");
});
});
// ๐ข GREEN: Implement the functionality
interface Player {
name: string;
score: number;
achievements: string[];
}
class GameScore {
private players: Map<string, Player> = new Map();
addPlayer(name: string): void {
this.players.set(name, {
name,
score: 0,
achievements: ["๐ New Player"]
});
}
addPoints(name: string, points: number): void {
const player = this.players.get(name);
if (!player) throw new Error(`๐ซ Player ${name} not found!`);
player.score += points;
// ๐ Check for achievements
if (player.score >= 1000 && !player.achievements.includes("๐ High Scorer")) {
player.achievements.push("๐ High Scorer");
}
}
getScore(name: string): number {
const player = this.players.get(name);
return player ? player.score : 0;
}
getWinner(): string | null {
let winner: Player | null = null;
for (const player of this.players.values()) {
if (!winner || player.score > winner.score) {
winner = player;
}
}
return winner ? winner.name : null;
}
getAchievements(name: string): string[] {
const player = this.players.get(name);
return player ? player.achievements : [];
}
}
๐ Advanced TDD Concepts
๐งโโ๏ธ Mocking and Test Doubles
When testing code that depends on external services, use mocks:
// ๐ฏ Interface for testing
interface UserRepository {
save(user: User): Promise<void>;
findById(id: string): Promise<User | null>;
}
// ๐ฎ Service to test
class UserService {
constructor(private repo: UserRepository) {}
async createUser(userData: { name: string; email: string }): Promise<User> {
const user = new User(userData.name, userData.email);
await this.repo.save(user);
return user;
}
}
// ๐งช Test with mock
describe("UserService", () => {
it("should create and save user", async () => {
// ๐ญ Create a mock repository
const mockRepo: UserRepository = {
save: jest.fn(),
findById: jest.fn()
};
const service = new UserService(mockRepo);
const userData = { name: "Alice ๐ฉ", email: "[email protected]" };
const user = await service.createUser(userData);
expect(user.name).toBe("Alice ๐ฉ");
expect(mockRepo.save).toHaveBeenCalledWith(user);
});
});
๐๏ธ Testing Async Operations
Handle promises and async code properly:
// ๐ Async function to test
class ApiClient {
async fetchUserData(id: string): Promise<UserData> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`๐ซ User ${id} not found`);
}
return response.json();
}
}
// ๐งช Test async operations
describe("ApiClient", () => {
it("should fetch user data successfully", async () => {
// ๐ญ Mock fetch
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ name: "Alice ๐ฉ", id: "123" })
});
const client = new ApiClient();
const userData = await client.fetchUserData("123");
expect(userData.name).toBe("Alice ๐ฉ");
expect(fetch).toHaveBeenCalledWith("/api/users/123");
});
it("should handle errors properly", async () => {
// ๐ญ Mock failed response
global.fetch = jest.fn().mockResolvedValue({
ok: false
});
const client = new ApiClient();
await expect(client.fetchUserData("999"))
.rejects
.toThrow("๐ซ User 999 not found");
});
});
โ ๏ธ Common Pitfalls and Solutions
๐ฑ Pitfall 1: Testing Implementation Instead of Behavior
// โ Wrong way - testing implementation details!
it("should call the internal method", () => {
const calculator = new Calculator();
const spy = jest.spyOn(calculator, 'internalCalculation');
calculator.add(2, 3);
expect(spy).toHaveBeenCalled(); // ๐ฅ Brittle test!
});
// โ
Correct way - test behavior!
it("should return correct sum", () => {
const calculator = new Calculator();
const result = calculator.add(2, 3);
expect(result).toBe(5); // โ
Tests what matters!
});
๐คฏ Pitfall 2: Overly Complex Tests
// โ Complex test that's hard to understand
it("should handle complex scenario", () => {
const cart = new ShoppingCart();
const items = [
{ id: "1", name: "Item 1", price: 10 },
{ id: "2", name: "Item 2", price: 20 },
{ id: "3", name: "Item 3", price: 30 }
];
items.forEach(item => cart.addItem(item));
cart.removeItem("2");
cart.applyDiscount(0.1);
expect(cart.getTotal()).toBe(36); // ๐ค How did we get 36?
});
// โ
Simple, focused tests
describe("ShoppingCart discounts", () => {
it("should apply 10% discount correctly", () => {
const cart = new ShoppingCart();
const item = { id: "1", name: "Book ๐", price: 100 };
cart.addItem(item);
cart.applyDiscount(0.1);
expect(cart.getTotal()).toBe(90); // โ
Clear expectation!
});
});
๐ ๏ธ Best Practices
- ๐ฏ One Assertion Per Test: Keep tests focused and clear
- ๐ Descriptive Test Names: โshould calculate total with discount appliedโ
- ๐ก๏ธ Arrange-Act-Assert: Structure your tests consistently
- ๐จ Use TypeScript Types: Make your tests type-safe too
- โจ Keep Tests Simple: If a test is complex, break it down
๐งช Hands-On Exercise
๐ฏ Challenge: Build a TDD Task Manager
Create a task management system using TDD:
๐ Requirements:
- โ Create, complete, and delete tasks
- ๐ท๏ธ Add categories and priorities
- ๐ Set due dates with validation
- ๐ Generate completion statistics
- ๐จ Each task needs an emoji!
๐ Bonus Points:
- Add task assignment to users
- Implement filtering and sorting
- Create notification system for overdue tasks
๐ก Solution
๐ Click to see solution
// ๐ด RED: Start with comprehensive tests
describe("TaskManager", () => {
describe("Task Creation", () => {
it("should create a task with required fields", () => {
const manager = new TaskManager();
const taskData = {
title: "Learn TDD ๐งช",
category: "learning" as const,
priority: "high" as const
};
const task = manager.createTask(taskData);
expect(task.title).toBe("Learn TDD ๐งช");
expect(task.category).toBe("learning");
expect(task.priority).toBe("high");
expect(task.completed).toBe(false);
expect(task.id).toBeDefined();
});
it("should validate due dates", () => {
const manager = new TaskManager();
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
expect(() => {
manager.createTask({
title: "Overdue task ๐
",
category: "work",
priority: "low",
dueDate: yesterday
});
}).toThrow("๐ซ Due date cannot be in the past");
});
});
describe("Task Management", () => {
it("should complete tasks", () => {
const manager = new TaskManager();
const task = manager.createTask({
title: "Complete this task โ
",
category: "work",
priority: "medium"
});
manager.completeTask(task.id);
expect(manager.getTask(task.id)?.completed).toBe(true);
});
it("should filter tasks by category", () => {
const manager = new TaskManager();
manager.createTask({ title: "Work task ๐ผ", category: "work", priority: "high" });
manager.createTask({ title: "Personal task ๐ ", category: "personal", priority: "low" });
const workTasks = manager.getTasksByCategory("work");
expect(workTasks).toHaveLength(1);
expect(workTasks[0].title).toBe("Work task ๐ผ");
});
});
describe("Statistics", () => {
it("should calculate completion percentage", () => {
const manager = new TaskManager();
const task1 = manager.createTask({ title: "Task 1 ๐", category: "work", priority: "high" });
const task2 = manager.createTask({ title: "Task 2 ๐", category: "work", priority: "high" });
manager.completeTask(task1.id);
expect(manager.getCompletionPercentage()).toBe(50);
});
it("should generate category statistics", () => {
const manager = new TaskManager();
manager.createTask({ title: "Work 1 ๐ผ", category: "work", priority: "high" });
manager.createTask({ title: "Work 2 ๐ผ", category: "work", priority: "low" });
manager.createTask({ title: "Personal ๐ ", category: "personal", priority: "medium" });
const stats = manager.getCategoryStats();
expect(stats.work).toBe(2);
expect(stats.personal).toBe(1);
});
});
});
// ๐ข GREEN: Implement the functionality
type Priority = "low" | "medium" | "high";
type Category = "work" | "personal" | "learning" | "health";
interface Task {
id: string;
title: string;
category: Category;
priority: Priority;
completed: boolean;
createdAt: Date;
dueDate?: Date;
completedAt?: Date;
}
interface CreateTaskData {
title: string;
category: Category;
priority: Priority;
dueDate?: Date;
}
class TaskManager {
private tasks: Map<string, Task> = new Map();
private nextId = 1;
createTask(data: CreateTaskData): Task {
// ๐ก๏ธ Validate due date
if (data.dueDate && data.dueDate < new Date()) {
throw new Error("๐ซ Due date cannot be in the past");
}
const task: Task = {
id: this.nextId.toString(),
title: data.title,
category: data.category,
priority: data.priority,
completed: false,
createdAt: new Date(),
dueDate: data.dueDate
};
this.tasks.set(task.id, task);
this.nextId++;
return task;
}
getTask(id: string): Task | undefined {
return this.tasks.get(id);
}
completeTask(id: string): void {
const task = this.tasks.get(id);
if (!task) throw new Error(`๐ซ Task ${id} not found`);
task.completed = true;
task.completedAt = new Date();
}
deleteTask(id: string): void {
if (!this.tasks.has(id)) {
throw new Error(`๐ซ Task ${id} not found`);
}
this.tasks.delete(id);
}
getTasksByCategory(category: Category): Task[] {
return Array.from(this.tasks.values())
.filter(task => task.category === category);
}
getCompletionPercentage(): number {
const totalTasks = this.tasks.size;
if (totalTasks === 0) return 100;
const completedTasks = Array.from(this.tasks.values())
.filter(task => task.completed).length;
return Math.round((completedTasks / totalTasks) * 100);
}
getCategoryStats(): Record<Category, number> {
const stats: Record<Category, number> = {
work: 0,
personal: 0,
learning: 0,
health: 0
};
for (const task of this.tasks.values()) {
stats[task.category]++;
}
return stats;
}
// ๐ต REFACTOR: Add priority sorting
getTasksByPriority(): Task[] {
const priorityOrder = { high: 3, medium: 2, low: 1 };
return Array.from(this.tasks.values())
.sort((a, b) => priorityOrder[b.priority] - priorityOrder[a.priority]);
}
// ๐ฏ Get overdue tasks
getOverdueTasks(): Task[] {
const now = new Date();
return Array.from(this.tasks.values())
.filter(task =>
!task.completed &&
task.dueDate &&
task.dueDate < now
);
}
}
// ๐ฎ Usage example
const taskManager = new TaskManager();
// ๐ Create some tasks
const learningTask = taskManager.createTask({
title: "Master TDD ๐งช",
category: "learning",
priority: "high",
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 1 week from now
});
const workTask = taskManager.createTask({
title: "Deploy new feature ๐",
category: "work",
priority: "high"
});
// โ
Complete a task
taskManager.completeTask(learningTask.id);
// ๐ Check progress
console.log(`๐ Completion: ${taskManager.getCompletionPercentage()}%`);
console.log("๐ High priority tasks:", taskManager.getTasksByPriority().slice(0, 3));
๐ Key Takeaways
Youโve learned so much about TDD! Hereโs what you can now do:
- โ Apply the Red-Green-Refactor cycle with confidence ๐ช
- โ Write focused, maintainable tests that guide your development ๐ก๏ธ
- โ Mock dependencies and test async code like a pro ๐ฏ
- โ Avoid common TDD pitfalls and write better tests ๐
- โ Build type-safe, well-tested TypeScript applications ๐
Remember: TDD isnโt just about testing - itโs about designing better software! It helps you think before you code. ๐ค
๐ค Next Steps
Congratulations! ๐ Youโve mastered Test-Driven Development with TypeScript!
Hereโs what to do next:
- ๐ป Practice TDD with the exercise above
- ๐๏ธ Start your next project using TDD from day one
- ๐ Explore advanced testing patterns like BDD and property-based testing
- ๐ Share your TDD journey with the community!
Remember: Every expert was once a beginner who refused to give up. Keep writing tests, keep refactoring, and most importantly, have fun building reliable software! ๐
Happy testing! ๐๐งชโจ