27 Code Coverage in Angular-Tests

Code Coverage (Testabdeckung) ist ein Maß dafür, wie viel des Anwendungscodes durch automatisierte Tests ausgeführt wird. In Angular-Projekten ist Code Coverage ein wichtiges Instrument zur Beurteilung der Testqualität, jedoch mit entscheidenden Einschränkungen, die jeder Entwickler verstehen sollte.

27.1 Grundlagen der Code Coverage

Code Coverage misst in der Regel vier Hauptaspekte:

  1. Zeilenabdeckung (Line Coverage): Welcher Prozentsatz der Codezeilen wurde ausgeführt?
  2. Anweisungsabdeckung (Statement Coverage): Welcher Prozentsatz der Anweisungen wurde ausgeführt?
  3. Zweigabdeckung (Branch Coverage): Wurden alle möglichen Pfade durch Entscheidungspunkte (if/else, switch) ausgeführt?
  4. Funktionsabdeckung (Function Coverage): Welcher Prozentsatz der Funktionen wurde aufgerufen?

27.2 Coverage-Messung in Angular

In Angular-Projekten ist die Messung der Code Coverage bereits in das Build-System integriert:

# Test mit Coverage-Bericht ausführen
ng test --code-coverage

# Alternative für Jest
ng test --coverage

Dies erzeugt einen Coverage-Bericht im /coverage-Verzeichnis des Projekts, der detaillierte Informationen über die Testabdeckung liefert und als HTML-Seite geöffnet werden kann.

27.3 Konfiguration der Coverage-Anforderungen

In der Datei angular.json können Mindestanforderungen für die Code Coverage festgelegt werden:

"test": {
  "options": {
    "codeCoverage": true,
    "codeCoverageExclude": [
      "src/testing/**/*",
      "src/environments/**/*"
    ],
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 85,
        "lines": 90,
        "statements": 90
      }
    }
  }
}

Mit Tools wie karma-coverage kann der Testlauf fehlschlagen, wenn die definierten Schwellenwerte nicht erreicht werden.

27.4 Die begrenzte Aussagekraft von Coverage-Prozentwerten

Ein hoher Coverage-Wert, selbst 100%, garantiert keineswegs fehlerfreien Code. Diese Erkenntnis ist entscheidend für die richtige Einschätzung von Testabdeckungsmetriken.

27.4.1 Warum 100% Coverage nicht ausreicht

Ein Testabdeckungswert von 100% bedeutet lediglich, dass jede Codezeile während der Tests mindestens einmal ausgeführt wurde. Dies sagt jedoch nichts darüber aus:

  1. Ob alle möglichen Zustandskombinationen getestet wurden. Ein einfaches Beispiel:
function verifyAge(age: number): string {
  if (age < 0) {
    return 'Invalid age';
  } else if (age < 18) {
    return 'Minor';
  } else if (age < 65) {
    return 'Adult';
  } else {
    return 'Senior';
  }
}

Ein Test, der nur die Werte -1, 10, 30 und 70 prüft, würde 100% Code Coverage erreichen, aber zahlreiche kritische Grenzfälle auslassen, wie z.B. den exakten Wert 18 oder 65.

  1. Ob alle Datenabhängigkeiten korrekt getestet wurden. Betrachten wir:
function processUserData(user: User): UserStats {
  const stats: UserStats = { activityLevel: 'low' };
  
  if (user.activities && user.activities.length > 0) {
    let totalPoints = 0;
    for (const activity of user.activities) {
      totalPoints += activity.points || 0;
    }
    
    if (totalPoints > 1000) {
      stats.activityLevel = 'high';
    } else if (totalPoints > 100) {
      stats.activityLevel = 'medium';
    }
  }
  
  return stats;
}

Selbst bei 100% Coverage könnten wichtige Szenarien ungetestet bleiben, wie der Fall, dass activity.points undefined ist, oder der Grenzfall von genau 100 oder 1000 Punkten.

  1. Ob die Interaktionen zwischen Komponenten korrekt getestet wurden. Die Ausführung einzelner Code-Einheiten garantiert nicht, dass sie korrekt zusammenarbeiten.

27.4.2 Der “Coverage-Illusion”-Effekt

Ein hohes Coverage-Maß kann ein falsches Sicherheitsgefühl erzeugen. Entwicklerteams können in die Falle tappen, Tests zu schreiben, die primär auf die Erhöhung der Coverage-Werte abzielen, anstatt auf das Testen funktionaler Anforderungen und Randbedingungen.

27.5 Die Bedeutung von Boundary Testing

Um die Grenzen der reinen Coverage-Messung zu überwinden, ist das Testen von Grenzwerten (Boundary Testing) entscheidend.

27.5.1 Innerhalb und außerhalb der Grenzen testen

Für jeden entscheidenden Bedingungspunkt sollten wir Testfälle für:

  1. Direkt auf der Grenze: Exakt der Wert, der die Bedingung auslöst (z.B. genau 18 Jahre)
  2. Knapp innerhalb der Grenze: Ein Wert kurz hinter dem Grenzwert (z.B. 18 Jahre und 1 Tag)
  3. Knapp außerhalb der Grenze: Ein Wert direkt vor dem Grenzwert (z.B. 17 Jahre und 364 Tage)
describe('verifyAge function', () => {
  // Ungültige Eingaben
  it('should identify negative ages as invalid', () => {
    expect(verifyAge(-1)).toBe('Invalid age');
    expect(verifyAge(-100)).toBe('Invalid age');
  });
  
  // Minderjährige - Grenztests
  it('should identify ages 0 to 17 as minor', () => {
    expect(verifyAge(0)).toBe('Minor');      // Untergrenze
    expect(verifyAge(10)).toBe('Minor');     // Mittlerer Wert
    expect(verifyAge(17)).toBe('Minor');     // Knapp unter der Grenze
  });
  
  // Erwachsene - Grenztests
  it('should identify ages 18 to 64 as adult', () => {
    expect(verifyAge(18)).toBe('Adult');     // Exakt auf der Grenze
    expect(verifyAge(19)).toBe('Adult');     // Knapp über der Grenze
    expect(verifyAge(40)).toBe('Adult');     // Mittlerer Wert
    expect(verifyAge(64)).toBe('Adult');     // Knapp unter der nächsten Grenze
  });
  
  // Senioren - Grenztests
  it('should identify ages 65 and above as senior', () => {
    expect(verifyAge(65)).toBe('Senior');    // Exakt auf der Grenze
    expect(verifyAge(66)).toBe('Senior');    // Knapp über der Grenze
    expect(verifyAge(90)).toBe('Senior');    // Höherer Wert
  });
});

27.5.2 Äquivalenzklassen identifizieren

Statt blind auf 100% Coverage zu zielen, sollten Entwickler Äquivalenzklassen identifizieren – Eingabebereiche, die voraussichtlich das gleiche Verhalten auslösen – und dann Grenzwerte für jede Klasse testen.

Für eine Funktion, die Rabatte basierend auf Bestellwerten berechnet:

describe('calculateDiscount function', () => {
  // Äquivalenzklasse: Kein Rabatt (0-99€)
  it('should apply no discount for orders below 100€', () => {
    expect(calculateDiscount(0)).toBe(0);      // Untergrenze
    expect(calculateDiscount(50)).toBe(0);     // Mittlerer Wert
    expect(calculateDiscount(99.99)).toBe(0);  // Knapp unter der Grenze
  });
  
  // Äquivalenzklasse: 5% Rabatt (100-499€)
  it('should apply 5% discount for orders between 100€ and 499€', () => {
    expect(calculateDiscount(100)).toBe(5);    // Exakt auf der Grenze
    expect(calculateDiscount(100.01)).toBe(5); // Knapp über der Grenze
    expect(calculateDiscount(300)).toBe(5);    // Mittlerer Wert
    expect(calculateDiscount(499.99)).toBe(5); // Knapp unter der nächsten Grenze
  });
  
  // Äquivalenzklasse: 10% Rabatt (500€+)
  it('should apply 10% discount for orders of 500€ or more', () => {
    expect(calculateDiscount(500)).toBe(10);   // Exakt auf der Grenze
    expect(calculateDiscount(500.01)).toBe(10);// Knapp über der Grenze
    expect(calculateDiscount(1000)).toBe(10);  // Höherer Wert
  });
});

27.6 Integration von Code-Coverage in CI/CD-Pipelines

Code Coverage ist besonders wertvoll, wenn sie in Continuous Integration eingebunden wird:

# .gitlab-ci.yml Beispiel
test:
  stage: test
  script:
    - ng test --code-coverage --watch=false
  coverage: /Statements\s+:\s+(\d+\.?\d*)%/
  artifacts:
    paths:
      - coverage/

Das ermöglicht die Überwachung der Coverage-Entwicklung im Zeitverlauf und kann bei Unterschreitung definierter Schwellenwerte automatisch Warnungen oder Fehler auslösen.

27.7 Ausgewogener Ansatz zur Code Coverage

Ein realistischer und wertvoller Ansatz zur Code Coverage sollte Folgendes berücksichtigen:

  1. Coverage als Werkzeug, nicht als Ziel: Verwenden Sie Coverage-Berichte, um ungetestete Bereiche zu identifizieren, nicht als primäres Erfolgskriterium.

  2. Funktionale Abdeckung: Messen Sie, welcher Prozentsatz der funktionalen Anforderungen durch Tests abgedeckt ist, nicht nur Code-Zeilen.

  3. Risikoorientiertes Testen: Widmen Sie kritischen oder komplexen Komponenten mehr Testaufwand als einfachem, unkritischem Code.

  4. Realistische Schwellenwerte: Setzen Sie praktikable Coverage-Schwellenwerte basierend auf Projektkontext und Ressourcen.

  5. Qualität vor Quantität: Ein einzelner durchdachter Test kann wertvoller sein als viele oberflächliche.

27.8 Die Rolle von Coverage in einer umfassenden Teststrategie

Code Coverage ist ein nützliches Werkzeug, aber nur ein Element einer umfassenden Teststrategie. Ein ausgewogener Ansatz kombiniert Coverage-Metriken mit:

Die Qualität eines Test-Suites wird letztendlich nicht durch einen Prozentwert definiert, sondern durch seine Fähigkeit, Fehler zu finden, Regressionsprobleme zu verhindern und das Vertrauen in die Anwendung zu stärken.

// Ein einfaches Beispiel, das die Grenzen von Coverage-Metriken demonstriert:
function divideNumbers(a: number, b: number): number {
  if (b === 0) {
    throw new Error('Division by zero');
  }
  return a / b;
}

// Ein Test mit 100% Coverage, der aber wichtige Fälle missachtet
it('should divide numbers', () => {
  expect(divideNumbers(10, 2)).toBe(5);
});

// Besser wäre:
describe('divideNumbers', () => {
  it('should correctly divide positive numbers', () => {
    expect(divideNumbers(10, 2)).toBe(5);
  });
  
  it('should correctly divide with negative numbers', () => {
    expect(divideNumbers(-10, 2)).toBe(-5);
    expect(divideNumbers(10, -2)).toBe(-5);
    expect(divideNumbers(-10, -2)).toBe(5);
  });
  
  it('should correctly handle division by decimals', () => {
    expect(divideNumbers(10, 0.5)).toBe(20);
  });
  
  it('should throw error when dividing by zero', () => {
    expect(() => divideNumbers(10, 0)).toThrowError('Division by zero');
  });
});