Einführung
„Wir brauchen mehr Tests” ist leicht gesagt, aber schwer gut umzusetzen. Ich habe Teams mit 90 % Codeabdeckung gesehen, die trotzdem Fehler ausliefern, und Teams mit 50 % Abdeckung, die selten Produktionsprobleme haben.
Der Unterschied liegt nicht in der Menge der Tests – sondern in der Strategie dahinter.
Die Testpyramide (neu betrachtet)
Die klassische Testpyramide schlägt vor:
- Viele Unit-Tests (schnell, isoliert)
- Einige Integrationstests (langsamer, testen Interaktionen)
- Wenige E2E-Tests (am langsamsten, testen vollständige Abläufe)
Das ist ein guter Ausgangspunkt, aber nicht die ganze Geschichte.
Das Problem mit reinen Unit-Tests
// 100 % Unit-Test-Abdeckung, aber funktioniert es?
class OrderService {
constructor(
private inventory: InventoryService,
private payment: PaymentService,
private shipping: ShippingService
) {}
async createOrder(order: Order): Promise<OrderResult> {
await this.inventory.reserve(order.items);
await this.payment.charge(order.total);
await this.shipping.schedule(order);
return { success: true };
}
}
// Unit-Test mit Mocks
test('createOrder calls all services', async () => {
const inventory = mock<InventoryService>();
const payment = mock<PaymentService>();
const shipping = mock<ShippingService>();
const service = new OrderService(inventory, payment, shipping);
await service.createOrder(testOrder);
expect(inventory.reserve).toHaveBeenCalled();
expect(payment.charge).toHaveBeenCalled();
expect(shipping.schedule).toHaveBeenCalled();
});
Dieser Test besteht, sagt uns aber nicht, ob das System tatsächlich funktioniert. Die Mocks entsprechen möglicherweise nicht dem realen Verhalten.
Meine Teststrategie
Ebene 1: Unit-Tests für reine Logik
Testen Sie reine Funktionen und Geschäftslogik ohne Abhängigkeiten:
// Reine Funktion - leicht zu testen
function calculateOrderTotal(items: OrderItem[], discount: Discount): number {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return applyDiscount(subtotal, discount);
}
test('calculateOrderTotal applies percentage discount', () => {
const items = [{ price: 100, quantity: 2 }];
const discount = { type: 'percentage', value: 10 };
expect(calculateOrderTotal(items, discount)).toBe(180);
});
test('calculateOrderTotal handles empty cart', () => {
expect(calculateOrderTotal([], null)).toBe(0);
});
Ebene 2: Integrationstests für Komponenten
Testen Sie Komponenten mit echten Abhängigkeiten (Datenbank, Cache), aber mocken Sie externe Services:
describe('OrderRepository', () => {
let db: TestDatabase;
let repository: OrderRepository;
beforeAll(async () => {
db = await TestDatabase.create();
repository = new OrderRepository(db);
});
afterAll(() => db.destroy());
beforeEach(() => db.clear());
test('creates and retrieves order', async () => {
const order = await repository.create({
userId: 'user_123',
items: [{ productId: 'prod_1', quantity: 2 }]
});
const retrieved = await repository.findById(order.id);
expect(retrieved).toMatchObject({
userId: 'user_123',
items: expect.arrayContaining([
expect.objectContaining({ productId: 'prod_1' })
])
});
});
test('finds orders by user', async () => {
await repository.create({ userId: 'user_123', items: [] });
await repository.create({ userId: 'user_123', items: [] });
await repository.create({ userId: 'user_456', items: [] });
const orders = await repository.findByUser('user_123');
expect(orders).toHaveLength(2);
});
});
Ebene 3: API-Tests für Services
Testen Sie Ihre API-Endpunkte mit einem echten Server, aber kontrollierten Abhängigkeiten:
describe('POST /api/orders', () => {
let app: TestApp;
beforeAll(async () => {
app = await TestApp.create({
// Echte Datenbank
database: testDb,
// Externe Services mocken
paymentProvider: mockPaymentProvider,
inventoryService: mockInventoryService
});
});
test('creates order successfully', async () => {
mockInventoryService.checkAvailability.mockResolvedValue(true);
mockPaymentProvider.charge.mockResolvedValue({ success: true });
const response = await app.post('/api/orders', {
items: [{ productId: 'prod_1', quantity: 1 }],
paymentMethod: 'card_123'
});
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
id: expect.any(String),
status: 'confirmed'
});
});
test('returns 400 for invalid input', async () => {
const response = await app.post('/api/orders', {
items: [] // Leerer Warenkorb
});
expect(response.status).toBe(400);
expect(response.body.error).toContain('items');
});
test('returns 402 when payment fails', async () => {
mockInventoryService.checkAvailability.mockResolvedValue(true);
mockPaymentProvider.charge.mockResolvedValue({
success: false,
error: 'Card declined'
});
const response = await app.post('/api/orders', {
items: [{ productId: 'prod_1', quantity: 1 }],
paymentMethod: 'card_123'
});
expect(response.status).toBe(402);
});
});
Ebene 4: E2E-Tests für kritische Pfade
Testen Sie vollständige Nutzer:innen-Abläufe für Ihre wichtigsten Prozesse:
describe('Checkout Flow', () => {
test('user can complete purchase', async () => {
// Vorbereitung
const user = await createTestUser();
const product = await createTestProduct({ price: 99.99 });
// In den Warenkorb legen
await api.post('/cart/items', { productId: product.id }, { as: user });
// Checkout
const order = await api.post('/checkout', {
paymentMethod: testCard,
shippingAddress: testAddress
}, { as: user });
// Überprüfung
expect(order.status).toBe('confirmed');
// Nebeneffekte prüfen
const inventory = await getProductInventory(product.id);
expect(inventory.reserved).toBe(1);
const email = await getLastEmailTo(user.email);
expect(email.subject).toContain('Order Confirmation');
});
});
Was getestet werden sollte
Immer testen
- Geschäftslogik: Berechnungen, Validierungen, Zustandsautomaten
- Randfälle: Leere Eingaben, Grenzwerte, Fehlerzustände
- Kritische Pfade: Checkout, Authentifizierung, Zahlung
- Fehlerbehebungen: Jede Fehlerbehebung sollte mit einem Test kommen
Manchmal testen
- Integrationspunkte: Datenbankabfragen, API-Aufrufe
- Komplexe UI-Interaktionen: Mehrstufige Formulare, Drag-and-Drop
Selten testen
- Einfaches CRUD: Wenn nur Daten durchgereicht werden, decken Integrationstests das ab
- Bibliotheken von Drittanbietern: Vertrauen Sie darauf, dass sie funktionieren
- Implementierungsdetails: Verhalten testen, nicht wie es implementiert ist
Test-Anti-Muster
Implementierungsdetails testen
// Schlecht - testet die Implementierung
test('uses cache before database', async () => {
await service.getUser('123');
expect(cache.get).toHaveBeenCalledBefore(database.query);
});
// Gut - testet das Verhalten
test('returns user data', async () => {
const user = await service.getUser('123');
expect(user).toMatchObject({ id: '123', name: 'John' });
});
Übermäßiges Mocking
// Schlecht - alles mocken
test('processOrder', async () => {
const mockDb = mock<Database>();
const mockCache = mock<Cache>();
const mockLogger = mock<Logger>();
const mockMetrics = mock<Metrics>();
// ... 10 weitere Mocks
// Dieser Test sagt uns nichts über reales Verhalten
});
// Gut - wo praktikabel echte Abhängigkeiten verwenden
test('processOrder', async () => {
const service = new OrderService(realDb, realCache);
// Nur externe Services mocken
service.paymentProvider = mockPaymentProvider;
const result = await service.processOrder(testOrder);
// Echten Datenbankzustand überprüfen
const saved = await realDb.orders.findById(result.id);
expect(saved.status).toBe('confirmed');
});
Flaky Tests
Tests, die manchmal bestehen und manchmal fehlschlagen, sind schlimmer als gar keine Tests:
// Schlecht - abhängig vom Timing
test('debounces search', async () => {
input.type('hello');
await sleep(300);
expect(searchCalled).toBe(false);
await sleep(200);
expect(searchCalled).toBe(true);
});
// Gut - Fake-Timer verwenden
test('debounces search', async () => {
jest.useFakeTimers();
input.type('hello');
jest.advanceTimersByTime(300);
expect(searchCalled).toBe(false);
jest.advanceTimersByTime(200);
expect(searchCalled).toBe(true);
});
Test-Organisation
Dateistruktur
src/
├── orders/
│ ├── order.service.ts
│ ├── order.service.test.ts # Unit-Tests
│ ├── order.repository.ts
│ └── order.repository.test.ts # Integrationstests
└── __tests__/
├── api/
│ └── orders.api.test.ts # API-Tests
└── e2e/
└── checkout.e2e.test.ts # E2E-Tests
Namenskonventionen
describe('OrderService', () => {
describe('createOrder', () => {
test('creates order with valid input', () => {});
test('throws ValidationError for empty cart', () => {});
test('reserves inventory before charging payment', () => {});
});
});
Continuous Integration
Schnelle Feedback-Schleife
# Bei jedem Push ausführen
test:unit:
script: npm run test:unit
timeout: 2 minutes
# Bei PR ausführen
test:integration:
script: npm run test:integration
timeout: 10 minutes
# Vor dem Deployment ausführen
test:e2e:
script: npm run test:e2e
timeout: 30 minutes
Schnell scheitern
Führen Sie die schnellsten Tests zuerst aus. Wenn Unit-Tests fehlschlagen, sparen Sie sich die E2E-Tests.
Fazit
Eine gute Teststrategie geht um Vertrauen, nicht um Abdeckung. Sie wollen:
- Fehler vor der Produktion abfangen
- Refactoring ohne Angst ermöglichen
- Erwartetes Verhalten dokumentieren
- Die Entwicklung nicht verlangsamen
Konzentrieren Sie Ihren Testaufwand dort, wo er am wichtigsten ist: Geschäftslogik, kritische Pfade und Integrationspunkte. Überspringen Sie Tests, die keinen Mehrwert bringen.
Die beste Testsuite ist die, die Ihr Team tatsächlich ausführt und der es vertraut.