Testing im Frontend - Ein Erfahrungsbericht zu Komponenten-, Integrations- und End-to-End-Tests
von Thomas CzogalikDie meisten Softwareentwickler*innen kommen im Laufe ihrer Karriere mit dem Thema "automatisiertes Testen" in Berührung. Oft beginnt diese Reise mit Backend-Anwendungen. Als Frontend- oder Full-Stack-Entwickler*in stellt man sich dann die Frage, wie es mit den Tests im Frontend aussieht - wie teste ich da und welche Aspekte sollten dabei im Fokus stehen? Das galt auch für alle meine bisher begleiteten Projekte. Während des Projekts wurde immer wieder die Frage aufgeworfen: Wie, was und warum sollten wir testen? Anfangs beschränkte sich diese Diskussion meist auf den Projektstart. Doch mit zunehmendem Wissen und Erfahrung kam auch während der Projektlaufzeit die Frage nach der Teststrategie auf. In diesem Erfahrungsbericht möchte ich diese Diskussionen zusammenfassen und einen Überblick darüber geben, was wir in dieser Zeit gelernt haben.
Warum überhaupt testen
In meinen bisherigen Full-Stack-Projekten war, für Backend-Anwendungen schnell geklärt ob und wie Tests geschrieben werden sollten. Die meisten hatten bereits Tests implementiert und waren sich einig, dass unser Businesscode getestet sein sollte. Diese Einigkeit erstreckte sich auch auf das Frontend. Die Gründe dafür sind vielfältig, beispielsweise um Fehler zu vermeiden oder um ein bestimmtes Qualitätsniveau zu erreichen. Das Frontend fungiert als Schnittstelle zwischen Benutzer und Backend-Anwendung, daher ist es für Entwickler*innen wichtig zu wissen, ob ein Benutzer seine Aufgaben weiterhin erledigen kann, wenn Änderungen vorgenommen werden. Zudem möchte niemand die ganze Zeit die Benutzeroberfläche manuell durchklicken.
Was wollen wir testen
Die nächste Frage, die wir klären mussten, war, was wir eigentlich testen wollen. Sollten es Unit-Tests sein? Was sind im Frontend Units? Black- oder Whitebox-Tests? Sollten Integrationstests geschrieben werden? Welche Units sollten dabei zusammengefasst werden? Oder sollten wir nur End-to-End-Tests durchführen? Und wenn ja, sollte das Backend gemockt werden? Die Liste der Fragen und Begriffe schien endlos. In solchen Momenten entschieden wir uns für einen sogenannten "Spike", einen explorativen Ansatz. Für diesen Artikel habe ich ein kleines Repository erstellt, das mit Angular, Jest und Cypress zeigt, wie die genannten Ansätze umgesetzt werden können und um unsere Disskussionen zu veranschaulichen. Dabei habe ich eine einfache Zähler-App erstellt, die den aktuellen Zählerstand anzeigt und mit einem Button erhöht oder verringert. Das gesamte Repository findet ihr hier. Die Testbeispiele können auf beliebige andere Frontend-Frameworks angewendet werden, und die verwendeten Test-Libraries sind ebenfalls flexibel einsetzbar.
Komponententests / Unit Tests
In den meisten Frontend-Frameworks stellt eine Komponente eine eigenständige und wiederverwendbare Einheit der Benutzeroberfläche dar. Sie kombiniert HTML, CSS und JavaScript/TypeScript, um Elemente der Benutzeroberfläche darzustellen. So auch in Angular. Daneben gibt es in Angular weitere abgeschlossene Units wie Services, Pipes, etc., die hier jedoch nicht näher betrachtet werden. Die Komponente, die für das Hoch- und Runterzählen zuständig ist, sieht wie folgt aus:
@Component({
// (1)
template: `
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
<p data-testid="value">Current Count: {{ count() }}</p>
`,
})
// (2)
export class CounterComponent {
count = signal(0);
decrement() {
this.count.update(value => value - 1);
}
increment() {
this.count.update(value => value + 1);
}
}
Diese Komponente besteht aus einem HTML-Template (1) und einem TypeScript-Teil (2). Ein Button zum hochzählen, ein Button zum runterzählen und eine Zähleranzeige. Der aktuelle Zustand wird in der Variable count festgehalten (die ein Signal ist). Jeder Button ist an eine Methode gebunden, sodass ein Klick die jeweilige Methode aufruft. Jetzt können wir die Komponente aus verschiedenen Perspektiven testen.
Whitebox
Hier schauen wir direkt in die Komponente. Wir betrachten die öffentlichen Methoden und Felder. Dieser Ansatz erinnert stark an unsere Backend-Tests. Eine Klasse, X öffentliche Methoden, und für jede Methode wird mindestens ein Test geschrieben. Bei mehreren Pfaden gibt es entsprechend weitere Tests. In unserem Beispiel könnten die Tests mit Jest so aussehen:
it('should increment', () => {
// (1)
expect(counterComponent.count()).toEqual(0);
// (2)
counterComponent.increment();
// (3)
expect(counterComponent.count()).toEqual(1);
});
it('should decrement', () => {
// (1)
expect(counterComponent.count()).toEqual(0);
// (2)
counterComponent.decrement();
// (3)
expect(counterComponent.count()).toEqual(-1);
});
- Überprüfe, ob der Zustand unserer Zählervariable 0 beträgt.
- Rufe die jeweilige öffentliche Methode auf.
- Überprüfe, ob sich der Zustand unserer Zählervariable verändert hat.
Mit dieser Methode erreichen wir eine 100%ige Testabdeckung.
Profi Tipp: Innerhalb der Komponente werden Angular Signals eingesetzt. Obwohl die Vorgehensweise auf den ersten Blick imperativ erscheint, geschehen viele reaktive Prozesse im Hintergrund. Falls jemand noch keine Erfahrung mit Signals hat, mit Observables arbeitet oder sich generell fragt, wie man RxJs testen kann, empfehle ich diesen Artikel.
Blackbox
Als wir mehr Erfahrung gesammelt haben, fiel uns auf, dass bei der Whitebox-Methode die Benutzersicht fehlt. Ein Benutzer ruft niemals eine dieser Methoden direkt auf. Er wird einen der beiden Buttons klicken. Dies wird durch den Whitebox-Test nicht abgedeckt. Es könnte sein, dass wir vergessen haben, die öffentlichen Methoden an den Button zu binden. In diesem Fall hätten wir zwar eine 100%ige Testabdeckung, aber 0% Funktionalität. Außerdem verändert sich nicht nur unsere Zählervariable bei einem Klick, auch die Zähleranzeige zeigt einen neuen Wert an. Auch das wird nicht getestet. Anstatt in die Komponente reinzuschauen, kann man die Komponente auch als Blackbox betrachten und bei den Tests auf die Elemente der Benutzeroberfläche zugreifen. In dem Beispiel sind das die beiden Buttons und die Anzeige, die den aktuellen Stand ausgibt. Um den Test-Setup lesbarer zu gestalten, wird hier die testing-library verwendet. Alternativ kann man auch mit Angular-Boardmitteln auf die Elemente zugreifen:
it('renders the current value and increment', () => {
// (1)
const incrementControl = screen.getByRole('button', {name: /increment/i});
let valueControl = screen.getByTestId('value');
// (2)
expect(valueControl).toHaveTextContent('Current Count: 0');
// (3)
fireEvent.click(incrementControl);
// (4)
expect(valueControl).toHaveTextContent('Current Count: 1');
});
- Finde den Inkrement-Button und die Zähleranzeige im DOM.
- Überprüfe, ob die Zähleranzeige zu Beginn auf 0 gesetzt ist.
- Simuliere einen Klick auf den Inkrement-Button mit der fireEvent-Funktion.
- Überprüfe, ob die Zähleranzeige nach dem Klick den Wert 1 anzeigt.
Durch diesen Ansatz erreichen wir eine 100%ige Testabdeckung und prüfen gleichzeitig, ob der Button mit der Methode verbunden ist. Außerdem wird die Verbindung zwischen unserer Zählerzustandsvariable und der Zähleranzeige getestet.
Egal ob White- oder Blackbox-Test, irgendwann stellte sich auch die Frage nach der Testgrenze. Wir haben zwar die Komponenten isoliert getestet. Das war aber eher unfreiwillig und lag daran, dass wir das gar nicht hinterfragt hatten und nicht wussten, dass es anders geht.
Da die 'CounterComponent' keine Kindkomponenten hat, stellt sich die Frage nach der Testgrenze hier nicht. Daher wechseln wir in ihre Elternkomponente, die 'AppComponent'.
Shallow vs. Deep-Rendering (Integrationstests)
Die 'AppComponent' zeigt den Titel der App an und enthält zusätzlich die 'CounterComponent':
<div>
<h1>{{ title }} app is running!</h1>
</div>
<app-counter></app-counter>
Komponententests sind in der Regel shallow, das bedeutet, sie rendern keine Kindkomponenten. Das heißt, dass wir in einem AppComponent-Test keinen Zugriff auf die Buttons und die Zähleranzeige haben. Entscheiden wir uns jedoch dafür, die Kindkomponenten zu rendern, sprechen wir von Deep Rendering und bewegen uns im Bereich der Integrationstests. In Angular muss dabei die Kindkomponente beim Test-Setup angegeben werden:
await render(AppComponent,
{
declarations:[CounterComponent]
}
);
Auch hier wird die testing-library zur Verbesserung der Lesbarkeit verwendet. Die zuvor gezeigten Tests aus der CounterComponent können eins zu eins in den AppComponent-Test übernommen werden. Falls Services, Pipes etc. genutzt werden, werden diese (in einem Integrationstest) direkt verwendet und nicht gemockt.
End-To-End
Die bisher gezeigten Testszenarien betrachten immer nur einen Teil der Anwendung, selbst wenn im Fall der Komponenten-Tests die Benutzerinteraktion einbezogen wird. Wenn man eine laufende Anwendung inklusive Benutzerinteraktion testen möchte, bleiben nur das manuelle Durchklicken oder automatisierte End-to-End-Tests, die die Anwendung starten und automatisch durchklicken. Dazu haben wir uns Cypress angeschaut. Die Tests ähneln vom Aufbau her den bereits gezeigten Blackbox-Tests der Komponenten:
it('should increment', () => {
// (1)
cy.get('[data-testid="value"]').contains("Current Count: 0")
// (2)
cy.get('app-counter > :nth-child(1)').click()
// (3)
cy.get('[data-testid="value"]').contains("Current Count: 1")
})
- Mithilfe eines HTML-Selektors wird die Zähleranzeige gefunden und auf ihren Initialwert geprüft.
- Der Inkrement-Button wird geklickt.
- Die Zähleranzeige wird überprüft.
In diesem Fall testen wir die laufende Anwendung mit echter Benutzerinteraktion, indem wir die richtigen Buttons klicken. Alle Komponenten werden gerendert, und alle weiteren Services, Pipes, etc. werden verwendet. Als Entwickler*in muss man sich nur entscheiden, ob ein Backend hochgefahren oder gemockt werden soll.
Unsere Erfahrungen
Nachdem man sich einen Überblick über die Teststrategien gemacht hat muss man entscheiden, welche man wählt oder kombiniert. In folgender Tabelle sind die Strategien nochmal gegenüber gestellt:
Teststrategie |
Fokus |
Beispiel |
Vor- und Nachteile |
Komponententests / White-Box-Test |
Direkter Blick auf die Komponente, isoliert getestet |
Testet öffentliche Methoden und Felder der Komponente. |
Vorteile: genaue Prüfung der internen Logik, Hohe Testabdeckung. Nachteile: Vernachlässigt Benutzersicht und Interaktion. |
Komponententests / Black-Box-Test |
Komponente wird von außen betrachtet |
Testet die Benutzerinteraktion mit der Komponente. |
Vorteile: Prüft neben der internen Logik auch die Benutzersicht und Interaktion. Nachteile: Kann Zeitaufwändig sein |
Integrationstests (Shallow vs. Deep Rendering) |
Testet das Zusammenspiel von Komponenten, inklusive Kindkomponenten (Deep Rendering) oder ohne (Shallow Rendering) |
Prüft, ob die Komponenten richtig zusammenarbeiten |
Vorteile: Berücksichtigt Interaktionen zwischen Komponenten. Nachteile: Kann nicht das gesamte UI-Verhalten testen. |
End-to-End-Tests (E2E) |
Testet die Anwendung als Ganzes mit realer Benutzerinteraktion |
Automatisierte Tests, die die Anwendung starten und durchklicken. |
Vorteile: Deckt das gesamte UI-Verhalten ab. Nachteile: Kann zeitaufwändig sein, ggfs. Abhängigkeit von Backend. |
Das Testen ist neben Qualitätssicherung auch Risikominimierung. Beispielsweise haben wir uns in den ersten Projekten immer gegen End-to-End-Tests entschieden und Whitebox-Tests geschrieben, da immer eine separate Testing-Abteilung involviert war, die selbst automatisierte End-to-End-Tests geschrieben hat. Wir haben kein großes Risiko gesehen, dass etwas kaputt gehen kann und wenn, doch dann bekommt man es während der Build-Phase mit. In späteren Projekten haben wir für sehr wichtige Workflows, dann doch ein paar End-to-End-Tests geschrieben um schneller Feedback zu bekommen. Immerhin ging es um die Kernfunktionalität unseres Produkts. Grundsätzlich haben wir bei den Komponententests Shallow Renderings betrieben, da wir unsere Komponenten isoliert testen wollten. Die meisten Diskussionen drehten sich dabei um die Entscheidung zwischen White- oder Blackbox-Tests. Am Anfang gewannen immer die Whitebox-Tests, weil das Testen dem aus dem Backend sehr nah kam und man schnell Entwickler*innen aufgleisen konnte. Langsam geht die Tendenz in Richtung der Blackbox-Tests, um die Benutzersicht mit einzubeziehen, dazu kommt hin und wieder ein End-To-End-Test.
Es ist wichtig zu betonen, dass alle diese Ansätze valide Teststrategien sind und jeder dazu dienen kann, die Qualität des Produkts zu erhöhen und das Risiko für Fehler zu minimieren. Wenn jedoch der Eindruck entsteht, dass die Tests keinen Mehrwert bringen, ist es ratsam, den aktuellen Ansatz zu überdenken und die Teststrategie gegebenenfalls anzupassen.
P.S.: Wer sich tiefer mit dem Testen in Angular beschäftigen möchte, empfehle ich außerdem: https://testing-angular.com/. Dort werden auch die Testmechanismen von Angular nochmal genau beschrieben.
P.P.S: Noch mehr zum Thema "Web" (Entwicklung) findet Ihr auf unserer Technologieseite "Web" - mit Angular, JavaScript, TypeScript, React, Redux und VUE.js