Test-Driven Development (TDD) ist eine Entwicklungsmethodik, bei der Tests vor dem eigentlichen Produktionscode geschrieben werden. Diese Herangehensweise verändert fundamental die Art, wie Software entwickelt wird, und führt zu besserer Code-Qualität, klarerer Architektur und höherer Entwicklerproduktivität.
TDD dreht den traditionellen Entwicklungsprozess um: Anstatt zuerst Code zu schreiben und dann Tests hinzuzufügen, beginnt TDD mit einem fehlschlagenden Test, der das gewünschte Verhalten beschreibt. Erst dann wird der minimale Code geschrieben, um diesen Test zum Bestehen zu bringen.
TDD basiert auf der Idee, dass Tests als Spezifikation und Design-Werkzeug dienen. Jeder Test definiert ein kleines Stück gewünschter Funktionalität und zwingt den Entwickler dazu, über die API und das Verhalten des Codes nachzudenken, bevor dieser implementiert wird.
// TDD beginnt mit der Frage: "Wie soll mein Code verwendet werden?"
// Beispiel: Eine Funktion zur Berechnung von Rabatten
// Zuerst der Test - definiert die gewünschte API
test('should calculate 10% discount correctly', () => {
const calculator = new DiscountCalculator();
const result = calculator.calculate(100, 0.1);
expect(result).toBe(90);
});
// Dann die minimale Implementierung
class DiscountCalculator {
calculate(price, discountRate) {
return price - (price * discountRate);
}
}Das Herzstück von TDD ist der Red-Green-Refactor Zyklus, ein kurzer, iterativer Prozess, der kontinuierlich wiederholt wird.
In der Red-Phase wird ein Test geschrieben, der das nächste gewünschte Feature oder Verhalten beschreibt. Dieser Test muss fehlschlagen, da die Funktionalität noch nicht implementiert ist.
// Red Phase: Test für noch nicht existierende Funktionalität
describe('Shopping Cart', () => {
test('should start with zero items', () => {
const cart = new ShoppingCart();
expect(cart.getItemCount()).toBe(0);
});
test('should add items correctly', () => {
const cart = new ShoppingCart();
cart.addItem('laptop', 1299.99);
expect(cart.getItemCount()).toBe(1);
expect(cart.getTotal()).toBe(1299.99);
});
});
// Zu diesem Zeitpunkt existiert ShoppingCart noch nicht!
// Tests schlagen fehl mit "ShoppingCart is not defined"In der Green-Phase wird der einfachste Code geschrieben, der alle Tests zum Bestehen bringt. Das Ziel ist nicht perfekter Code, sondern funktionsfähiger Code.
// Green Phase: Minimale Implementierung
class ShoppingCart {
constructor() {
this.items = [];
}
getItemCount() {
return this.items.length;
}
addItem(name, price) {
this.items.push({ name, price });
}
getTotal() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
}
// Tests bestehen jetzt - Green Phase erreicht!In der Refactor-Phase wird der Code verbessert, ohne das Verhalten zu ändern. Die Tests fungieren als Sicherheitsnetz, das sicherstellt, dass die Funktionalität erhalten bleibt.
// Refactor Phase: Code-Qualität verbessern
class ShoppingCart {
constructor() {
this.items = [];
}
getItemCount() {
return this.items.length;
}
addItem(name, price) {
this.validateItem(name, price);
this.items.push(this.createItem(name, price));
}
getTotal() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
// Refactoring: Extrahierte Hilfsmethoden
validateItem(name, price) {
if (!name || typeof name !== 'string') {
throw new Error('Item name must be a non-empty string');
}
if (typeof price !== 'number' || price < 0) {
throw new Error('Price must be a positive number');
}
}
createItem(name, price) {
return {
id: this.generateId(),
name: name.trim(),
price: Math.round(price * 100) / 100 // Auf 2 Dezimalstellen runden
};
}
generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
}TDD fördert inkrementelle Entwicklung durch kleine, überprüfbare Schritte.
// Beispiel: Entwicklung einer Validierungsfunktion für E-Mail-Adressen
// Schritt 1: Einfachster Fall
test('should accept valid email address', () => {
expect(isValidEmail('user@example.com')).toBe(true);
});
function isValidEmail(email) {
return email.includes('@'); // Minimale Implementierung
}
// Schritt 2: Erweiterte Validation
test('should reject email without domain', () => {
expect(isValidEmail('user@')).toBe(false);
});
function isValidEmail(email) {
const parts = email.split('@');
return parts.length === 2 && parts[1].length > 0;
}
// Schritt 3: Weitere Validierung
test('should reject email without local part', () => {
expect(isValidEmail('@example.com')).toBe(false);
});
function isValidEmail(email) {
const parts = email.split('@');
return parts.length === 2 && parts[0].length > 0 && parts[1].length > 0;
}
// Schritt 4: Domain-Validierung
test('should require valid domain format', () => {
expect(isValidEmail('user@example')).toBe(false);
expect(isValidEmail('user@example.com')).toBe(true);
});
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}// Entwicklung einer User Management Klasse
describe('User Manager', () => {
let userManager;
beforeEach(() => {
userManager = new UserManager();
});
// Test 1: User erstellen
test('should create new user', () => {
const userData = {
name: 'John Doe',
email: 'john@example.com'
};
const user = userManager.createUser(userData);
expect(user.id).toBeDefined();
expect(user.name).toBe(userData.name);
expect(user.email).toBe(userData.email);
expect(user.createdAt).toBeInstanceOf(Date);
});
// Test 2: User finden
test('should find user by id', () => {
const user = userManager.createUser({
name: 'Jane Smith',
email: 'jane@example.com'
});
const foundUser = userManager.findById(user.id);
expect(foundUser).toEqual(user);
});
// Test 3: Duplikate verhindern
test('should prevent duplicate email addresses', () => {
userManager.createUser({
name: 'User One',
email: 'duplicate@example.com'
});
expect(() => {
userManager.createUser({
name: 'User Two',
email: 'duplicate@example.com'
});
}).toThrow('Email address already exists');
});
});// Implementierung nach TDD-Zyklus
class UserManager {
constructor() {
this.users = new Map();
this.emailIndex = new Set();
}
createUser(userData) {
this.validateUserData(userData);
if (this.emailIndex.has(userData.email)) {
throw new Error('Email address already exists');
}
const user = {
id: this.generateId(),
name: userData.name,
email: userData.email,
createdAt: new Date()
};
this.users.set(user.id, user);
this.emailIndex.add(user.email);
return user;
}
findById(id) {
return this.users.get(id);
}
validateUserData(userData) {
if (!userData.name || !userData.email) {
throw new Error('Name and email are required');
}
}
generateId() {
return Math.random().toString(36).substr(2, 9);
}
}Detroit School (klassisches TDD): Fokus auf State-basierte Tests mit minimaler Verwendung von Mocks.
// Detroit School: State-basierte Tests
describe('Order Processing (Detroit Style)', () => {
test('should process order and update inventory', () => {
const inventory = new Inventory();
inventory.addProduct('laptop', 5);
const order = new Order(inventory);
order.addItem('laptop', 2);
const result = order.process();
expect(result.success).toBe(true);
expect(inventory.getStock('laptop')).toBe(3); // State verification
});
});London School (mockistische TDD): Fokus auf Interaction-basierte Tests mit extensiver Mock-Verwendung.
// London School: Interaction-basierte Tests
describe('Order Processing (London Style)', () => {
test('should process order and call inventory service', () => {
const mockInventory = {
checkStock: jest.fn().mockReturnValue(true),
reserveItems: jest.fn()
};
const order = new Order(mockInventory);
order.addItem('laptop', 2);
order.process();
expect(mockInventory.checkStock).toHaveBeenCalledWith('laptop', 2);
expect(mockInventory.reserveItems).toHaveBeenCalledWith([
{ product: 'laptop', quantity: 2 }
]);
});
});Outside-In TDD: Beginnt mit Akzeptanztests und arbeitet sich von außen nach innen vor.
// Outside-In: Startet mit High-Level Feature
describe('User Registration Feature', () => {
test('should register new user successfully', () => {
const registrationService = new UserRegistrationService();
const result = registrationService.register({
email: 'newuser@example.com',
password: 'securepassword'
});
expect(result.success).toBe(true);
expect(result.user.email).toBe('newuser@example.com');
expect(result.confirmationEmailSent).toBe(true);
});
});
// Dann werden die benötigten Komponenten entwickelt:
// - UserValidator
// - PasswordHasher
// - EmailService
// - UserRepositoryInside-Out TDD: Beginnt mit Low-Level Komponenten und baut darauf auf.
// Inside-Out: Startet mit grundlegenden Bausteinen
describe('Password Hasher', () => {
test('should hash password securely', () => {
const hasher = new PasswordHasher();
const hash = hasher.hash('plaintext');
expect(hash).not.toBe('plaintext');
expect(hasher.verify('plaintext', hash)).toBe(true);
});
});
// Dann Integration zu höheren Ebenen
describe('User Service', () => {
test('should use password hasher when creating user', () => {
const hasher = new PasswordHashher();
const userService = new UserService(hasher);
const user = userService.createUser('test@example.com', 'password');
expect(user.passwordHash).not.toBe('password');
expect(hasher.verify('password', user.passwordHash)).toBe(true);
});
});// TDD mit asynchronen Operationen
describe('Async User Service', () => {
test('should fetch user data from API', async () => {
const apiClient = {
get: jest.fn().mockResolvedValue({
data: { id: 1, name: 'John Doe' }
})
};
const userService = new AsyncUserService(apiClient);
const user = await userService.getUser(1);
expect(user.name).toBe('John Doe');
expect(apiClient.get).toHaveBeenCalledWith('/users/1');
});
test('should handle API errors gracefully', async () => {
const apiClient = {
get: jest.fn().mockRejectedValue(new Error('Network error'))
};
const userService = new AsyncUserService(apiClient);
await expect(userService.getUser(1))
.rejects
.toThrow('Failed to fetch user data');
});
});// Implementierung mit Error Handling
class AsyncUserService {
constructor(apiClient) {
this.apiClient = apiClient;
}
async getUser(id) {
try {
const response = await this.apiClient.get(`/users/${id}`);
return response.data;
} catch (error) {
throw new Error('Failed to fetch user data');
}
}
}// TDD mit Events
describe('Event Emitter Service', () => {
test('should emit events when user created', () => {
const eventEmitter = new EventEmitter();
const userService = new UserService(eventEmitter);
const eventHandler = jest.fn();
eventEmitter.on('user.created', eventHandler);
userService.createUser({ name: 'John', email: 'john@example.com' });
expect(eventHandler).toHaveBeenCalledWith({
type: 'user.created',
user: expect.objectContaining({
name: 'John',
email: 'john@example.com'
})
});
});
});Fast: Tests sollten schnell ausführbar sein. Independent: Tests sollten unabhängig voneinander sein. Repeatable: Tests sollten in jeder Umgebung wiederholbar sein. Self-Validating: Tests sollten ein eindeutiges Pass/Fail-Ergebnis haben. Timely: Tests sollten rechtzeitig geschrieben werden.
// Good: FIRST-konforme Tests
describe('Calculator', () => {
test('should add two numbers', () => {
const calc = new Calculator();
expect(calc.add(2, 3)).toBe(5);
});
test('should subtract two numbers', () => {
const calc = new Calculator();
expect(calc.subtract(5, 3)).toBe(2);
});
});
// Bad: Abhängige Tests
describe('Calculator (Bad Example)', () => {
let calc;
let result;
test('should add two numbers', () => {
calc = new Calculator();
result = calc.add(2, 3);
expect(result).toBe(5);
});
test('should use previous result', () => {
// Abhängig vom vorherigen Test!
expect(calc.add(result, 1)).toBe(6);
});
});// Good: Beschreibende Testnamen
describe('User Authentication', () => {
test('should return user data when credentials are valid', () => {
// Test implementation
});
test('should throw error when password is incorrect', () => {
// Test implementation
});
test('should lock account after 3 failed attempts', () => {
// Test implementation
});
});
// Bad: Vage Testnamen
describe('User Authentication', () => {
test('test login', () => {
// Unclear what exactly is being tested
});
test('password test', () => {
// Too vague
});
});// Good: Ein Konzept pro Test
describe('Discount Calculator', () => {
test('should apply 10% discount to regular customers', () => {
const calculator = new DiscountCalculator();
const result = calculator.calculate(100, 'regular', 0.1);
expect(result).toBe(90);
});
test('should apply 15% discount to premium customers', () => {
const calculator = new DiscountCalculator();
const result = calculator.calculate(100, 'premium', 0.15);
expect(result).toBe(85);
});
});
// Bad: Mehrere Konzepte in einem Test
describe('Discount Calculator', () => {
test('should calculate discounts', () => {
const calculator = new DiscountCalculator();
// Zu viele Assertions für verschiedene Szenarien
expect(calculator.calculate(100, 'regular', 0.1)).toBe(90);
expect(calculator.calculate(100, 'premium', 0.15)).toBe(85);
expect(calculator.calculate(100, 'vip', 0.2)).toBe(80);
});
});// Antipattern: Test behauptet etwas Falsches
test('should validate email format', () => {
const validator = new EmailValidator();
expect(validator.isValid('not-an-email')).toBe(true); // Lüge!
});// Antipattern: Übermäßiges Setup
describe('Order Processing', () => {
test('should calculate shipping cost', () => {
// Viel zu komplexes Setup für einen einfachen Test
const database = new TestDatabase();
const userRepository = new UserRepository(database);
const productRepository = new ProductRepository(database);
const shippingService = new ShippingService();
const taxService = new TaxService();
const discountService = new DiscountService();
const order = new Order(userRepository, productRepository);
order.setShippingService(shippingService);
order.setTaxService(taxService);
order.setDiscountService(discountService);
// Eigentlicher Test ist winzig
expect(order.calculateShipping()).toBe(5.99);
});
});
// Better: Fokussierter Test
test('should calculate shipping cost', () => {
const order = new Order();
order.addItem({ weight: 1.5, category: 'books' });
expect(order.calculateShipping()).toBe(5.99);
});// Antipattern: Test für Implementation
test('should call save method on repository', () => {
const mockRepo = { save: jest.fn() };
const userService = new UserService(mockRepo);
userService.createUser({ name: 'John' });
expect(mockRepo.save).toHaveBeenCalled(); // Tests Implementation
});
// Better: Test für Verhalten
test('should persist new user', () => {
const repository = new InMemoryUserRepository();
const userService = new UserService(repository);
const user = userService.createUser({ name: 'John' });
const savedUser = repository.findById(user.id);
expect(savedUser.name).toBe('John'); // Tests Behavior
});// TDD für React-Komponenten
describe('UserProfile Component', () => {
test('should display user name and email', () => {
const user = { name: 'John Doe', email: 'john@example.com' };
render(<UserProfile user={user} />);
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('john@example.com')).toBeInTheDocument();
});
test('should show edit button for current user', () => {
const user = { id: 1, name: 'John Doe' };
const currentUserId = 1;
render(<UserProfile user={user} currentUserId={currentUserId} />);
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
});
});// TDD für Express-Routes
describe('User API', () => {
test('POST /users should create new user', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com'
};
const response = await request(app)
.post('/users')
.send(userData)
.expect(201);
expect(response.body.name).toBe(userData.name);
expect(response.body.id).toBeDefined();
});
test('POST /users should validate required fields', async () => {
await request(app)
.post('/users')
.send({}) // Leere Daten
.expect(400)
.expect(res => {
expect(res.body.error).toContain('Name is required');
});
});
});Design-Verbesserung: TDD zwingt dazu, über die API und Schnittstellen nachzudenken, bevor Code geschrieben wird.
Lebende Dokumentation: Tests dokumentieren das erwartete Verhalten des Codes.
Refactoring-Sicherheit: Eine umfassende Test-Suite ermöglicht sichere Code-Änderungen.
Defekt-Reduzierung: Bugs werden früh im Entwicklungsprozess gefunden.
Lernkurve: TDD erfordert eine Änderung der Denkweise und benötigt Übung.
Zeitaufwand: Initial erscheint TDD langsamer, zahlt sich aber langfristig aus.
Legacy Code: TDD ist schwieriger in bestehenden Systemen ohne Tests einzuführen.
// Herausforderung: Legacy Code testbar machen
class LegacyOrderProcessor {
process(order) {
// Direkte Datenbankzugriffe, externe API-Calls, etc.
const db = new Database();
const emailService = new EmailService();
const paymentGateway = new PaymentGateway();
// Schwer zu testen durch fest verdrahtete Abhängigkeiten
}
}
// Refactoring für Testbarkeit
class OrderProcessor {
constructor(database, emailService, paymentGateway) {
this.database = database;
this.emailService = emailService;
this.paymentGateway = paymentGateway;
}
process(order) {
// Jetzt testbar durch Dependency Injection
}
}Die in den Kapiteln “Unit Testing mit Jest und Mocha” behandelten Frameworks lassen sich alle für TDD verwenden. Jest bietet dabei besonders entwicklerfreundliche Features wie Watch-Mode für kontinuierliches Testing.
// Jest Watch-Mode für TDD-Workflow
// package.json
{
"scripts": {
"test:watch": "jest --watch",
"tdd": "jest --watch --verbose"
}
}// Automatische Test-Ausführung bei Dateiänderungen
// jest.config.js
module.exports = {
watchman: true,
watchPathIgnorePatterns: ['node_modules'],
testMatch: ['**/__tests__/**/*.test.js'],
collectCoverageFrom: [
'src/**/*.js',
'!src/**/*.test.js'
]
};