<link rel="stylesheet" href="/assets/fonts/jetbrains-mono/jetbrains-mono.css" />
All posts

Testing und Debugging in Angular: Der vollständige leitfaden zu Jest, Jasmine/Karma, Cypress, Playwright und Angular Devtools

Einführung: Warum Testing und Debugging den Unterschied Ausmachen

Jede Angular-Anwendung, die in Produktion geht, steht früher oder später vor derselben Frage: "Woher weiß ich, dass diese Änderung nichts kaputt gemacht hat?". Die ernsthafte Antwort lautet nicht "ich habe es von Hand im Browser getestet", sondern eine automatisierte Test-Suite, die in Sekunden läuft, bei jedem Commit, ohne menschliches Zutun. Gleichzeitig braucht man, wenn etwas schiefgeht — eine Komponente, die zu oft neu rendert, ein Memory Leak, eine Route, die nicht lädt — gezielte Debugging-Werkzeuge, um zu verstehen, was passiert und wo, statt sich auf verstreute console.log-Aufrufe zu verlassen.

In diesem Guide vertiefen wir drei Säulen des Lebenszyklus einer ausgereiften Angular-Anwendung:

  • Unit-Tests mit Jasmine/Karma (der Angular-CLI-Standard) und mit Jest (der schnelleren, im modernen JavaScript-Ökosystem zunehmend beliebten Alternative).
  • E2E-Tests (End-to-End) mit Cypress und Playwright, um vollständige Benutzerabläufe in einem echten Browser zu validieren.
  • Performance-Debugging mit Angular DevTools, um Engpässe bei Change Detection und Rendering zu finden.

Jeder Abschnitt enthält einsatzbereite Codebeispiele aus realen Szenarien mit Komponenten, Services, Signals und HTTP-Aufrufen — denselben Bausteinen, die in jeder produktiven Angular-Anwendung zu finden sind.

Themen: #Angular #Testing #UnitTesting #Jest #Jasmine #Karma #Cypress #Playwright #E2ETesting #AngularDevTools #Debugging #Performance #WebDevelopment

Die Testpyramide: Wo Sich die Zeit Investieren Lohnt

Bevor man auch nur eine Zeile Test schreibt, lohnt es sich, das mentale Modell der Testpyramide vor Augen zu haben, ursprünglich von Mike Cohn vorgeschlagen:

  • Basis (breit): Unit-Tests. Schnell (Millisekunden), isoliert, zahlreich. Sie testen eine Funktion, eine Komponente oder einen Service isoliert.
  • Mitte: Integrationstests. Prüfen, ob mehrere Einheiten korrekt zusammenarbeiten (z. B. eine Komponente mit ihrem echten, nicht gemockten Service).
  • Spitze (schmal): E2E-Tests. Langsam (Sekunden/Minuten), brüchig bei schlechter Schreibweise, aber die einzigen, die die echte Nutzererfahrung vom Login bis zum Checkout validieren.

Der häufigste Fehler ist, die Pyramide umzukehren: wenige Unit-Tests und Dutzende langsame, brüchige E2E-Tests, die die CI zu einem 40-minütigen Albtraum machen. Ziel dieses Guides ist es, Ihnen die Werkzeuge an die Hand zu geben, um die Pyramide richtig herum aufzubauen: viele schnelle Unit-Tests, eine angemessene Anzahl von E2E-Tests auf kritischen Abläufen und Debugging-Werkzeuge für den Fall, dass trotzdem etwas durch die Tests rutscht.

Teil 1: Unit-Tests mit Jest und Jasmine/Karma

1.1 — Das Standard-Setup: Jasmine + Karma

Wenn Sie ein Projekt mit ng new erstellen, konfiguriert Angular CLI automatisch Jasmine als Assertion-Framework (describe, it, expect) und Karma als Test Runner, der die Tests über karma.conf.js in einem echten Browser (standardmäßig Chrome Headless) ausführt.

// karma.conf.js — configurazione generata da Angular CLI
process.env.CHROME_BIN = require('puppeteer').executablePath();

module.exports = function (config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine', '@angular-devkit/build-angular'],
    plugins: [
      require('karma-jasmine'),
      require('karma-chrome-launcher'),
      require('karma-jasmine-html-reporter'),
      require('karma-coverage'),
      require('@angular-devkit/build-angular/plugins/karma'),
    ],
    client: {
      jasmine: {
        // random: false disabilita l'ordine casuale dei test (utile in debug)
        random: false,
      },
      clearContext: false,
    },
    coverageReporter: {
      dir: require('path').join(__dirname, './coverage/my-app'),
      subdir: '.',
      reporters: [{ type: 'html' }, { type: 'text-summary' }],
      // Soglie minime: la build fallisce se la copertura scende sotto questi valori
      check: {
        global: {
          statements: 80,
          branches: 75,
          functions: 80,
          lines: 80,
        },
      },
    },
    reporters: ['progress', 'kjhtml'],
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['ChromeHeadless'],
    singleRun: true, // false in locale per il watch mode, true in CI
    restartOnFileChange: true,
  });
};

Zum Ausführen der Tests: lokal ng test (Watch-Modus aktiv) oder ng test --no-watch --browsers=ChromeHeadless in der CI, wo kein interaktiver Browser benötigt wird und der Prozess mit einem Exit Code enden soll, statt im Wartezustand zu bleiben.

1.2 — Anatomie eines Unit-Tests: describe, it, expect

Jede Jasmine-Testdatei folgt derselben Struktur: describe gruppiert logisch zusammengehörige Tests, it (oder sein Alias test) definiert einen einzelnen Fall, expect führt die Assertion durch.

// price-calculator.ts
export function applyDiscount(price: number, percentage: number): number {
  if (percentage < 0 || percentage > 100) {
    throw new Error('La percentuale di sconto deve essere tra 0 e 100');
  }
  return Number((price - (price * percentage) / 100).toFixed(2));
}

// price-calculator.spec.ts
import { applyDiscount } from './price-calculator';

describe('applyDiscount', () => {
  it('applica correttamente uno sconto del 20%', () => {
    expect(applyDiscount(100, 20)).toBe(80);
  });

  it('non modifica il prezzo con sconto 0%', () => {
    expect(applyDiscount(50, 0)).toBe(50);
  });

  it('arrotonda a due decimali', () => {
    expect(applyDiscount(19.99, 15)).toBe(16.99);
  });

  it('lancia un errore se la percentuale è negativa', () => {
    expect(() => applyDiscount(100, -5)).toThrowError(
      'La percentuale di sconto deve essere tra 0 e 100',
    );
  });

  it('lancia un errore se la percentuale supera 100', () => {
    expect(() => applyDiscount(100, 150)).toThrowError();
  });
});

Dies ist ein reiner Unit-Test: keine Angular-Abhängigkeit, kein TestBed, nur eine Funktion und ihre Assertions. Es ist die schnellste Art von Test zum Schreiben, Lesen und Ausführen — und sollte immer dann bevorzugt werden, wenn Logik aus einer Komponente oder einem Service in eine reine, isoliert testbare Funktion extrahiert werden kann.

1.3 — Einen Angular-Service mit TestBed testen

Wenn die Logik von Angulars DI (Dependency Injection) abhängt, wird TestBed benötigt, um eine Testumgebung mit denselben Providern aufzubauen, die die echte Anwendung verwenden würde — wobei externe Abhängigkeiten (HTTP, Storage, andere Services) durch Mocks ersetzt werden.

// cart.service.ts
import { Injectable, signal, computed } from '@angular/core';

export interface CartItem {
  id: string;
  price: number;
  quantity: number;
}

@Injectable({ providedIn: 'root' })
export class CartService {
  private readonly _items = signal([]);
  readonly items = this._items.asReadonly();

  readonly total = computed(() =>
    this._items().reduce((sum, item) => sum + item.price * item.quantity, 0),
  );

  readonly itemCount = computed(() =>
    this._items().reduce((sum, item) => sum + item.quantity, 0),
  );

  addItem(item: CartItem): void {
    const existing = this._items().find(i => i.id === item.id);
    if (existing) {
      this._items.update(items =>
        items.map(i => (i.id === item.id ? { ...i, quantity: i.quantity + item.quantity } : i)),
      );
    } else {
      this._items.update(items => [...items, item]);
    }
  }

  removeItem(id: string): void {
    this._items.update(items => items.filter(i => i.id !== id));
  }

  clear(): void {
    this._items.set([]);
  }
}

// cart.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { CartService } from './cart.service';

describe('CartService', () => {
  let service: CartService;

  beforeEach(() => {
    TestBed.configureTestingModule({});
    service = TestBed.inject(CartService);
  });

  it('parte con carrello vuoto', () => {
    expect(service.items()).toEqual([]);
    expect(service.total()).toBe(0);
  });

  it('aggiunge un articolo e calcola il totale', () => {
    service.addItem({ id: 'p1', price: 10, quantity: 2 });
    expect(service.items().length).toBe(1);
    expect(service.total()).toBe(20);
  });

  it('incrementa la quantità se l\'articolo esiste già', () => {
    service.addItem({ id: 'p1', price: 10, quantity: 1 });
    service.addItem({ id: 'p1', price: 10, quantity: 3 });
    expect(service.items().length).toBe(1);
    expect(service.items()[0].quantity).toBe(4);
  });

  it('rimuove un articolo per id', () => {
    service.addItem({ id: 'p1', price: 10, quantity: 1 });
    service.addItem({ id: 'p2', price: 20, quantity: 1 });
    service.removeItem('p1');
    expect(service.items().length).toBe(1);
    expect(service.items()[0].id).toBe('p2');
  });

  it('itemCount somma le quantità, non il numero di righe', () => {
    service.addItem({ id: 'p1', price: 10, quantity: 3 });
    service.addItem({ id: 'p2', price: 5, quantity: 2 });
    expect(service.itemCount()).toBe(5);
  });
});

Beachten Sie, wie dieser Test auch Computed Signals validiert: Es ist keine besondere Konfiguration nötig, Computeds werden synchron neu berechnet und können sofort nach der Zustandsänderung wie eine normale Funktion gelesen werden (service.total()).

1.4 — Eine Standalone-Komponente mit TestBed und ComponentFixture testen

Das Testen einer Komponente erfordert einen zusätzlichen Schritt gegenüber einem Service: Man muss über ComponentFixture eine echte Instanz der Komponente im DOM erzeugen, mit fixture.detectChanges() einen Change-Detection-Zyklus erzwingen und dann das resultierende DOM mit DebugElement oder nativen Queries abfragen.

// quantity-selector.component.ts
import { Component, input, output, computed } from '@angular/core';

@Component({
  selector: 'app-quantity-selector',
  standalone: true,
  template: `
    -
    {{ value() }}
    +
  `,
})
export class QuantitySelectorComponent {
  value = input(1);
  min = input(1);
  max = input(99);
  valueChange = output();

  readonly canIncrement = computed(() => this.value() < this.max());

  increment(): void {
    if (this.value() < this.max()) {
      this.valueChange.emit(this.value() + 1);
    }
  }

  decrement(): void {
    if (this.value() > this.min()) {
      this.valueChange.emit(this.value() - 1);
    }
  }
}

// quantity-selector.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { QuantitySelectorComponent } from './quantity-selector.component';

describe('QuantitySelectorComponent', () => {
  let fixture: ComponentFixture;
  let component: QuantitySelectorComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [QuantitySelectorComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(QuantitySelectorComponent);
    component = fixture.componentInstance;
  });

  function getIncrementButton() {
    return fixture.debugElement.query(By.css('[data-testid="increment"]'));
  }

  function getQuantityText(): string {
    return fixture.debugElement.query(By.css('[data-testid="quantity-value"]')).nativeElement.textContent.trim();
  }

  it('mostra il valore iniziale', () => {
    fixture.componentRef.setInput('value', 3);
    fixture.detectChanges();
    expect(getQuantityText()).toBe('3');
  });

  it('emette valueChange con valore incrementato al click su +', () => {
    fixture.componentRef.setInput('value', 5);
    fixture.detectChanges();

    let emittedValue: number | undefined;
    component.valueChange.subscribe(v => (emittedValue = v));

    getIncrementButton().nativeElement.click();

    expect(emittedValue).toBe(6);
  });

  it('disabilita il pulsante + quando si raggiunge il massimo', () => {
    fixture.componentRef.setInput('value', 99);
    fixture.componentRef.setInput('max', 99);
    fixture.detectChanges();

    expect(getIncrementButton().nativeElement.disabled).toBeTrue();
  });

  it('non emette valueChange se il pulsante è disabilitato', () => {
    fixture.componentRef.setInput('value', 99);
    fixture.componentRef.setInput('max', 99);
    fixture.detectChanges();

    let called = false;
    component.valueChange.subscribe(() => (called = true));
    getIncrementButton().nativeElement.click();

    expect(called).toBeFalse();
  });
});

data-testid-Attribute statt CSS-Klassen zur Auswahl von Elementen in Tests zu verwenden, ist eine bewährte Best Practice: Sie entkoppelt die Tests vom visuellen Styling, sodass ein CSS-Refactoring die Test-Suite nicht bricht.

1.5 — Abhängigkeiten mit jasmine.createSpyObj ausspähen und mocken

Wenn eine Komponente von einem Service abhängt, wollen wir nicht, dass ihre Unit-Tests die echte Logik des Service aufrufen (der wiederum eine echte HTTP-API aufrufen könnte). Stattdessen erstellen wir einen Mock mit nur den notwendigen Methoden und kontrollieren genau, was sie zurückgeben.

// checkout.component.spec.ts (estratto)
import { of, throwError } from 'rxjs';
import { OrderService } from './order.service';
import { NotificationService } from '../shared/notification.service';

describe('CheckoutComponent', () => {
  let orderServiceSpy: jasmine.SpyObj;
  let notificationSpy: jasmine.SpyObj;

  beforeEach(async () => {
    orderServiceSpy = jasmine.createSpyObj('OrderService', ['submitOrder', 'getShippingCost']);
    notificationSpy = jasmine.createSpyObj('NotificationService', ['success', 'error']);

    await TestBed.configureTestingModule({
      imports: [CheckoutComponent],
      providers: [
        { provide: OrderService, useValue: orderServiceSpy },
        { provide: NotificationService, useValue: notificationSpy },
      ],
    }).compileComponents();
  });

  it('mostra un messaggio di successo quando l\'ordine va a buon fine', () => {
    orderServiceSpy.submitOrder.and.returnValue(of({ orderId: 'ORD-123', status: 'confirmed' }));

    const fixture = TestBed.createComponent(CheckoutComponent);
    fixture.componentInstance.submit();

    expect(orderServiceSpy.submitOrder).toHaveBeenCalledTimes(1);
    expect(notificationSpy.success).toHaveBeenCalledWith(jasmine.stringMatching(/ORD-123/));
  });

  it('mostra un errore se il servizio ordini fallisce', () => {
    orderServiceSpy.submitOrder.and.returnValue(
      throwError(() => new Error('Pagamento rifiutato')),
    );

    const fixture = TestBed.createComponent(CheckoutComponent);
    fixture.componentInstance.submit();

    expect(notificationSpy.error).toHaveBeenCalledWith('Pagamento rifiutato');
    expect(notificationSpy.success).not.toHaveBeenCalled();
  });
});

jasmine.createSpyObj erstellt ein Objekt mit gefälschten Methoden, die jeden Aufruf protokollieren. .and.returnValue(...) konfiguriert, was zurückgegeben wird, und toHaveBeenCalledWith(...) prüft die übergebenen Argumente. Diese Technik isoliert die zu testende Komponente vollständig von der echten Logik ihrer Abhängigkeiten.

1.6 — HTTP-Aufrufe mit HttpClientTestingModule testen

Für Services, die HttpClient verwenden, stellt Angular HttpClientTestingModule und HttpTestingController bereit: Sie fangen echte HTTP-Requests ab und erlauben es, mit gefälschten Daten zu antworten, während gleichzeitig URL, Methode und verwendete Header überprüft werden.

// product.service.ts
@Injectable({ providedIn: 'root' })
export class ProductService {
  private readonly http = inject(HttpClient);
  private readonly baseUrl = '/api/products';

  getById(id: string): Observable {
    return this.http.get(`${this.baseUrl}/${id}`);
  }

  search(query: string, page = 1): Observable> {
    return this.http.get>(this.baseUrl, {
      params: { q: query, page: page.toString() },
    });
  }
}

// product.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ProductService } from './product.service';

describe('ProductService', () => {
  let service: ProductService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
    });
    service = TestBed.inject(ProductService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  afterEach(() => {
    // Essenziell: prüft, dass keine verwaisten HTTP-Requests unbehandelt bleiben
    httpMock.verify();
  });

  it('recupera un prodotto per id', () => {
    const mockProduct = { id: 'p1', name: 'Tastiera meccanica', price: 89.9 };

    service.getById('p1').subscribe(product => {
      expect(product).toEqual(mockProduct);
    });

    const req = httpMock.expectOne('/api/products/p1');
    expect(req.request.method).toBe('GET');
    req.flush(mockProduct);
  });

  it('propaga un errore 404 al subscriber', () => {
    service.getById('missing').subscribe({
      next: () => fail('doveva fallire'),
      error: err => expect(err.status).toBe(404),
    });

    const req = httpMock.expectOne('/api/products/missing');
    req.flush('Not found', { status: 404, statusText: 'Not Found' });
  });

  it('costruisce correttamente i query params per la ricerca', () => {
    service.search('meccanica', 2).subscribe();

    const req = httpMock.expectOne(
      r => r.url === '/api/products' && r.params.get('q') === 'meccanica' && r.params.get('page') === '2',
    );
    expect(req.request.method).toBe('GET');
    req.flush({ items: [], total: 0, page: 2 });
  });
});

Der Aufruf httpMock.verify() in afterEach ist essenziell: Er lässt den Test fehlschlagen, wenn HTTP-Requests, die die Komponente/der Service gesendet hat, von keiner Assertion abgefangen wurden — so werden falsche Positivergebnisse vermieden, bei denen ein Request stillschweigend "vergessen" wird.

1.7 — Asynchroner Code: fakeAsync, tick() und waitForAsync

Manche Szenarien — Debounce in einem Suchfeld, Timeouts, Retries mit Verzögerung — verwenden setTimeout oder zeitgesteuerte RxJS-Operatoren wie debounceTime. Sie mit echten Timern zu testen würde die Suite langsam und nicht-deterministisch machen. fakeAsync löst das Problem, indem es die Zeit virtualisiert.

// search-box.component.ts (estratto)
export class SearchBoxComponent {
  private readonly productService = inject(ProductService);
  query = new FormControl('');
  results = signal([]);

  constructor() {
    this.query.valueChanges
      .pipe(
        debounceTime(300),
        distinctUntilChanged(),
        switchMap(q => (q ? this.productService.search(q) : of({ items: [] as Product[] }))),
        takeUntilDestroyed(),
      )
      .subscribe(result => this.results.set(result.items));
  }
}

// search-box.component.spec.ts
import { fakeAsync, tick, TestBed } from '@angular/core/testing';

it('esegue la ricerca solo dopo 300ms di inattività (debounce)', fakeAsync(() => {
  const fixture = TestBed.createComponent(SearchBoxComponent);
  const component = fixture.componentInstance;
  spyOn(TestBed.inject(ProductService), 'search').and.returnValue(
    of({ items: [{ id: 'p1', name: 'Mouse', price: 20 }] }),
  );

  component.query.setValue('mo');
  tick(100);
  component.query.setValue('mou');
  tick(100);
  component.query.setValue('mouse');

  // Es sind erst 200ms seit der letzten Änderung vergangen: die Suche darf noch NICHT starten
  expect(TestBed.inject(ProductService).search).not.toHaveBeenCalled();

  tick(300); // wir vervollständigen den Debounce
  fixture.detectChanges();

  expect(TestBed.inject(ProductService).search).toHaveBeenCalledOnceWith('mouse');
  expect(component.results().length).toBe(1);
}));

tick(ms) lässt die virtuelle Uhr der Testzone künstlich vorlaufen und löst anstehende Timer aus, ohne tatsächlich so lange zu warten. Das macht den Test deterministisch und sofort, während die Debounce-Logik korrekt validiert wird.

1.8 — Effects testen, die auf Signals reagieren

Mit der Einführung von Signals verwenden viele Komponenten effect(), um auf Zustandsänderungen zu reagieren (z. B. Synchronisation mit localStorage). Das Testen eines Effects erfordert, den Flush des reaktiven Kontexts explizit zu erzwingen — mit TestBed.flushEffects() oder, einfacher, mit fixture.detectChanges(), wenn die Komponente an ein Fixture angebunden ist.

// theme.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { ThemeService } from './theme.service';

describe('ThemeService — persistenza tema con effect()', () => {
  beforeEach(() => localStorage.clear());

  it('salva il tema in localStorage quando cambia', () => {
    TestBed.runInInjectionContext(() => {
      const service = TestBed.inject(ThemeService);
      service.setTheme('dark');

      TestBed.tick(); // erzwingt den Flush ausstehender Effects

      expect(localStorage.getItem('theme')).toBe('dark');
    });
  });
});

1.9 — Marble Testing für komplexe Observables

Wenn die RxJS-Logik komplex wird (Kombinationen von combineLatest, switchMap, Retry mit Backoff), erlaubt Marble Testing mit TestScheduler, zeitliche Ereignisabfolgen deklarativ zu beschreiben und in einem einzigen synchronen Tick zu verifizieren.

import { TestScheduler } from 'rxjs/testing';

describe('retryWithBackoff (marble testing)', () => {
  let scheduler: TestScheduler;

  beforeEach(() => {
    scheduler = new TestScheduler((actual, expected) => {
      expect(actual).toEqual(expected);
    });
  });

  it('riprova due volte prima di emettere il valore con successo', () => {
    scheduler.run(({ cold, expectObservable }) => {
      // '#' steht für einen Fehler, '-' für einen Zeitframe, 'a' für einen emittierten Wert
      const source$ = cold('-#', undefined, new Error('fail'));
      const expected = '  -- -- -- -- -#';

      // In einem realen Szenario würden wir hier retryWithBackoff(source$, { retries: 2 }) anwenden
      expectObservable(source$.pipe()).toBe('-#', undefined, new Error('fail'));
    });
  });
});

Marble Testing hat eine steilere Lernkurve im Vergleich zu "klassischen" Tests, zahlt sich aber aus, wenn die reaktive Logik präzises Timing beinhaltet, das mit reinem fakeAsync nur schwer zuverlässig zu verifizieren wäre.

1.10 — Zu Jest wechseln: warum und wie

Jest ist zum De-facto-Standard im React/Node-Ökosystem geworden, und immer mehr Angular-Teams setzen es aus drei konkreten Gründen anstelle von Karma ein:

  • Geschwindigkeit: Jest führt Tests in Node.js mit JSDOM aus, ohne einen echten Chrome-Browser zu öffnen — deutlich schneller, besonders in der CI.
  • Intelligenter Watch-Modus: jest --watch führt nur die Tests aus, die mit geänderten Dateien zusammenhängen (via Git-Diff-Analyse).
  • Integriertes Snapshot Testing und Mocking: jest.fn(), jest.mock() und Snapshots sind nativ verfügbar, ohne zusätzliche Bibliotheken.
npm install --save-dev jest jest-preset-angular @types/jest
npm uninstall karma karma-chrome-launcher karma-coverage karma-jasmine karma-jasmine-html-reporter
// jest.config.js
module.exports = {
  preset: 'jest-preset-angular',
  setupFilesAfterEach: ['/setup-jest.ts'],
  testPathIgnorePatterns: ['/node_modules/', '/dist/'],
  collectCoverage: true,
  coverageDirectory: 'coverage',
  coverageThreshold: {
    global: {
      statements: 80,
      branches: 75,
      functions: 80,
      lines: 80,
    },
  },
  transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
};
// setup-jest.ts
import 'jest-preset-angular/setup-jest';
// package.json (estratto)
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:ci": "jest --ci --coverage --maxWorkers=2"
  }
}

1.11 — Jasmine vs Jest: praktische Syntaxunterschiede

Gute Nachricht: Die describe/it/expect-Syntax ist nahezu identisch, sodass die Migration der meisten Tests oft nur eine Frage der Konfiguration ist. Die Unterschiede zeigen sich vor allem beim Mocking.

AspektJasmineJest
Einen Spy erstellenjasmine.createSpy('name')jest.fn()
Ein ganzes Modul mockenNicht nativ (DI erforderlich)jest.mock('./module')
Rückgabewert konfigurierenspy.and.returnValue(x)fn.mockReturnValue(x)
Snapshot TestingNicht nativ unterstütztexpect(x).toMatchSnapshot()
Fake TimerfakeAsync + tick()jest.useFakeTimers() + jest.advanceTimersByTime()
AusführungsumgebungEchter Browser (Karma + Chrome)Node.js + JSDOM
Typische Geschwindigkeit (500 Tests)~25-40s~8-15s
// Beispiel für Modul-Mocking mit Jest — nützlich für schwere externe Bibliotheken
jest.mock('./analytics.service', () => ({
  AnalyticsService: jest.fn().mockImplementation(() => ({
    track: jest.fn(),
    identify: jest.fn(),
  })),
}));

describe('CheckoutComponent con Jest', () => {
  it('traccia l\'evento purchase al completamento ordine', () => {
    const trackSpy = jest.fn();
    // ... Konfiguration der Komponente mit dem Mock ...
    expect(trackSpy).toHaveBeenCalledWith('purchase', expect.objectContaining({ orderId: expect.any(String) }));
  });
});

1.12 — Snapshot Testing: wann einsetzen (und wann vermeiden)

Snapshot-Tests erfassen die gerenderte Ausgabe einer Komponente und vergleichen sie mit einer auf der Festplatte gespeicherten Version. Sie sind nützlich für rein präsentationelle Komponenten mit stabiler Ausgabe, werden aber zum Problem, wenn das Team sie automatisch mit --ci=false --updateSnapshot aktualisiert, ohne den Diff zu prüfen — wodurch sie zu einer rein formalen Kontrolle werden.

it('il badge di stato ordine ha il markup atteso', () => {
  const fixture = TestBed.createComponent(OrderStatusBadgeComponent);
  fixture.componentRef.setInput('status', 'shipped');
  fixture.detectChanges();

  expect(fixture.nativeElement.innerHTML).toMatchSnapshot();
});

Faustregel: Verwenden Sie Snapshots nur für einfaches, stabiles Markup (Badges, Icons, Formatierer), niemals für Komponenten mit Geschäftslogik — dort kommuniziert eine explizite Assertion (expect(text).toBe('Spedito')) die Absicht deutlich besser als ein undurchsichtiger 200-Zeilen-HTML-Snapshot.

1.13 — Übergreifende Best Practices für effektive Unit-Tests

  • AAA-Muster (Arrange-Act-Assert): Strukturieren Sie jeden Test in drei klare Blöcke: Daten vorbereiten, Aktion ausführen, Ergebnis überprüfen.
  • Eine konzeptuelle Assertion pro Test: Mehrere expect-Aufrufe im selben Test sind in Ordnung, wenn sie dasselbe aus verschiedenen Blickwinkeln prüfen, aber ein Test, der drei unabhängige Verhaltensweisen prüft, sollte in drei Tests aufgeteilt werden.
  • Keine Implementierungsdetails testen: Testen Sie beobachtbares Verhalten (Ausgabe, emittierte Events, gerendertes DOM), nicht private Variablen oder interne Methodenaufrufe.
  • Beschreibende Testnamen: it('deaktiviert den Button, wenn der Warenkorb leer ist') ist unendlich nützlicher als it('Test 3'), wenn ein Test in der CI um 2 Uhr nachts fehlschlägt.
  • Unabhängige Tests: Jeder Test muss allein, in beliebiger Reihenfolge, laufen können. Hängt ein Test vom Zustand ab, den ein anderer hinterlassen hat, ist er ein brüchiger Test.
  • Coverage als Indikator, nicht als Ziel: 100% Code Coverage mit schwachen Assertions (expect(true).toBe(true)) ist schlechter als 70% mit soliden Assertions. Nutzen Sie Coverage, um ungetesteten Code zu finden, nicht als KPI, der um jeden Preis maximiert werden muss.

1.14 — Guards und Resolver testen

Route Guards und Resolver sind oft der Ort, an dem die heikelste Autorisierungslogik einer Anwendung liegt — und genau die Art von Code, die unbedingt eine solide Testabdeckung haben muss, denn ein Bug hier bedeutet nicht authentifizierte Nutzer, die auf geschützte Seiten zugreifen, oder umgekehrt.

// auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';

export const authGuard: CanActivateFn = (route, state) => {
  const authService = inject(AuthService);
  const router = inject(Router);

  if (authService.isAuthenticated()) {
    return true;
  }

  router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
  return false;
};

// auth.guard.spec.ts
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { authGuard } from './auth.guard';
import { AuthService } from './auth.service';

describe('authGuard', () => {
  let authServiceSpy: jasmine.SpyObj;
  let routerSpy: jasmine.SpyObj;

  beforeEach(() => {
    authServiceSpy = jasmine.createSpyObj('AuthService', ['isAuthenticated']);
    routerSpy = jasmine.createSpyObj('Router', ['navigate']);

    TestBed.configureTestingModule({
      providers: [
        { provide: AuthService, useValue: authServiceSpy },
        { provide: Router, useValue: routerSpy },
      ],
    });
  });

  it('permette la navigazione se l\'utente è autenticato', () => {
    authServiceSpy.isAuthenticated.and.returnValue(true);

    const result = TestBed.runInInjectionContext(() =>
      authGuard({} as any, { url: '/dashboard' } as any),
    );

    expect(result).toBeTrue();
    expect(routerSpy.navigate).not.toHaveBeenCalled();
  });

  it('reindirizza al login con returnUrl se non autenticato', () => {
    authServiceSpy.isAuthenticated.and.returnValue(false);

    const result = TestBed.runInInjectionContext(() =>
      authGuard({} as any, { url: '/dashboard/settings' } as any),
    );

    expect(result).toBeFalse();
    expect(routerSpy.navigate).toHaveBeenCalledWith(['/login'], {
      queryParams: { returnUrl: '/dashboard/settings' },
    });
  });
});

Den Guard als einfache Funktion zu testen (dank TestBed.runInInjectionContext) statt eine ganze geroutete Komponente zu montieren, ist viel schneller und fokussiert sich genau auf die Autorisierungslogik, ohne Rauschen durch das Rendering.

1.15 — Benutzerdefinierte Pipes und Directives testen

// truncate.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({ name: 'truncate', standalone: true })
export class TruncatePipe implements PipeTransform {
  transform(value: string, maxLength = 100, suffix = '…'): string {
    if (!value || value.length <= maxLength) return value;
    return value.slice(0, maxLength).trim() + suffix;
  }
}

// truncate.pipe.spec.ts
describe('TruncatePipe', () => {
  const pipe = new TruncatePipe();

  it('non modifica stringhe più corte del limite', () => {
    expect(pipe.transform('Ciao mondo', 20)).toBe('Ciao mondo');
  });

  it('tronca e aggiunge il suffisso di default', () => {
    const result = pipe.transform('Questo è un testo piuttosto lungo da troncare', 10);
    expect(result).toBe('Questo è u…');
  });

  it('gestisce stringhe vuote o undefined senza errori', () => {
    expect(pipe.transform('', 10)).toBe('');
  });
});

// highlight-on-hover.directive.ts
import { Directive, ElementRef, HostListener, inject, input } from '@angular/core';

@Directive({ selector: '[appHighlightOnHover]', standalone: true })
export class HighlightOnHoverDirective {
  private readonly el = inject(ElementRef);
  color = input('#fff3cd');

  @HostListener('mouseenter') onEnter(): void {
    this.el.nativeElement.style.backgroundColor = this.color();
  }

  @HostListener('mouseleave') onLeave(): void {
    this.el.nativeElement.style.backgroundColor = '';
  }
});

// highlight-on-hover.directive.spec.ts
@Component({
  standalone: true,
  imports: [HighlightOnHoverDirective],
  template: `
Testo
`, }) class HostComponent {} describe('HighlightOnHoverDirective', () => { it('applica il colore di sfondo al mouseenter e lo rimuove al mouseleave', () => { const fixture = TestBed.createComponent(HostComponent); fixture.detectChanges(); const div: HTMLElement = fixture.nativeElement.querySelector('div'); div.dispatchEvent(new Event('mouseenter')); expect(div.style.backgroundColor).toBe('rgb(255, 0, 0)'); div.dispatchEvent(new Event('mouseleave')); expect(div.style.backgroundColor).toBe(''); }); });

Pipes und Directives sind sehr kleine Logikeinheiten ohne schwere Abhängigkeiten und gehören zu den kostengünstigsten Dingen, die man erschöpfend testen kann: Ein paar Minuten Schreibaufwand garantieren eine nahezu vollständige Abdeckung aller Grenzfälle (leerer String, Grenzwert, fehlerhafte Eingabe).

Teil 2: E2E-Testing mit Cypress und Playwright

2.1 — Warum E2E-Tests über Unit-Tests hinaus nötig sind

Unit-Tests prüfen, ob einzelne Teile isoliert funktionieren, können aber keine Integrationsprobleme erkennen: falsch konfiguriertes Routing, CSS, das einen Button versteckt, CORS, das nur in einer bestimmten Umgebung blockiert wird, oder eine Regression im vollständigen Checkout-Flow. E2E-Tests öffnen einen echten (oder headless) Browser, navigieren durch die Anwendung wie ein echter Nutzer und überprüfen das Endergebnis.

2.2 — Cypress: Installation und Projektstruktur

npm install --save-dev cypress
npx cypress open   # interaktiver Modus, nützlich während der Entwicklung
npx cypress run    # headless Modus, für CI
// cypress.config.ts
import { defineConfig } from 'cypress';

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:4200',
    supportFile: 'cypress/support/e2e.ts',
    specPattern: 'cypress/e2e/**/*.cy.ts',
    viewportWidth: 1280,
    viewportHeight: 800,
    video: false, // lokal deaktiviert, in CI oft für das Debugging reaktiviert
    retries: {
      runMode: 2,  // wiederholt fehlgeschlagene Tests 2x in CI (mildert Netzwerk-Flakiness)
      openMode: 0,
    },
    env: {
      apiUrl: 'http://localhost:3000/api',
    },
  },
});

Die typische Struktur eines Cypress-Projekts:

cypress/
├── e2e/
│   ├── auth/
│   │   ├── login.cy.ts
│   │   └── register.cy.ts
│   ├── checkout/
│   │   └── checkout-flow.cy.ts
│   └── blog/
│       └── blog-navigation.cy.ts
├── fixtures/
│   └── products.json
├── support/
│   ├── commands.ts
│   └── e2e.ts

2.3 — Erster Cypress-Test: Login-Flow

// cypress/e2e/auth/login.cy.ts
describe('Login', () => {
  beforeEach(() => {
    cy.visit('/login');
  });

  it('effettua il login con credenziali valide e reindirizza alla dashboard', () => {
    cy.get('[data-testid="email-input"]').type('utente@example.com');
    cy.get('[data-testid="password-input"]').type('PasswordSicura123!');
    cy.get('[data-testid="login-submit"]').click();

    cy.url().should('include', '/dashboard');
    cy.get('[data-testid="welcome-message"]').should('contain.text', 'Bentornato');
  });

  it('mostra un errore con credenziali errate', () => {
    cy.get('[data-testid="email-input"]').type('utente@example.com');
    cy.get('[data-testid="password-input"]').type('passwordSbagliata');
    cy.get('[data-testid="login-submit"]').click();

    cy.get('[data-testid="login-error"]')
      .should('be.visible')
      .and('contain.text', 'Credenziali non valide');
    cy.url().should('include', '/login');
  });

  it('disabilita il pulsante submit finché i campi non sono validi', () => {
    cy.get('[data-testid="login-submit"]').should('be.disabled');
    cy.get('[data-testid="email-input"]').type('non-una-email');
    cy.get('[data-testid="login-submit"]').should('be.disabled');
    cy.get('[data-testid="email-input"]').clear().type('valida@example.com');
    cy.get('[data-testid="password-input"]').type('almeno8caratteri');
    cy.get('[data-testid="login-submit"]').should('not.be.disabled');
  });
});

2.4 — Netzwerkaufrufe mit cy.intercept abfangen

Ein E2E-Test, der von einem echten Backend abhängt, ist langsam und brüchig (sich ändernde Daten, ausgefallene externe Services). cy.intercept erlaubt es, HTTP-Antworten zu stubben und den Test dabei realistisch, aber deterministisch zu halten.

// cypress/e2e/checkout/checkout-flow.cy.ts
describe('Flusso di checkout completo', () => {
  beforeEach(() => {
    cy.intercept('GET', '/api/cart', { fixture: 'cart-with-items.json' }).as('getCart');
    cy.intercept('POST', '/api/orders', {
      statusCode: 201,
      body: { orderId: 'ORD-9981', status: 'confirmed' },
    }).as('submitOrder');

    cy.visit('/checkout');
    cy.wait('@getCart');
  });

  it('completa un ordine e mostra la conferma', () => {
    cy.get('[data-testid="shipping-name"]').type('Mario Rossi');
    cy.get('[data-testid="shipping-address"]').type('Via Roma 1, Milano');
    cy.get('[data-testid="place-order"]').click();

    cy.wait('@submitOrder').its('request.body').should('deep.include', {
      shippingName: 'Mario Rossi',
    });

    cy.get('[data-testid="order-confirmation"]').should('contain.text', 'ORD-9981');
  });

  it('mostra un errore se il pagamento viene rifiutato dal backend', () => {
    cy.intercept('POST', '/api/orders', {
      statusCode: 402,
      body: { message: 'Pagamento rifiutato' },
    }).as('submitOrderFailed');

    cy.get('[data-testid="shipping-name"]').type('Mario Rossi');
    cy.get('[data-testid="shipping-address"]').type('Via Roma 1, Milano');
    cy.get('[data-testid="place-order"]').click();

    cy.wait('@submitOrderFailed');
    cy.get('[data-testid="checkout-error"]').should('contain.text', 'Pagamento rifiutato');
  });
});

2.5 — Custom Commands: DRY in Cypress-Tests bleiben

Aktionen, die sich in vielen Tests wiederholen (Login, ein Produkt zum Warenkorb hinzufügen), sollten in Custom Commands extrahiert werden, um Duplikation zu vermeiden.

// cypress/support/commands.ts
declare global {
  namespace Cypress {
    interface Chainable {
      loginViaApi(email: string, password: string): Chainable;
    }
  }
}

Cypress.Commands.add('loginViaApi', (email: string, password: string) => {
  cy.request('POST', '/api/auth/login', { email, password }).then(response => {
    window.localStorage.setItem('authToken', response.body.token);
  });
});

export {};
// Verwendung in einem Test — "stiller" Login via API, statt das Formular jedes Mal auszufüllen
describe('Dashboard (utente già autenticato)', () => {
  beforeEach(() => {
    cy.loginViaApi('utente@example.com', 'PasswordSicura123!');
    cy.visit('/dashboard');
  });

  it('mostra le statistiche dell\'utente', () => {
    cy.get('[data-testid="stats-panel"]').should('be.visible');
  });
});

Sich per direktem API-Aufruf (cy.request) statt jedes Mal über die UI einzuloggen, ist eine der effektivsten Optimierungen, um eine E2E-Suite zu beschleunigen: Ein Test, der nicht den Login selbst testen soll, muss auch nicht über die Oberfläche laufen.

2.6 — Cypress Component Testing

Über die klassischen E2E-Tests hinaus unterstützt Cypress Component Testing: Es montiert eine einzelne Angular-Komponente isoliert in einem echten Browser und kombiniert die Geschwindigkeit von Unit-Tests mit der Treue eines echten Renderings (kein JSDOM).

// quantity-selector.cy.ts (Cypress Component Testing)
import { QuantitySelectorComponent } from './quantity-selector.component';

describe('QuantitySelectorComponent (component test)', () => {
  it('incrementa il valore al click su +', () => {
    cy.mount(QuantitySelectorComponent, {
      componentProperties: { value: 5, max: 10 },
    });

    cy.get('[data-testid="increment"]').click();
    cy.get('[data-testid="quantity-value"]').should('have.text', '6');
  });
});

2.7 — Playwright: Installation und Konfiguration

npm init playwright@latest
# Installiert die benötigten Browser (Chromium, Firefox, WebKit)
npx playwright install
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: [['html', { open: 'never' }], ['github']],
  use: {
    baseURL: 'http://localhost:4200',
    trace: 'on-first-retry', // zeichnet eine Trace nur auf, wenn ein Test fehlschlägt und wiederholt wird
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
  ],
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:4200',
    reuseExistingServer: !process.env.CI,
  },
});

2.8 — Erster Playwright-Test: derselbe Login-Flow

// e2e/auth/login.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Login', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
  });

  test('effettua il login con credenziali valide', async ({ page }) => {
    await page.getByTestId('email-input').fill('utente@example.com');
    await page.getByTestId('password-input').fill('PasswordSicura123!');
    await page.getByTestId('login-submit').click();

    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.getByTestId('welcome-message')).toContainText('Bentornato');
  });

  test('mostra un errore con credenziali non valide', async ({ page }) => {
    await page.getByTestId('email-input').fill('utente@example.com');
    await page.getByTestId('password-input').fill('sbagliata');
    await page.getByTestId('login-submit').click();

    await expect(page.getByTestId('login-error')).toBeVisible();
    await expect(page.getByTestId('login-error')).toContainText('Credenziali non valide');
  });
});

2.9 — Locators, Auto-Waiting und Network Mocking

Playwright verwendet Locators, "lazy" Objekte, die sich vor jeder Aktion automatisch neu mit dem DOM verbinden, mit integriertem Auto-Waiting: Es ist kein sleep oder explizites Warten nötig — Playwright wartet automatisch, bis das Element sichtbar, aktiviert und stabil ist, bevor es damit interagiert.

// e2e/checkout/checkout-flow.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Flusso di checkout', () => {
  test.beforeEach(async ({ page }) => {
    await page.route('**/api/cart', route =>
      route.fulfill({ path: 'e2e/fixtures/cart-with-items.json' }),
    );
    await page.route('**/api/orders', route =>
      route.fulfill({
        status: 201,
        json: { orderId: 'ORD-9981', status: 'confirmed' },
      }),
    );
    await page.goto('/checkout');
  });

  test('completa un ordine e mostra la conferma', async ({ page }) => {
    await page.getByTestId('shipping-name').fill('Mario Rossi');
    await page.getByTestId('shipping-address').fill('Via Roma 1, Milano');

    const [orderRequest] = await Promise.all([
      page.waitForRequest('**/api/orders'),
      page.getByTestId('place-order').click(),
    ]);

    expect(orderRequest.postDataJSON()).toMatchObject({ shippingName: 'Mario Rossi' });
    await expect(page.getByTestId('order-confirmation')).toContainText('ORD-9981');
  });
});

2.10 — Parallele Cross-Browser-Ausführung und der Trace Viewer

Eine der Stärken von Playwright ist die native parallele Ausführung auf Chromium, Firefox und WebKit ohne zusätzliche Infrastruktur (Grid, Cloud-Anbieter). Schlägt ein Test in der CI fehl, erlaubt die aufgezeichnete Trace, die gesamte Ausführung Schritt für Schritt zu überprüfen — DOM-Snapshot, Netzwerk, Konsole — wie ein interaktives Video.

# Führt die Suite auf allen konfigurierten Browsern parallel aus
npx playwright test

# Führt nur auf Chromium mit interaktiver Debug-UI aus
npx playwright test --project=chromium --ui

# Analysiert die Trace eines in der CI fehlgeschlagenen Tests
npx playwright show-trace trace.zip

2.11 — Cypress vs Playwright: Vergleichstabelle

MerkmalCypressPlaywright
ArchitekturLäuft innerhalb des Browsers (gleicher Event Loop wie die App)Steuert den Browser von außen über ein Protokoll (CDP/WebSocket)
Unterstützte BrowserChrome, Edge, Firefox, ElectronChromium, Firefox, WebKit (also auch Safari)
Multi-Tab / Multi-DomainHistorisch eingeschränkte UnterstützungNativ, ohne Workarounds
Parallele AusführungErfordert Cypress Cloud (kostenpflichtig) oder manuelles ShardingNativ, kostenlos, in die Config integriert
Typische GeschwindigkeitGutGenerell höher, besonders in CI
Debug-WerkzeugeTime-Travel-Debugging in der interaktiven UITrace Viewer + UI Mode
Component TestingJa, ausgereiftJa (Playwright Component Testing, neuer)
LernkurveSehr sanft, sehr lesbare APIEtwas breiter (async/await überall)

In der Praxis: Ist das Team bereits an Cypress gewöhnt und erfordert das Projekt kein Safari/WebKit, bleibt Cypress dank seiner Developer Experience eine hervorragende Wahl. Wird echte Multi-Browser-Abdeckung (inklusive Safari) und native Parallelisierung ohne Zusatzkosten benötigt, ist Playwright heute die solidere Wahl für neue Projekte.

2.12 — CI/CD-Integration mit GitHub Actions

# .github/workflows/e2e-playwright.yml
name: E2E Tests (Playwright)

on:
  pull_request:
    branches: [main, develop]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx playwright install --with-deps

      - name: Build applicazione
        run: npm run build

      - name: Esegui test E2E
        run: npx playwright test

      - name: Carica report HTML come artifact
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14
# .github/workflows/e2e-cypress.yml (equivalente con Cypress)
name: E2E Tests (Cypress)

on:
  pull_request:
    branches: [main, develop]

jobs:
  cypress:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: cypress-io/github-action@v6
        with:
          build: npm run build
          start: npm run start
          wait-on: 'http://localhost:4200'
          browser: chrome

Ein oft übersehenes Detail: E2E-Tests nur bei Pull Requests auszuführen, die Frontend-Code betreffen (über paths: im Trigger), vermeidet die Verschwendung von CI-Minuten bei Änderungen, die z. B. nur die Dokumentation oder das Backend betreffen.

2.13 — Visual Regression Testing mit Playwright

Über funktionale Assertions hinaus kann Playwright Screenshots erfassen und sie automatisch mit einer auf der Festplatte gespeicherten Referenzversion vergleichen — nützlich, um rein visuelle Regressionen (ein sich ändernder Abstand, eine falsche Farbe) zu erkennen, die keine textuelle Assertion erfassen würde.

// e2e/visual/product-card.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Regressione visiva — ProductCard', () => {
  test('la card prodotto corrisponde allo screenshot di riferimento', async ({ page }) => {
    await page.goto('/prodotti/tastiera-meccanica');
    const card = page.getByTestId('product-card');

    // Der erste Durchlauf erzeugt den Referenz-Screenshot (--update-snapshots);
    // spätere Durchläufe schlagen fehl, wenn Pixel-Unterschiede über dem Schwellenwert liegen.
    await expect(card).toHaveScreenshot('product-card-baseline.png', {
      maxDiffPixelRatio: 0.02,
    });
  });

  test('la card in stato "esaurito" ha un badge visivamente distinto', async ({ page }) => {
    await page.goto('/prodotti/tastiera-meccanica?stock=0');
    await expect(page.getByTestId('product-card')).toHaveScreenshot('product-card-sold-out.png');
  });
});
# Aktualisiert die Referenz-Screenshots nach einer beabsichtigten UI-Änderung
npx playwright test --update-snapshots

# Speichern Sie in CI immer die generierten Diffs als Artifact für die visuelle Überprüfung

Faustregel: Der Schwellenwert maxDiffPixelRatio sollte so kalibriert werden, dass kleine Anti-Aliasing-Unterschiede zwischen verschiedenen Umgebungen (lokal vs. CI) toleriert werden, sonst wird der Test aus Gründen flaky, die nichts mit einem echten visuellen Bug zu tun haben.

Teil 3: Debugging-Tricks mit Angular DevTools

3.1 — Installation und erster Start

Angular DevTools ist eine offizielle Erweiterung für Chrome und Firefox, die ein eigenes Panel zu den Browser-Entwicklertools hinzufügt. Nach der Installation über den Chrome Web Store erscheint ein "Angular"-Tab in den DevTools (F12) jeder Seite, die eine Angular-Anwendung im Development-Modus ausführt (in der Produktion, mit aktivem enableProdMode(), werden die benötigten Metadaten aus Performance- und Sicherheitsgründen entfernt).

3.2 — Component Explorer: den Komponentenbaum inspizieren

Der Tab Components zeigt den gesamten Baum der gerenderten Komponenten, ähnlich dem DOM-Baum, aber auf Ebene der Angular-Komponenten statt HTML-Tags. Durch Auswahl einer Komponente lässt sich in Echtzeit inspizieren:

  • Die aktuellen Werte aller @Input() und der als Input übergebenen Signals.
  • Der interne Zustand der Komponente (Properties, Signals, Computeds).
  • Die Eltern-/Kind-Hierarchie der Komponenten, um zu verstehen, woher ein unerwarteter Wert stammt.

Es ist außerdem möglich, den Wert einer Property direkt im Panel zu ändern und die Komponente sofort neu rendern zu sehen — äußerst nützlich, um einen Grenzfall zu reproduzieren (z. B. eine leere Liste, einen negativen Preis), ohne die Daten an der Quelle ändern zu müssen.

3.3 — Der Injector Tree: verstehen, woher ein Service kommt

Der Tab Injector Tree visualisiert die Hierarchie der Dependency-Injection-Injectors der Anwendung — nützlich zur Diagnose von Bugs der Art "Warum erhält diese Komponente eine andere Instanz des Service als die daneben?", typischerweise verursacht durch einen auf Komponentenebene statt root deklarierten Provider, der eine isolierte Instanz für diesen Teilbaum erzeugt.

3.4 — Der Profiler: einen Change-Detection-Zyklus aufzeichnen

Der Tab Profiler ist das mächtigste Werkzeug für das Performance-Debugging. Durch Klicken auf "Start recording" und Interaktion mit der App (Klicks, Scrollen, Tippen) zeichnet Angular DevTools jeden Change-Detection-Zyklus auf und erzeugt ein Balkendiagramm — ähnlich einer Flame Graph — bei dem jeder Balken eine Komponente darstellt und seine Höhe die Zeit, die für die Prüfung ihrer Bindings aufgewendet wurde.

// Typisches Szenario zur Diagnose mit dem Profiler:
// eine Listenkomponente, die bei jedem Tastenanschlag in einem Suchfeld neu rendert,
// obwohl sich die Listendaten nicht geändert haben.

@Component({
  selector: 'app-product-list',
  standalone: true,
  // ❌ Ohne OnPush prüft Angular diese Komponente bei JEDEM CD-Zyklus
  // der gesamten Anwendung, auch bei Tastenanschlägen in unabhängigen Komponenten.
  template: ``,
})
export class ProductListComponent {
  @Input() products: Product[] = [];
}

Im Profiler äußert sich das als ein breiter, wiederholter Balken für ProductListComponent bei jedem einzelnen Input-Event, selbst wenn der Nutzer nur in ein Feld tippt, das nichts mit der Produktliste zu tun hat.

3.5 — Von der Diagnose zur Behebung: ChangeDetectionStrategy.OnPush

@Component({
  selector: 'app-product-list',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (p of products(); track p.id) {
      
    }
  `,
})
export class ProductListComponent {
  products = input([]);
}

Mit OnPush prüft Angular die Komponente nur, wenn: (1) sich ein @Input/Signal-Input per Referenz ändert, (2) ein DOM-Event darin auftritt, oder (3) sie explizit mit markForCheck() markiert wird. Wiederholt man die Aufnahme im Profiler nach dieser Änderung, verschwindet der Balken von ProductListComponent beim Tippen im Suchfeld — der visuelle Beweis, dass die Optimierung funktioniert hat.

3.6 — trackBy (bzw. track in der neuen @for-Syntax) für große Listen

Eine weitere häufige, im Profiler sichtbare Ursache von Engpässen: Listen, die bei jedem Update vollständig neu gerendert werden (zerstört und neu erstellt), statt nur die tatsächlich geänderten Elemente zu aktualisieren.


@for (product of products(); track $index) {
  
}


@for (product of products(); track product.id) {
  
}

Im Profiler zeigt sich der Effekt von track $index bei einer neu sortierten Liste (z. B. nach einer Sortieroperation) als eine hohe Anzahl zerstörter und neu erstellter ProductCardComponent-Instanzen, statt einer einfachen Neupositionierung — ein klares Signal, dass der gewählte track die Elemente nicht korrekt identifiziert.

3.7 — Angular DevTools mit dem Performance-Panel von Chrome kombinieren

Angular DevTools zeigt hervorragend, welche Komponenten Angular prüft, geht aber nicht ins Detail darüber, was auf Ebene der JavaScript-Engine passiert (Garbage Collection, Layout Thrashing, Long Tasks). Für eine vollständige Analyse zeichnet man eine parallele Session im nativen Performance-Tab von Chrome DevTools auf:

  • Long Tasks (rote Balken über 50ms) deuten auf synchrone Arbeit hin, die den Main Thread blockiert — oft ein zu schwerer Change-Detection-Zyklus.
  • Der Abschnitt Layout / Reflow zeigt ineffizient abwechselnde DOM-Lese-/Schreibvorgänge an (z. B. das Lesen von offsetHeight in einer Schleife, die anschließend Styles schreibt).
  • Der Memory-Graph über die Zeit hilft, Memory Leaks zu finden — typischerweise nie abbestellte RxJS-Subscriptions in wiederholt zerstörten und neu erstellten Komponenten (z. B. in einer Route, die der Nutzer häufig erneut öffnet).
// Häufige Ursache für Memory Leaks: eine manuelle Subscription, die nie abbestellt wird
export class DashboardComponent implements OnInit {
  ngOnInit(): void {
    // ❌ Jedes Mal, wenn die Komponente neu erstellt wird, sammelt sich eine neue Subscription an
    interval(5000).subscribe(() => this.refreshStats());
  }
}

// ✅ Korrigiert mit takeUntilDestroyed — die Subscription wird automatisch
//    bereinigt, wenn Angular die Komponente zerstört
export class DashboardComponent implements OnInit {
  private readonly destroyRef = inject(DestroyRef);

  ngOnInit(): void {
    interval(5000)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(() => this.refreshStats());
  }
}

3.8 — Zusätzliche Console-Tricks: console.table, console.time, debugger

Nicht jedes Debugging erfordert dedizierte Werkzeuge. Einige native, oft vergessene Console-Methoden beschleunigen die Analyse komplexer Daten erheblich:

// console.table: zeigt Arrays von Objekten als lesbare Tabelle,
// viel nützlicher als console.log zum Debuggen von Datenlisten
console.table(this.products());

// console.time / console.timeEnd: misst schnell die Dauer eines Blocks,
// ohne externe Profiling-Werkzeuge importieren zu müssen
console.time('calcolaTotaleCarrello');
const total = this.calculateTotal();
console.timeEnd('calcolaTotaleCarrello'); // gibt aus: calcolaTotaleCarrello: 2.341ms

// console.trace: gibt den vollständigen Call Stack aus — sehr nützlich, um zu verstehen,
// VON WO eine Funktion aufgerufen wird, die von zu vielen Stellen im Code aufgerufen wird
someSharedUtilityFunction(): void {
  console.trace('someSharedUtilityFunction chiamata da:');
}

// Die debugger-Anweisung unterbricht die Ausführung genau wie ein manuell gesetzter
// Breakpoint, lebt aber im Quellcode — nützlich für seltene Bedingungen
if (order.total < 0) {
  debugger; // der Browser hält hier NUR an, wenn die DevTools geöffnet sind
}

Ein letzter praktischer Tipp: Bei bedingten Breakpoints in Chrome DevTools (Rechtsklick auf die Zeilennummer → "Add conditional breakpoint") kann man einen Ausdruck wie product.price < 0 eingeben, wodurch die Ausführung nur angehalten wird, wenn die Bedingung wahr ist, statt Dutzende Male in einer Schleife auf "continue" klicken zu müssen.

3.9 — Heap Snapshot: Memory Leaks präzise aufspüren

Wenn der Profiler von Angular DevTools eine Anwendung zeigt, die nach mehreren Minuten Nutzung zunehmend langsamer wird (klassisches Memory-Leak-Symptom), ist der nächste Schritt der Memory-Tab von Chrome DevTools:

  1. Navigieren Sie die App in einen "sauberen" Zustand (z. B. ein leeres Dashboard) und zeichnen Sie dann einen ersten Heap Snapshot auf.
  2. Führen Sie die verdächtige Aktion wiederholt aus (z. B. öffnen und schließen Sie einen Dialog 10 Mal).
  3. Erzwingen Sie eine manuelle Garbage Collection (Papierkorb-Symbol) und zeichnen Sie einen zweiten Heap Snapshot auf.
  4. Verwenden Sie die "Comparison"-Ansicht zwischen den beiden Snapshots: Objekte, deren Anzahl weiter steigt (z. B. Instanzen von DialogComponent, die eigentlich zerstört sein sollten), sind das Symptom des Leaks.
// Typische, in einem Comparison-Heap-Snapshot erkennbare Ursache:
// ein benutzerdefinierter EventEmitter oder eine RxJS-Subscription, die von einem
// Singleton-Service am Leben gehalten wird und ihrerseits eine Referenz auf die
// Komponente hält und so die Garbage Collection verhindert.

@Injectable({ providedIn: 'root' })
export class NotificationBusService {
  private readonly _messages = new Subject();
  readonly messages$ = this._messages.asObservable();
}

// ❌ Die Komponente abonniert den Singleton-Service, meldet sich aber nie ab:
// jede Instanz der Komponente bleibt für immer am Subject "hängen".
export class ToastComponent implements OnInit {
  constructor(private bus: NotificationBusService) {}
  ngOnInit(): void {
    this.bus.messages$.subscribe(msg => this.show(msg));
  }
}

In einer Anwendung, in der die Komponente ToastComponent wiederholt erstellt und zerstört wird (z. B. innerhalb einer Route, die der Nutzer oft besucht), akkumuliert diese eine nicht abbestellte Subscription eine Referenz pro jemals erstellter Instanz — genau das Muster, das ein Comparison-Heap-Snapshot sichtbar macht, mit einer Anzahl von "Detached"-Instanzen, die stetig wächst und nie wieder auf null zurückgeht.

Fazit: Ein Mehrschichtiges Sicherheitsnetz Aufbauen

Keines dieser Werkzeuge ersetzt die anderen: Schnelle Unit-Tests mit Jest oder Jasmine/Karma geben bei jedem Speichern sofortiges Vertrauen in jede einzelne Codeeinheit; E2E-Tests mit Cypress oder Playwright validieren, dass kritische Abläufe (Login, Checkout, Suche) vor dem Release wirklich End-to-End funktionieren; Angular DevTools kommt zum Einsatz, wenn man verstehen muss, warum etwas langsam ist oder sich unerwartet verhält — etwas, das kein automatisierter Test allein vollständig diagnostizieren kann.

Die Kombination dieser drei Ebenen — multipliziert mit einer CI, die sie bei jedem Pull Request automatisch ausführt — ist das, was ein Angular-Projekt von "funktioniert auf meinem Rechner" in ein Produkt verwandelt, an dem das Team mit Vertrauen refactoring kann, weil es weiß, dass eine Regression erkannt wird, bevor sie die Nutzer erreicht.

  • Minimale Checkliste für ein ausgereiftes Angular-Projekt:
  • ✅ Unit-Tests für jeden Service mit nicht-trivialer Logik, mit in der CI konfigurierter Mindest-Coverage.
  • ✅ E2E-Tests auf den 3-5 für das Business wirklich kritischen Abläufen (nicht auf jeder einzelnen Seite).
  • ChangeDetectionStrategy.OnPush als Standard für neue Komponenten.
  • ✅ Eine Profiling-Session mit Angular DevTools vor jedem wichtigen Release auf den Seiten mit hohem Traffic.

💬 Leser-Notizen

0 Notizen

Notiz schreiben

Teile deine Meinung, einen Vorschlag oder ein Kompliment

Neueste Notizen

Noch keine Notizen. Sei der Erste, der kommentiert!