Eine praktische Teststrategie für reale Anwendungen

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:

  1. Fehler vor der Produktion abfangen
  2. Refactoring ohne Angst ermöglichen
  3. Erwartetes Verhalten dokumentieren
  4. 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.