Zurück

Komponententests in Angular: Ein Leitfaden

von Markus Wiktorin

Aus der beliebten Reihe „Katastrophen des Arbeitsalltags“: Du hast gerade eine winzige Änderung an deiner Angular-App vorgenommen und plötzlich funktioniert irgendwas nicht mehr. Spätestens jetzt wird klar: Ohne ordentliche Tests fliegt dir deine Anwendung irgendwann um die Ohren. In diesem Artikel zeigen wir dir, wie dir Komponententests in Angular das Leben leichter machen. Lass uns in die Welt der Komponententests eintauchen und verstehen, warum Du sie brauchst, wenn Du richtig gute Software entwickeln willst.

Was ist eine Angular-Komponente?

Als kleine Auffrischung machen wir uns zunächst klar, aus welchen Bausteinen eine Angular-Komponente besteht:

  1. HTML: Das Template, welches die Struktur und den Inhalt der Benutzeroberfläche definiert.
  2. CSS: Die Styles, die das Aussehen und Layout der Komponente bestimmen.
  3. TypeScript: Die Logik, die das Verhalten der Komponente steuert.

Die Styles im CSS lassen wir bei unseren automatisierten Komponententests außen wir. Wir konzentrieren uns auf die Logik und das Verhalten im TypeScript-Code. Außerdem testen wir die Integration mit dem HTML-Template, um sicherzustellen, dass unser definiertes Verhalten richtig dargestellt wird.

Eine kleine Beispiel-Komponente

Um die Thematik greifbarer zu machen, schauen wir uns eine einfache CounterComponent an. Diese Komponente zeigt einen Zähler an und bietet zwei Buttons: Einen zum Erhöhen und einen zum Verringern des Zählerstandes.

import { Component, Input, Output, EventEmitter } from '@angular/core';

@Component({
	selector: 'app-counter',
	template: `
	    <div>
		    <p data-testid="counter-value">{{ counter }}</p>
		    <button (click)="increment()" data-testid="increment-btn">+</button>
		    <button (click)="decrement()" data-testid="decrement-btn">-</button>
		    <button (click)="clicked.emit()" data-testid="output-btn">Emit Output</button>
	    </div>
	`,
	standalone: true
})
export class CounterComponent {
	@Input() counter = 0;
	
	@Output() clicked = new EventEmitter<void>();
	
	increment() {
	    this.counter++;
	}
	
	decrement() {
	    this.counter--;
	}
}

Die Komponente hat also eine counter-Variable, die auch als Input annotiert ist und damit von außen gesetzt werden kann, und zwei Methoden: increment und decrement, die den Zählerstand erhöhen bzw. verringern.

Testaufbau mit TestBed

Um unsere Komponenten zu initialisieren und zu testen, bietet uns das Angular-Test-Framework das TestBed.

Für eine klassische Komponente (nicht standalone) sieht der Testaufbau so aus:

let fixture: ComponentFixture<CounterComponent>;
let component: CounterComponent;

beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [CounterComponent]
  }).compileComponents();

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

Hier definieren wir eine Testumgebung mit declarations, in der unsere Komponente deklariert und kompiliert wird. Diese Vorbereitung läuft asynchron, daher nutzen wir async und await.

Seit Angular 14 gibt es die Möglichkeit, Standalone-Komponenten zu verwenden, die etwas anders eingerichtet werden:

let fixture: ComponentFixture<CounterComponent>;
let component: CounterComponent;

beforeEach(() => {
  fixture = TestBed.createComponent(CounterComponent);
  component = fixture.componentInstance;
});

Da Standalone-Komponenten keine zusätzliche Konfiguration im TestBed erfordern, wird hier direkt die Komponente erstellt und die Instanz zugewiesen. Solange wir keine Abhängigkeiten der Komponente mocken möchten ist das Test-Setup von Standalone-Komponenten also viel einfacher als von klassischen Komponenten.

Test-IDs und ihre Vor- und Nachteile

Du hast vielleicht bemerkt, dass wir data-testid Attribute im HTML-Template verwenden. Diese Test-IDs sind besonders nützlich, da sie eine stabile Schnittstelle zum Testen bieten. Änderungen an CSS-Klassen oder Textinhalten beeinflussen die Tests nicht. Außerdem sind Tests leichter zu verstehen, weil sie klar angeben, welche Elemente getestet werden. Ob Ihr Test-IDs verwendet, solltet ihr allerdings innerhalb des Teams klären, denn es gibt auch Argumente gegen sie. Es kann mehr Arbeit bedeuten, data-testid Attribute einzufügen und zu verwalten. Abgesehen vom erhöhten Verwaltungsaufwand haben Test-IDs auch den Effekt, dass die Tests technischer sind und weniger aus der Nutzerperspektive testen.

Inputs testen

Ein häufiges Szenario ist das Setzen von Eingabewerten (Inputs). Nehmen wir mal an, wir möchten den Zähler initial auf einen bestimmten Wert einstellen und prüfen, ob er korrekt dargestellt wird. Wir haben zwei Möglichkeiten, wie wir den Input im Test setzen können:

component.counter = 5;                        // Variante 1
fixture.componentRef.setInput('counter', 5);  // Variante 2

fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="counter-value"]').textContent).toBe('5');

Hier geben wir den Wert von counter direkt ein und prüfen anschließend, ob der angezeigte Wert stimmt. Variante 1 ist die simpelste und vermutlich die am weitesten verbreitete. Weil die Variable public ist, können wir sie von außen einfach setzen. In Variante 2 nutzen wir das Angular fixture. Damit sind wir näher an der Realität, außerdem können wir hier - im Gegensatz zur ersten Variante - auch Signal-Inputs und Aliase testen.

Den Aufruf fixture.detectChanges() brauchen wir in beiden Fällen, damit das TestBed die ChangeDetection ausführt und die Änderungen prüfbar werden.

Outputs testen

Abgesehen von Inputs haben viele Komponenten auch Outputs. Hier ist ein Beispiel, wie wir diese testen können:

it('should emit output event', () => {
  jest.spyOn(component.clicked, 'emit');
  const button = fixture.nativeElement.querySelector('[data-testid="output-btn"]');
  button.click();
  fixture.detectChanges();
  expect(component.clicked.emit).toHaveBeenCalled();
});

In diesem Beispiel verwenden wir jest.spyOn, um das emit-Ereignis zu überwachen und zu prüfen, ob es korrekt ausgelöst wurde, wenn der Button angeklickt wird. Alternativ zu dieser Möglichkeit könnten wir auch eine Subscription auf component.clicked registrieren. Das ist etwas mehr Aufwand, allerdings näher an der Realität.

Wie auch bei den Inputs haben wir also die Wahl, auf welche Art und Weise wir unsere Tests schreiben. Innerhalb des Entwicklungsteams sollten wir uns auf einen einheitlichen Stil einigen, damit die Komplexität nicht zu groß wird, der Test-Code einheitlich bleibt und man die Tests schnell versteht.

@testing-library/user-event: Ein Must-Have

Wenn es darum geht, Benutzerinteraktionen zu simulieren, hat sich die Library @testing-library/user-event als besonders effektiv erwiesen. Diese Library bietet eine intuitive und realistische Möglichkeit, Aktionen wie Klicks, Eingaben und mehr zu simulieren.

Beispiel: Button-Klick

Schauen wir uns an, wie wir mit user-event einen Button-Klick simulieren:

import userEvent from '@testing-library/user-event';

it('should increment the counter', async () => {
  const user = userEvent.setup();
  const button = fixture.nativeElement.querySelector('[data-testid="increment-btn"]');
  await user.click(button);
  fixture.detectChanges();
  expect(fixture.nativeElement.querySelector('[data-testid="counter-value"]').textContent).toBe('1');
});

Mit userEvent.setup initialisieren wir die Benutzerinteraktionen und simulieren dann den Button-Klick. Das asynchrone Verhalten macht die Tests robuster gegenüber Timing-Problemen. Durch die Library werden die Browser-Events simuliert, die auch bei einem echten Klick durch den User auf den Button passieren. Wir kommen durch diese Library also mit relativ wenig Aufwand ziemlich nah an das, was in Wirklichkeit passiert.

Tests mit oder ohne Template?

Anfangs haben wir gesagt, dass wir uns bei den Komponententests auf das Verhalten im TypeScript und das HTML-Template konzentrieren. Das ist nicht die einzige Möglichkeit, denn wir können uns auch nur auf die Logik fokussieren und die Integration mit dem Template außen vor lassen. Beide Varianten haben ihre Vor- und Nachteile:

  • Mit Template: Diese Methode ist näher dran am realen Verhalten der Anwendung. Sie ermöglicht es uns, die Interaktion zwischen dem Template und der Logik umfassend zu testen. Der Nachteil ist, dass solche Tests oft langsamer und fehleranfälliger sein können.
  • Ohne Template: Diese Variante konzentriert sich auf die Logik der Komponente und ist daher schneller und weniger anfällig für Fehler. Sie testet gegen das öffentliche Interface der Komponenten-Klasse, ohne das HTML-Template und Rendering zu berücksichtigen. Der Nachteil ist, dass diese Tests weniger realistisch sind und möglicherweise nicht alle Probleme aufdecken, die in der echten Anwendung auftreten könnten.

Letztendlich hängt die Wahl der Methode von den Bedürfnissen des Teams und der eingesetzten Test-Strategie ab. Beide Ansätze haben ihre Berechtigung und können sich in verschiedenen Szenarien als vorteilhaft erweisen.

Wir hoffen, dieser Leitfaden hat dir geholfen, das Thema Komponententests in Angular besser zu verstehen. Happy Testing!🚀

Möchtest du noch tiefer in das Thema Komponententests eintauchen? Dann komm‘ zu unserem

Angular Testing Training

Hier lernst du alle Tricks und Kniffe rund ums Testen in Angular und wie du deine Teststrategie verbessern kannst. Weitere Infos und aktuelle Termine findet du hier:

Angular Testing Training Zurück