Von Unit-Tests und E2E-Tests mit Angular

Angular Testing

Angular Testing

Von Unit-Tests und E2E-Tests mit Angular

Angular Testing


Tests schaffen Sicherheit, dokumentieren und regen zum Nachdenken an. Das gilt vor allem, wenn Sie im Team über längere Zeit an einer Applikation arbeiten. Mit Angular als Framework für Ihre Applikation sind Sie jedoch auch für diese Herausforderung bestens gewappnet. Dabei setzt Angular auf verbreitete Standards und Bibliotheken und erweitert sie um frameworkspezifische Details, um Ihnen das Testen Ihrer Applikation so einfach wie möglich zu gestalten.

Leider werden viele Angular-Applikationen nicht oder nur unzureichend getestet. Das liegt zum einen an der Bequemlichkeit der Entwickler, zum anderen aber auch an einer gewissen Einstiegshürde. In diesem Artikel möchte ich Ihnen zeigen, wie Sie die Werkzeuge, die Ihnen Angular bietet, benutzen können, um Ihre Applikation abzusichern. Dabei werden Sie sehen, dass das Erzeugen von Tests keineswegs eine Strafaufgabe sein muss.

Grundlagen

Beim Thema Tests stolpern Sie recht schnell über das Konzept der Testpyramide. Das Konzept besagt, dass es verschiedene Arten von Tests gibt und Sie unterschiedlich viele Tests pro Kategorie benötigen. Die Basis der Testpyramide bilden Unit-Tests, die zahlenmäßig die meisten Tests stellen. Sie werden während der Entwicklung sehr häufig ausgeführt und sollten aus diesem Grund sehr wenig Laufzeit benötigen. Unit-Tests prüfen nur einzelne Codestücke, in den meisten Fällen handelt es sich dabei um Funktionen. Die nächsten beiden Ebenen, die Integrations- und Akzeptanztests, testen nicht mehr nur einzelne unabhängige Codefragmente, sondern größere Einheiten bis hin zur grafischen Oberfläche. Die Anzahl dieser Tests ist wesentlich geringer als die der Unit-Tests, allerdings ist die Laufzeit auch wesentlich länger, da die Tests das gesamte System einbeziehen.

Bei Angular-Applikationen unterscheidet man, ähnlich wie bei der Testpyramide, zwischen zwei Kategorien von Tests: Unit-Tests und End-to-End-Tests. Diese Unterscheidung wird nötig, da sie auf unterschiedliche Technologien setzen: Unit-Tests werden auf Basis von Jasmine und Karma entwickelt, End-to-End-Tests – auch kurz E2E-Tests – verwenden dagegen Protractor. In welchen Fällen setzen Sie nun auf Unit-Tests, und wann sollten Sie lieber einen E2E-Test erstellen? Wenn Sie tiefer in das Thema Testing eintauchen, werden Sie schnell merken, dass gerade Benutzerinteraktionen und Workflows recht umständlich mit Unit-Tests abzudecken sind. Das ist der klassische Anwendungsfall für E2E-Tests: Eine Tastatureingabe, ein Klick auf einen Button und dann warten, dass die Applikation mit der korrekten Ausgabe reagiert. Mit Unit-Tests dagegen sichern Sie einzelne Methoden ab: Liefert das Observable bei einer bestimmten Wertekonstellation eines injizierten Service die korrekten Werte, und wird bei einer Fehleingabe die korrekte Exception ausgelöst? All das sind Dinge, die Sie mit Unit-Tests abdecken.

Set-up

Das Aufsetzen einer Angular-Applikation gestaltet sich vergleichsweise aufwendig, sodass Sie dies in den seltensten Fällen selbst übernehmen, sondern stattdessen entweder auf vorgefertigte Templates oder andere Werkzeuge zurückgreifen sollten. Als sehr hilfreich hat sich in diesem Bereich das Angular CLI erwiesen. Mit diesem Kommandozeilenwerkzeug können Sie innerhalb kürzester Zeit mit der Entwicklung Ihrer Applikation starten. Neben dem Set-up von Angular und TypeScript übernimmt das Angular CLI auch die Installation und Konfiguration Ihrer Testumgebung. Um es auf Ihrem System zu installieren, führen Sie den Befehl npm install -g angular-cli aus. Im Anschluss daran können Sie das Werkzeug auf Ihrer Kommandozeile verwenden. Mit ng init in einem leeren Verzeichnis lassen Sie die Basisstruktur für Ihre Applikation erzeugen. Außerdem werden sämtliche Pakete heruntergeladen, die Sie üblicherweise für den Start einer neuen Angular-Applikation benötigen. Neben den Kernkomponenten von Angular werden auch Jasmine, Karma und Protractor installiert.

Das Angular CLI sorgt auch für eine einfache Testausführung: Sie können Ihre Unit-Tests von der Kommandozeile mit dem Befehl npm test ausführen. Diese als „npm Scripts“ bezeichneten Kommandos werden in der package.json-Datei Ihres Projekts in der Sektion „scripts“ festgelegt. Im Fall von npm test wird auf das Kommando ng test verwiesen – an dieser Stelle übernimmt das Angular CLI wieder die Kontrolle und führt die Tests aus. Die E2E-Tests Ihres Projekts führen Sie ebenfalls über die Kommandozeile mit der Eingabe npm run e2e aus.

Unit-Tests in Angular

Für Unit-Tests setzt Angular auf die bewährte Kombination von Jasmine und Karma. Jasmine ist ein sehr weit verbreitetes Testframework, das bereits in der ersten Version von Angular zum Einsatz kam. Bei der Erzeugung von Tests können Sie ohne Einschränkungen auf den kompletten Funktionsumfang von Jasmine zurückgreifen. Listing 1 zeigt Ihnen ein Beispiel für einen Test mit Jasmine, damit Sie sich mit den grundlegenden Elementen des Frameworks vertraut machen können. Die Beschreibung der einzelnen Komponenten können Sie Tabelle 1 entnehmen.

Listing 1: Test mit Jasmine

describe('Calculator', () => {
  let calc: Calculator;
 
  beforeEach(() => {
    calc = new Calculator();
  });
 
  it('should add 1 and 1 and return 2', () => {
    const result = calc.add(1, 1);
    expect(result).toEqual(2);
  });
});

Komponente

Beschreibung

beforeAll(), afterAll()

Set-up- und Tear-down-Routinen vor bzw. nach allen Tests

beforeEach(), afterEach()

Set-up- und Tear-down-Routinen vor bzw. nach jedem einzelnen Test

describe()

Testsuite zur Gruppierung von Tests

it()

Einzelner Testfall

expect().toEqual()

Assertion, Prüfung innerhalb eines Tests

Tabelle 1: Die wichtigsten Elemente von Jasmine

Die zweite Komponente, die Sie für die Ausführung von Unit-Tests benötigen, ist der Testrunner Karma. Er stellt die Infrastruktur zur Verfügung, auf der die Jasmine-Tests ausgeführt werden. Der wichtigste Teil von Karma ist der Server; dort werden ein oder mehrere Browser registriert. Der Server erhält eine Konfiguration und die Tests, die ausgeführt werden sollen, und schickt entsprechende Befehle an alle registrierten Browser, sodass diese die Tests ausführen. Die Ergebnisse werden an den Karma-Server zurückgesendet, der wiederum für die Darstellung der Ergebnisse sorgt. Führen Sie Ihre Tests mit dem Kommando npm test aus, werden sowohl Karma als auch der TypeScript-Compiler in einen watch-Modus versetzt. Das bedeutet, dass Ihr Quellcode bei jedem Speichern nach einer Änderung neu kompiliert und alle Unit-Tests automatisch ausgeführt werden. Dadurch erhalten Sie kontinuierlich Rückmeldung über den Zustand Ihrer Tests. Zunächst erzeugen wir nun Unit-Tests für verschiedene Bestandteile einer Angular-Applikation und widmen uns danach den E2E-Tests.

Komponenten testen

Die wichtigsten Bausteine einer Angular-Applikation sind unbestritten die Komponenten. Sie bestehen im Kern aus einer Komponentenklasse mit einem Decorator, der zusätzliche Metainformationen hinzufügt, einem HTML-Template, das für die Darstellung sorgt, und einem optionalen Style Sheet. Aus diesem Aufbau ergibt sich, dass Sie beim Testen einer Komponente nicht so vorgehen können, wie Sie es bei einfachen Klassen und Funktionen gewohnt sind. Da Angular selbst hier auch ein Stück beteiligt ist, müssen Sie auch beim Testen auf die Testing Utilities des Frameworks zurückgreifen, um sinnvolle Tests erzeugen zu können. Diese Hilfsmittel finden Sie im Modul @angular/core/testing.

Für den ersten Komponententest gehen wir von einer Task-Komponente als Beispiel aus. Diese Komponente verfügt über einen Titel, der den Task beschreibt, und einen Status, der angibt, ob der Task erledigt ist oder nicht. Außerdem weist die Komponentenklasse eine Toggle-Methode auf, die den Status umschaltet. Ein erster Test soll die Erstellung der Komponente absichern. Initial soll der Titel eine leere Zeichenkette und der Status den Wert „false“ aufweisen. Bevor Sie mit dem Testen der Komponente beginnen, müssen Sie sich zunächst darum kümmern, die Umgebung für den Test vorzubereiten. Diese Schritte führen Sie vor jedem Test durch, um sicherzustellen, dass er in einer sauberen Umgebung ausgeführt wird. Die richtige Stelle hierfür ist die beforeEach-Methode von Jasmine.

Das Set-up wird in zwei Schritte unterteilt: die Erstellung eines Testmoduls und die Instanziierung der Komponente. Der Grund für diese Zweiteilung ist, dass das Kompilieren von Komponenten ein asynchroner Prozess ist und er deshalb im async-Helper von Angular gekapselt werden muss. Die Erzeugung des Testmoduls erfolgt mit einem Aufruf der configureTestingModule-Methode. Dieses Modul stellt die Umgebung dar, in der die zu testende Komponente eingebunden wird; das Testmodul erfüllt damit den gleichen Zweck wie ein gewöhnliches NgModule in Ihrer Applikation. Hier können Sie unter anderem Ihre Komponenten als Declaration registrieren. Falls Sie auf die Integration Ihrer Komponente in das Testmodul verzichten, erhalten Sie bei der Ausführung Ihrer Tests die folgende Fehlermeldung: „Error: Cannot create the component TaskComponent as it was not imported into the testing module!“

Das Testmodul sorgt vor allem dafür, dass sich die Testumgebung vollständig unter Ihrer Kontrolle befindet und keine unerwarteten Seiteneffekte auftreten, die die Testausführung stören könnten. Verwenden Sie externe Templates und Style Sheets, müssen Sie auf Ihrem Testmodul noch die compileComponents-Methode aufrufen – sie sorgt dafür, dass die Templates und ­Style Sheets verarbeitet werden. Diese Schritte werden im normalen Applikationsbetrieb von Angular automatisch durchgeführt; im Testbetrieb müssen Sie sich selbst darum kümmern. Das hat den Hintergrund, dass Sie selbst bestimmen können, wann und in welcher Reihenfolge die Verarbeitungsschritte stattfinden. Dadurch können Sie jede einzelne Phase des Lebenszyklus Ihrer Applikation testen.

Mit der Konfiguration des Testmoduls haben Sie den ersten Schritt des Test-Set-ups erledigt. Für den eigentlichen Test benötigen Sie jedoch auch noch Zugriff auf eine konkrete Instanz Ihrer Komponente, die Sie mit der createComponent-Methode erzeugen. Beim Aufruf dieser Methode übergeben Sie die Klasse der Komponente, die Sie instanziieren möchten, und erhalten eine so genannte ComponentFixture als Ergebnis zurück. Mit diesem Objekt erhalten Sie mit der componentInstance Zugriff auf die Instanz der Komponente. Das DebugElement der Fixture gewährt außerdem Einblick in das Template der Komponente.

Wie bereits erwähnt, müssen Sie alles, was Angular Ihnen sonst abnimmt, selbst ausführen. Beim Testen einer Komponente ist eine der wichtigsten Operationen die Change Detection. Mit einem Aufruf von detectChanges auf der Fixture aktivieren Sie diese und sorgen so dafür, dass das Data Binding zwischen Komponentenklasse und Template aktiviert wird. Mit diesem Set-up können Sie nun die Eigenschaften und Methoden Ihrer Komponente ganz einfach über die Komponenteninstanz testen. Beispiele hierfür finden Sie in Listing 2.

Listing 2: Komponententest

describe('TaskComponent', () => {
  let component: TaskComponent;
  let fixture: ComponentFixture<TaskComponent>;
 
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ TaskComponent ]
    })
    .compileComponents();
  }));
 
  beforeEach(() => {
    fixture = TestBed.createComponent(TaskComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
 
  it ('should have an empty title and false status', () => {
    expect(component.title).toEqual('');
    expect(component.status).toEqual(false);
  });
 
  it('should toggle the status', () => {
    component.toggle();
    expect(component.status).toEqual(true);
  });
});

Die Eigenschaften und Methodenaufrufe Ihrer Komponente wirken sich häufig auf das Template Ihrer Komponente aus. Zur Überprüfung dieser Effekte müssen Sie nicht gleich einen E2E-Test schreiben, sondern können innerhalb eines Unit-Tests über das DebugElement auch Ihr Template testen. Über die Eigenschaft debugElement können Sie mit der query-Methode gezielt nach bestimmten Elementen im Template Ihrer Komponente suchen, wobei die By-Klasse zum Einsatz kommt. Mit ihrer Hilfe können Sie Elemente anhand von CSS-Selektoren auswählen.

Neben dieser wohl am häufigsten eingesetzten Methode können Sie Elemente außerdem anhand bestimmter Direktiven lokalisieren – oder Sie verwenden die all-Methode, um alle Elemente auszuwählen. So können Sie die title-Eigenschaft der Komponente aus unserem Beispiel nicht nur direkt auf der Komponenteninstanz prüfen, sondern auch die Repräsentation im Template. Das Ergebnis der query-Methode erlaubt Ihnen über das nativeElement Zugriff auf das HTML-Element, bei dem Sie beispielsweise den Textinhalt über die Eigenschaft textContent auslesen können (Listing 3). Wenn Sie zu Testzwecken den Wert einer Eigenschaft in Ihrer Komponente ändern, müssen Sie danach die Change Detection anstoßen. Erst danach ist die Änderung auch im Template wirksam.

Listing 3: Test des Templates

it('should contain the correct title', () => {
  component.title = 'Test';
  fixture.detectChanges();
  const debugElement = fixture.debugElement.query(By.css('.title'));
  const element = debugElement.nativeElement;
  expect(element.textContent).toEqual('Test');
});

Kommunikation zwischen den Komponenten testen

Durch den Komponentenansatz von Angular werden Applikationen als Bäume von Komponenten aufgebaut. Der Datenfluss erfolgt häufig über Inputs und Outputs. Bei einem Input werden Informationen über Property Binding an die Kindkomponente weitergereicht – in unserem Taskbeispiel bedeutet das, dass wir eine Liste von Tasks haben. Die Liste ist die Elternkomponente, die die Informationen über alle Tasks hält. Im Template der Elternkomponente iterieren Sie über alle Tasks und stellen pro Task die Kindkomponente dar. Sie ist dafür verantwortlich, dem Benutzer die entsprechenden Informationen anzuzeigen. Damit Sie die Taskinformationen nicht mehrfach vom Server holen müssen, erhält jede Taskkomponente die Informationen von der Listenkomponente. Ändert sich der Status eines Tasks, müssen Sie mit dieser Information ebenfalls umgehen. Da die Listenkomponente die Information vorhält, muss sie darüber informiert werden. Die Lösung besteht darin, sie über einen Output zu informieren. Outputs werden als Event Emitter umgesetzt, an die die Elternkomponenten entsprechende Event Handler binden.

Sowohl für das Testen von Inputs als auch für Outputs können Sie die Komponente entweder im Verbund mit einer anderen Komponente oder für sich alleine testen. In der ersten Variante erzeugen Sie eine Testkomponente, die die Rolle der eigentlichen Elternkomponente spielt; damit bleibt die Testumgebung kontrollierbar. Die zweite, etwas weniger aufwendige Variante besteht darin, dass Sie Input und Output simulieren. So kann ein Input einfach als Wertzuweisung an die Eigenschaft der Komponente umgesetzt werden. Diese Zuweisung sollte bereits vor dem ersten Aufruf der detectChanges-Methode erfolgen, da es ansonsten zu Fehlermeldungen kommen kann. Entweder Sie verlagern den Methodenaufruf in den Test oder Sie weisen den Input schon in der beforeEach-Methode zu. Für welche Variante Sie sich entscheiden, hängt vor allem davon ab, für wie viele Tests Sie diese Aktion durchführen und ob Sie verschiedene Ausprägungen testen möchten. Der Code im beforeEach ist für alle folgenden Tests allgemeingültig. Das heißt, wenn die Inputwerte variieren sollen, verlagern Sie sie am besten in die jeweiligen Tests (Listing 4).

Beim Output ist es meist so, dass Sie ihn an eine bestimmte Benutzerinteraktion koppeln. Im Taskbeispiel kann das beispielsweise der Klick auf einen Button sein, der dafür sorgt, dass der Status umgeschaltet wird. Im Test registrieren Sie sich zunächst beim Event Emitter und lösen dann den Klick auf das Buttonelement aus (Listing 5).

Listing 4: Inputtest

beforeEach(() => {
  fixture = TestBed.createComponent(TaskItemComponent);
  component = fixture.componentInstance;
  const task = new Task('Test', false);
  component.task = task;
  fixture.detectChanges();
});
 
it('should correctly handle input', () => {
  fixture.detectChanges();
  const debugElement = fixture.debugElement.query(By.css('.title'));
  const element = debugElement.nativeElement;
  expect(element.textContent).toEqual('Test');
});

Listing 5: Outputtest

it('should correctly handle output', () => {
  let taskStatus = false;
  component.toggled.subscribe((task) => taskStatus = task.status);
 
  const debugElement = fixture.debugElement.query(By.css('button'));
  debugElement.triggerEventHandler('click', null);
  expect(taskStatus).toBe(true);
});

Zunächst merken Sie sich den initialen Taskstatus in einer Variablen, danach registrieren Sie eine Callback-Funktion auf das Toggle-Event der Komponente, in der Sie den neuen Status setzen. Wird nun ein Klick auf den Button ausgelöst, soll sich der Status ändern. Über das debugElement können Sie nicht nur Elemente suchen und ihre Eigenschaften prüfen, sondern auch Events, wie beispielsweise einen Klick, auslösen. Hierfür verwenden Sie die triggerEventHandler-Methode. Dadurch wird der Event Emitter ausgelöst, der Status ändert sich und Sie können dies mit einer Assertion überprüfen. Damit ist auch Ihr Output abgesichert.

Service testen

Die Informationen über die Tasks, die dargestellt werden sollen, kommen vom Server. Sie können den HTTP-Service natürlich direkt in Ihre Komponente einbinden; eine weitaus mehr verbreitete Variante ist es aber, die Kommunikation in einem separaten Service zu kapseln. Das hat den Vorteil, dass Sie zusätzliche Logik dort implementieren und den Service an mehreren Stellen in Ihrer Applikation wiederverwenden können.

Als Nächstes lernen Sie, wie Sie generell Komponenten mit Abhängigkeiten testen können. Für dieses Szenario gehen wir zunächst von einem sehr einfachen Service aus, der ein Array von Tasks liefert. Einen solchen Service testen Sie allerdings nicht direkt in Verbindung mit der Komponente, sondern stellen lediglich ein Stub-Objekt zur Verfügung, das dieselben Schnittstellen bietet wie der Service, allerdings für den Testzweck besser kontrollierbar ist (Listing 6).

Listing 6: Test eines synchronen Service

describe('TaskListComponent', () => {
  let component: TaskListComponent;
  let fixture: ComponentFixture<TaskListComponent>;
 
  beforeEach(async(() => {
    const taskServiceStub = {
      get() {
        return [
          new Task('Test1', false),
          new Task('Test2', true)
        ];
      }
    };
 
    TestBed.configureTestingModule({
      declarations: [ TaskListComponent, TaskItemComponent ],
      providers: [
        {provide: TaskService, useValue: taskServiceStub}
      ]
    })
    .compileComponents();
  }));
 
  beforeEach(() => {
    fixture = TestBed.createComponent(TaskListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
 
  it('should have two children', () => {
    const debugElement = fixture.debugElement.queryAll(By.css('li'));
    expect(debugElement.length).toEqual(2);
  });
});

Dieser Test gestaltet sich etwas aufwendiger, da Sie in diesem Fall zunächst ein Double für die Kindkomponente und ein weiteres Stub-Objekt für den Service erzeugen müssen. Haben Sie diese beiden Strukturen in Ihr Testmodul integriert, ist der eigentliche Test ein Kinderspiel. Angular übernimmt die Dependency Injection automatisch anhand der Informationen, die über das Testmodul verfügbar sind. Rufen Sie nun in der ng­On­Init die get-Methode Ihres Service auf, werden nach dem detectChanges-Aufruf die beiden Kindelemente eingefügt. In Ihrem Test müssen Sie dann nur noch statt der bekannten query-Methode die queryAll-Methode verwenden, um alle li-Elemente in Ihrer Komponente zu finden. Nachdem Sie im Service zwei Taskelemente zurückgeben, müssen genau zwei li-Elemente auffindbar sein. Sollten Sie Zugriff auf den Service-Stub benötigen, können Sie dies entweder über TestBed.get(TaskService) oder über die injector.get-Methode des debugElements erreichen. Das direkte Modifizieren des Stub-Objekts erzeugt nicht den gewünschten Effekt.

Nun ist es in JavaScript in den seltensten Fällen so, dass Sie Informationen synchron bekommen – und genau das gilt auch für die meisten Services. Entweder liefern sie Ihnen ein Observable- oder ein Promise-Objekt zurück und erst zu einem späteren Zeitpunkt die Informationen, die Sie angefragt haben. Zur Lösung dieses Problems greifen Sie auf die Testdoubles von Jasmine zurück. Dabei handelt es sich um Wrapper-Objekte und -Funktionen, die sowohl zum Auslesen von Funktionsaufrufen (Spy) als auch zur Steuerung von Verhalten (Stubs) verwendet werden.

Sie holen sich also über den Injector eine Referenz auf den Service-Stub und legen einen Jasmine-Stub über die get-Methode des Service. Dieser Stub gibt eine Promise zurück, die sofort mit einem Array mit zwei Taskobjekten aufgelöst wird. Trotz dieser sofortigen Auflösung handelt es sich hierbei um eine asynchrone Operation. Das bedeutet, dass der bisherige Aufbau der Tests hier wirkungslos ist. Prüfen Sie mit einem synchronen Test, ob zwei li-Elemente existieren, schlägt er fehl, da noch keine Kindkomponente hinzugefügt wurde (Listing 7).

Listing 7: Test eines asynchronen Service

describe('TaskListComponent', () => {
  let component: TaskListComponent;
  let fixture: ComponentFixture<TaskListComponent>;
 
  beforeEach(async(() => {
    const taskServiceStub = {
      get() {}
    };
 
    TestBed.configureTestingModule({
      declarations: [ TaskListComponent, TaskItemComponent ],
      providers: [
        {provide: TaskService, useValue: taskServiceStub}
      ]
    })
    .compileComponents();
  }));
 
  beforeEach(() => {
    fixture = TestBed.createComponent(TaskListComponent);
    component = fixture.componentInstance;
 
    const tasks = [
      new Task('Test1', false),
      new Task('Test2', true)
    ];
    const serviceStub = fixture.debugElement.injector.get(TaskService);
    spyOn(serviceStub, 'get').and.returnValue(Promise.resolve(tasks));
    fixture.detectChanges();
  });
 
  it('should have two children', async(() => {
    fixture.detectChanges();
    fixture.whenStable().then(() => {
      fixture.detectChanges();
      const debugElement = fixture.debugElement.queryAll(By.css('li'));
      expect(debugElement.length).toEqual(2);
    });
  }));
});

Das bedeutet, dass Sie auf die Promise des Service warten müssen. Hier haben Sie wieder zwei Möglichkeiten: Entweder nutzen Sie die async- oder die fakeAsync-Funktion. Bei der ersten Methode wird eine asynchrone Testzone erzeugt. Die whenStable-Methode der Test-Fixture gibt Ihnen ein Promise-Objekt zurück, das aufgelöst wird, sobald alle Promises in der Zone aufgelöst sind. Ein Aufruf der detectChanges-Methode stellt sicher, dass alle Änderungen auch auf das Template angewendet werden. Danach können Sie Ihre Assertion durchführen.

Die fakeAsync-Funktion arbeitet sehr ähnlich wie die async-Funktion, nur, dass Ihr Code in diesem Fall linearer und einfacher gestaltet ist. Innerhalb der fakeAsync-Funktion können Sie die tick-Methode aufrufen, die dann, ähnlich wie die whenStable-Methode, dafür sorgt, dass der folgende Code erst ausgeführt wird, wenn alle Promises der Zone aufgelöst sind. Dann müssen Sie nur noch die Change Detection aktivieren und können ebenfalls Ihre Assertion formulieren.

Mit diesen Mitteln können Sie natürlich nicht nur Komponenten mit Abhängigkeiten testen, sondern auch Services, die wiederum andere Services als Abhängigkeiten haben. Die Funktionsweise ist hier gleich: Sie registrieren alle Services als Provider in Ihrem Testmodul und können den Service, den Sie als Abhängigkeit injecten, durch einen Stub ersetzen. Über den Injector können Sie die Serviceinstanz beeinflussen. Das Testen von Services und Pipes ist in der Regel auch um einiges einfacher als das Testen von Komponenten, da es keine Verbindungen zu Templates gibt und Sie sich auch nicht um die Change Detection kümmern müssen.

Pipes testen

Pipes können, wie auch Services, normalerweise wie ganz normale JavaScript-Objekte getestet werden: Sie rufen eine Funktion mit einem Wert auf und erwarten einen bestimmten Rückgabewert. Anhand einer einfachen Pipe zeige ich Ihnen, wie Sie einen solchen Test formulieren können. Nutzen Sie Angular-CLI, erhalten Sie auch in diesem Fall wieder Hilfe, indem das Werkzeug die erforderlichen Strukturen bereits für Sie erzeugt. Alles, was Sie hier tun müssen, ist, die Pipe zu instanziieren und ihre transform-Methode aufzurufen. Die Erzeugung des Objekts können Sie entweder direkt im Test durchführen oder, um Duplikate im Code zu vermeiden, in einer beforeEach-Routine.

Listing 8: Pipe testen

describe('UppercasePipe', () => {
  let pipe: UppercasePipe;
  beforeEach(() => {
    pipe = new UppercasePipe();
  });
 
  it('should turn all letters to uppercase', () => {
    const result = pipe.transform('hello World');
    expect(result).toEqual('HELLO WORLD';)
  });
});

Für einen einfachen Service sieht der Test ähnlich aus. Auch hier instanziieren Sie die Serviceklasse wie eine gewöhnliche JavaScript-Klasse und prüfen anschließend die Methoden des Objekts. Lediglich wenn es um Abhängigkeiten geht, die über die Dependency Injection von Angular eingefügt werden, müssen Sie wieder etwas tiefer in die Trickkiste greifen.

Direktiven testen

Tests für Direktiven ähneln gewöhnlichen Komponententests. Das einzige, was Sie beachten müssen, ist, dass Sie für den Test Ihrer Direktive eine Testkomponente verwenden müssen, um die Auswirkungen der Direktive zu prüfen. Diese Variante kennen Sie allerdings auch schon von den Input- und Outputtests. Sie erzeugen also eine einfache Testkomponente, wenden Ihre Direktive im Template an und können dann die Auswirkungen in Ihren Tests prüfen. Eine Erleichterung für Direktiventests bietet die bereits erwähnte By.directive-Methode. Mit dieser Methode können Sie Elemente lokalisieren, auf die eine bestimmte Direktive angewendet wurde.

E2E-Tests

Beim E2E-Testing prüfen Sie nicht mehr nur Einheiten wie Klassen oder einzelne Funktionen, sondern ganze Workflows in Ihrer Applikation. Im Gegensatz zu Unit-Tests setzt das voraus, dass Ihre Applikation auf Ihrem lokalen System oder einem Testsystem läuft. Auch die Strukturierung der Testdateien unterscheidet sich von den Unit-Tests: Die Dateien von Unit-Tests werden in Angular normalerweise bei den Dateien abgelegt, die Sie testen sollen. E2E-Tests werden in einem separaten Verzeichnis abgelegt, da sie meist nicht direkt einer bestimmten Datei zugeordnet werden können. Die Grundlage eines E2E-Tests bildet Protractor, ein Framework, das speziell für Angular entwickelt wurde und auf WebDriver aufbaut. WebDriver stellt die Infrastruktur für die Ausführung der Tests zur Verfügung und ist am ehesten mit Karma zu vergleichen: Bei beiden werden Browser an einer Serverkomponente registriert und Tests ausgeführt. Ihre Tests formulieren Sie in Protractor wie gewohnt in Jasmine-Syntax; Sie können also auf Set-up- und Tear-down-Routinen, Testsuites, Tests und Assertions zurückgreifen.

Ein typischer E2E-Test (Listing 9) ist so aufgebaut, dass Sie zu einer bestimmten Seite navigieren, den Ausgangszustand prüfen, mit der Seite interagieren und die Auswirkungen Ihrer Interaktion ebenfalls testen. Die hierfür erforderlichen Kommandos schreiben Sie normalerweise nicht direkt in Ihren Test, da ihn das recht unübersichtlich werden lässt; außerdem werden diese Kommandos meist häufiger benutzt. Aus diesem Grund haben sich die so genannten „Page Objects“, kurz PO, etabliert. Das sind einfache Klassen, die die häufigsten Kommandos zum Testen einer Seite beinhalten. Das betrifft sowohl das Navigieren zur Seite als auch das Auffinden bestimmter Elemente. Die PO-Klasse inkludieren Sie in Ihren Test, instanziieren das PO in der Set-up-Routine und verwenden seine Methoden in Ihren Tests (Listing 10).

Das wichtigste Element von Protractor ist das browser-Objekt, das zur Steuerung des registrierten Browsers dient. Hier können Sie beispielsweise mit der get-Methode zu einem bestimmten URL navigieren. Das element-Objekt entspricht der query-Methode des debugElements in Unit-Tests: Mit ihm können Sie in Kombination mit dem by-Objekt die Elemente Ihrer Applikation lokalisieren. Haben Sie die Referenz auf ein Element, können Sie beispielsweise mit den click- oder sendKeys-Methoden eine Benutzerinteraktion simulieren. Der Vorteil von Protractor ist, dass das Framework automatisch auf die Elemente wartet, mit denen Sie interagieren möchten. Sie müssen also nicht explizit angeben, wann Sie wie lange auf welche Elemente warten wollen.

Listing 9: E2E-Test

describe('List Page', function() {
  let page: ListPage;
 
  beforeEach(() => {
    page = new ListPage();
  });
 
  it('should have 3 task items', () => {
    page.navigateTo();
    expect(page.getTaskItems().count).toEqual(3);
  });
});

Listing 10: Page-Object-Klasse

export class ListPage {
  navigateTo() {
    return browser.get('/list');
  }
 
  getTaskItems() {
    return element.all(by.css('li'));
  }
}

Fazit

Mit den Hilfsmitteln, die Ihnen Angular bietet, ist das Schreiben von Tests kein Problem mehr. Sie können den Ablauf Ihrer Applikation bis ins kleinste Detail steuern und alle Abhängigkeiten durch Stub-Objekte ersetzen, was sowohl für die Laufzeit Ihrer Tests als auch die Stabilität von Vorteil ist. Bei Unit-Tests prüfen Sie lediglich einzelne unabhängige Einheiten Ihrer Applikation; in E2E-Tests interagieren Sie wie ein Benutzer mit Ihrer Applikation. Gerade um die korrekte Kommunikation zwischen Komponenten und konkrete Aktionen eines Benutzers zu testen, eignen sich E2E-Tests wesentlich besser als klassische Unit-Tests. E2E-Tests arbeiten auf einer Instanz Ihrer Applikation, die vom Angular-Front­end bis zum Backend auf dem Server reicht. Deshalb ist die Laufzeit der E2E-Tests normalerweise auch wesentlich länger als die von Unit-Tests.

Haben Sie sich erst einmal an die Werkzeuge gewöhnt, werden Sie sehen, dass auch testgetriebene Entwicklung mit Angular komfortabel möglich ist. Sie beschreiben die Bestandteile Ihrer Applikation wie Komponenten, Pipes oder Services in einem Test und implementieren dann gegen diesen. Bei dieser Vorgehensweise erhalten Sie sehr schnell Rückmeldung, falls Teile Ihrer Applikation durch eine Änderung nicht mehr funktionieren. Gerade für Refactorings ist das ein unverzichtbares Hilfsmittel.

springer_sebastian_sw.tif_fmt1.jpgSebastian Springer ist JavaScript-Entwickler bei MaibornWolff in München und beschäftigt sich vor allem mit der Architektur von client- und serverseitigem JavaScript. Sebastian ist Berater und Dozent für JavaScript und vermittelt sein Wissen regelmäßig auf nationalen und internationalen Konferenzen.

Sebastian Springer

Sebastian Springer ist JavaScript-Entwickler bei MaibornWolff in München und beschäftigt sich vor allem mit der Architektur von client- und serverseitigem JavaScript. Sebastian ist Berater und Dozent für JavaScript und vermittelt sein Wissen regelmäßig auf nationalen und internationalen Konferenzen.