Blog

Sanduhr

Angular-Komponententests – Zeit und Nerven sparen

Komponententests in Angular sind teuer und nehmen viel Zeit in Anspruch. Deshalb sollten die Testmodule so sparsam wie möglich aufgebaut sein. Durch die Erfahrungen in mehreren Projekten haben wir viele hilfreiche Tipps in Bezug auf Angular-Komponententests gesammelt und konnten so eine Zeitersparnis von insgesamt 90% erreichen. Diese Tipps und Erfahrungen möchten wir in diesem Beitrag gern mit euch teilen. So könnt ihr zukünftig nicht nur viel Zeit, sondern auch viele Nerven sparen.

Hintergrund

In einem unserer Projekte dauerte die Ausführung der Angular-Tests insgesamt 8-9 Minuten. Die Applikation hatte etwa 100 Komponenten und knapp 1000 Tests. Durch die lange Ausführungszeit der Tests verzögerte sich auch das automatische Deployment über die Jenkins-Pipeline und alle nachgelagerten Prozesse. Ein neuer Build konnte so erst knapp 15-20 Minuten nach der eigentlichen Änderung ausgeliefert werden. Aufgrund dieser erheblichen Verzögerungen entschieden wir uns, die Angular-Tests näher zu analysieren. Mithilfe der daraus gewonnenen Erkenntnisse konnten wir die Tests optimieren und die Ausführungszeit auf unter 1 Minute senken. Dadurch sparen wir pro Woche mehr als 6 Stunden und eine sehr große Menge an Entwickler-Nerven.


Tipps

Bevor wir mit den eigentlichen Tipps beginnen, möchten wir den Rahmen festlegen, in dem wir uns bewegen. Definieren wir dazu zunächst, warum die Komponente getestet werden soll. Das Ziel  ist es, die Funktionalitäten der Komponente zu testen, also einen isolierten Unit-Test durchzuführen. Häufig wird im Test-Modul aber mehr importiert, als für den Unit-Test nötig ist. Dadurch wird aus einem Unit-Test ganz schnell ein ungewollter Integrationstest. Das ist problematisch, da in Angular für jeden it-Block innerhalb des Tests das gesamte Test-Modul neu instanziiert wird. Je mehr man also im Test-Modul importiert, desto länger dauert auch der Komponententest. Das möchten wir verhindern, indem wir euch Tipps geben, eure Komponenten möglichst isoliert und damit auch schneller zu testen – ganz im Sinne eines Unit-Tests.

1. Komponenten deklarieren statt Module zu importieren

In jedem Unit-Test einer Komponente wird ein Test-Modul instanziiert. Statt das ganze Modul zu importieren, in dem die Komponente deklariert wird, sollte die Komponente selbst im Test-Modul deklariert werden. Ansonsten würden auch alle weiteren Komponenten, Module und Services aus dem Modul in das Test-Modul importiert, obwohl sie für die Ausführung des Tests nicht relevant sind.

import { AppComponent } from 'app/app.component';
    ...
TestBed.configureTestingModule({
    ...
    declarations: [
        AppComponent
    ],
    ...
})

Nicht bei jeder Komponente ist es damit getan. Oft hat die Komponente Abhängigkeiten auf andere Komponenten, Services, Pipes oder Direktiven. Sind diese nicht im Test-Modul enthalten, schlägt auch der Test fehl. Sie zu importieren, ist aber nicht unbedingt die Lösung, da, wie eingangs erläutert, die Isolierung des Tests damit ausgehebelt wird. Die nachfolgenden Tipps geben deshalb einen Überblick, wie man Komponenten mit zusätzlichen Abhängigkeiten isoliert testen kann.

2. Das NO_ERRORS_SCHEMA nutzen

Um einen echten Unit-Test einer Komponente durchzuführen, sollte nur die Komponente und nicht das Zusammenspiel mit ihren Kind-Komponenten getestet werden. Werden letzere nicht im Test-Modul deklariert, würde jedoch jeder Verweis auf sie im HTML-Template zu Fehlern führen. Das lässt sich mithilfe des NO_ERRORS_SCHEMA umgehen. Dazu importiert man das Schema im schemas Array des Test-Moduls.

import { NO_ERRORS_SCHEMA } from '@angular/core';
...
TestBed.configureTestingModule({
    ...
    schemas: [
        NO_ERRORS_SCHEMA
    ],
    ...
})

3. Services mocken

Wird in einer Komponente ein Service genutzt, sollte dieser gemockt werden, da im Komponententest nicht der Service getestet und in den meisten Fällen nicht der gesamte Service genutzt wird.

Komponente

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.scss']
})
export class AppComponent {

    constructor(
        private authenticationService: AuthenticationService,
    ) {
        this.authenticationService.init()
    }
}

Test

const authenticationServiceMock: AuthenticationService = {
    init(): void {}
};

TestBed.configureTestingModule({
    declarations: [
        AppComponent
    ],
    providers: [
        {
            provide: AuthenticationService,
            useValue: authenticationServiceMock
        }
    ]
})

Das Mocken von Services gilt für alle @Injectable-Klassen, die per Dependency Injection bereitgestellt werden. Somit können auch andere Klasseninstanzen mit Mock-Implementierungen im Test-Modul überschrieben werden. Eine ausführliche Dokumentation zu den providers findet sich auf der Angular-Website zum Thema Dependency Injection.

4. Pipes mocken

In den meisten Fällen lohnt sich ein Mocken von Pipes aus reiner Performancesicht nicht. Werden beispielsweise simple Text-Transformationen durchgeführt, würde ein Mocken der Pipe keinen merkbaren Performancegewinn bringen. In solchen Fällen reicht es, die originale Pipe zu den Deklarationen des Test-Moduls hinzuzufügen.

Bei komplexen oder umfangreichen Pipes kann sich ein Mocken jedoch lohnen. Ist die Pipe sehr komplex oder besitzt Abhängigkeiten auf andere Services, sollte ein Mocken in Betracht gezogen werden. Dazu wird im Test eine Pipe mit demselben Namen erstellt und zu den Deklarationen des Test-Moduls hinzugefügt.

Komponente

@Component({
    selector: 'user-birthday',
    template: `
        <div>{{ user.birthday | date }}</div>
    `,
    styles: ['']
})
export class UserBirthdayComponent {
    @Input()
    user: User;
}

Test

@Pipe({
    name: 'date'
})
export class MockDatePipe implements PipeTransform {
    transform(value: any, ...args: any[]) {
        return value;
    }
}
...
TestBed.configureTestingModule({
    declarations: [
        UserBirthdayComponent,
        MockDatePipe
    ]
})

5. Direktiven mocken

Bei Direktiven gibt es eine Besonderheit. Wird die Direktive als reines Attribut innerhalb eines HTML-Tags genutzt, greift das NO_ERRORS_SCHEMA, wodurch ein Mocken nicht nötig ist.

<input 
  class="form-control" 
  ngbTooltip="This is a tooltip">
</input>

Wird hingegen die Direktive exportiert und im Template oder der Komponente referenziert, muss sie im Test-Modul enthalten sein. Die Referenz ist sonst nicht definiert, was im Test zu einem Fehler führen würde.

<input 
  class="form-control" 
  ngbTooltip="This is a tooltip" 
  #tooltip="ngbTooltip"> // Direktive wird exportiert
</input>
<button (click)="tooltip.close()">Close</button>

Wird eine Direktive wie beschrieben exportiert, gilt dasselbe Gebot wie bei den Pipes. Es lohnt sich vom Performancegewinn her nicht, für jede Direktive eine Mock-Implementierung zu erstellen. Nur sehr komplexe oder umfangreiche Direktiven mit vielen Abhängigkeiten sind so schwergewichtig, dass sie merkbar die Performance der Tests beeinflussen. In solchen Fällen wird im Test eine Direktive mit demselben Selektor und Exportnamen erstellt, wie sie im HTML Template genutzt wird. Diese fügt man anschließend zu den Deklarationen im Test-Modul hinzu.

Komponente

@Component({
    selector: 'user-popover',
    template: `
        <div #popover="ngbPopover">{{ user.name }}</div>
    `,
    styles: ['']
})
export class UserPopoverComponent implements OnDestroy {
    @ViewChild('popover')
    popover: NgbPopover;

    ngOnDestroy() {
        this.popover.close();
    }
}

Test

@Directive({selector: '[ngbPopover]', exportAs: 'ngbPopover'})
export class NgbPopoverMockDirective {
    close(): void {}
}
...
TestBed.configureTestingModule({
    declarations: [
        UserPopoverComponent,
        NgbPopoverMockDirective
    ]
})

Generell gilt, dass das Importieren von Modulen im Komponenten/Unit-Test sehr viel mehr importiert, als man für den eigentlichen Test benötigt. Dadurch wird aus einem Unit-Test schnell ein Integrationstest, der erheblich länger dauert. Deshalb lohnt es sich, die Komponententests möglichst stark zu isolieren und komplexe Services, Pipes oder Direktiven zu mocken.

Natürlich sollte nicht absolut alles gemockt werden, was auch gemockt werden kann. Der Performancegewinn ist oft marginal im Vergleich zum damit verbundenen Aufwand. Deshalb sollte man sorgfältig auswählen, welche Services, Pipes oder Direktiven man in den Komponententests mockt.

Ich hoffe, dass wir euch mit den Tipps helfen konnten und in diesem Sinne: Happy Testing!

Über den Autor

Joost Zöllner arbeitet als Frontend-Architekt bei der Management- und IT-Unternehmensberatung Holisticon AG. Er ist ein technikbegeisterter und wissenshungriger Problemlöser mit einem Faible für Web-Technologien. Joost beschäftigt sich viel mit aktuellen Javascript-Technologien, um die gewonnenen Erkenntnisse in seinen Projekten anzuwenden.

Antwort hinterlassen