Meine Zusammenfassung der Unit-Tests in Angular

Für diejenigen unter Ihnen, die die Gelegenheit hatten, sich das Angular-Framework (V2 und mehr) anzusehen, haben Sie gesehen, wie umfangreich die Dokumentation auf der offiziellen Website sein kann, insbesondere zu der Unit-Tests-Teil. Der Zweck dieses Artikels besteht darin, Ihnen eine Zusammenfassung der Best Practices für Unit-Tests in Angular zur Verfügung zu stellen.
Der Artikel geht davon aus, dass Sie Angular und seine Unit-Tests bereits verwendet haben und dass Sie das lexikalische Gebiet der Unit-Tests kennen.
Die hier gegebenen Ratschläge und Lösungen stammen aus einer sechsmonatigen Erfahrung mit der Angular-Entwicklung mit einem Team von fünf Personen. Sie sind natürlich Gegenstand von Verbesserungen/Diskussionen.

Präambel: Wie „mockt“ man richtig mit Angular?

Eine kleine Präambel ist notwendig, um einen Punkt klarzustellen, der meiner Meinung nach in der Literatur zu diesem Thema zu oft übersehen wird und der für das weitere Verständnis von entscheidender Bedeutung sein wird:
Mocks, Spy und andere Stub.
Zu oft wird in Unit-Test-Beispielen von Angular ein etwas naiver Ansatz angeboten. Lass uns nehmen der Fall aus dem Test einer Komponente im offiziellen Dokument:

class MockUserService {
  isLoggedIn = true;
  user = { name: "Test User" };
}
/* ... */
beforeEach(() => {
  TestBed.configureTestingModule({
    // provide the component-under-test and dependent service
    providers: [
      WelcomeComponent,
      { provide: UserService, useClass: MockUserService }
    ]
  });
  // inject both the component and the dependent service.
  comp = TestBed.get(WelcomeComponent);
  userService = TestBed.get(UserService);
});

Dieser Code erklärt, dass die Implementierung eines Dienstes (hier UserService) erstellen Sie einfach eine gefälschte Klasse, die diesen Dienst ersetzt (hier MockUserService).
Dieser Ansatz ärgert mich im Grunde, weil wir alle Typprüfungen verlieren. Tatsächlich gibt es dafür keine Garantie MockUserService respektiert die Schnittstelle der Klasse UserService. Hinzu kommen die Kosten für das Schreiben einer gefälschten Klasse ...
[bctt tweet=”In Angular ist das Erstellen eines Mocks von Hand weder ein sicherer noch ein effizienter Ansatz.”] Eine andere Möglichkeit, die hier und da zu sehen ist, besteht darin, die Klasse zu erweitern, die wir dann verspotten möchten, Überladen Sie die gewünschten Verhaltensweisen. Wenn wir zu unserem vorherigen Beispiel zurückkehren, würde es so aussehen:

class MockUserService extends UserService {
  user = { name: "Another user" };
}
/* ... */
beforeEach(() => {
  TestBed.configureTestingModule({
    // provide the component-under-test and dependent service
    providers: [
      WelcomeComponent,
      FirstLevelNeededService,
      SecondLevelNeededService,
      { provide: UserService, useClass: MockUserService }
    ]
  });
  // inject both the component and the dependent service.
  comp = TestBed.get(WelcomeComponent);
  userService = TestBed.get(UserService);
});

Diese Methode ist sogar noch schlechter als die vorherige, da Sie, obwohl Sie das Tipp- und Ausführlichkeitsproblem behoben haben, die für einen Komponententest erforderliche Isolierung durchbrochen haben.
Wie die eigentliche Umsetzung von UserService wird importiert, TestBed wird verlangen, dass wir es auch mit allen Klassen auf denen zur Verfügung stellen UserService. Stellen Sie sich das folgende Abhängigkeitsschema vor:

+----------+ +-------------------------+ +---- - ---------------------+ | | | | | | | UserService +<---+ FirstLevelNeededService +<----+ SecondLevelNeededService | | | | | | | +----------+ +-------------------------+ +---- - ---------------------+

Nach diesem Schema sollten wir die bereitstellen TestBed, FirstLevelNeededService et SecondLevelNeededService. Dies ergibt keinen Sinn, wenn Sie darüber nachdenken, da der Zweck eines Mocks darin besteht, das Verhalten einer Klasse zu simulieren. Warum also Scheinabhängigkeiten bereitstellen?
[bctt tweet=”Erstellen Sie niemals einen Mock einer TypeScript-Klasse, indem Sie ihre ursprüngliche Implementierung in Angular erweitern.”] Andernfalls müssen Sie eine große Anzahl von Klassen für jede Testsuite bereitstellen.

Verwenden Sie eine Scheinbibliothek

Für objektorientierte Entwickler ist die Verwendung einer Mock-Bibliothek im Zusammenhang mit Unit-Tests naheliegend, da ihre Sprache oft nicht so dynamisch wie JavaScript ist und es Ihnen nicht erlaubt, Mocks on the fly ("on the fly") zu schreiben. - Wer hat das gesagt?).
Eine Mock-Bibliothek verwendet die über TypeScript erstellte Typdefinition, um ein Mock einer Klasse zu erstellen.
Alter Reflex von „Javaiste“ vielleicht, gefällt mir persönlich ts mockito als Mocks Buchhändler, aber es gibt noch andere: TS-Mock oder Typmoq
Wenn wir unser Beispiel mit ts-mockito nehmen, würde unser Code so aussehen:

import { instance, mock, when } from "ts-mockito";
/***/
let userServiceMock: UserService;
beforeEach(() => {
  userServiceMock = mock(UserService);
  when(userServiceMock.user).thenReturn({ name: "A user" });
  TestBed.configureTestingModule({
    providers: [
      WelcomeComponent,
      { provide: UserService, useValue: instance(userServiceMock) }
    ]
  });
  userService = TestBed.get(UserService);
});

Einfach, nicht wahr? ts-mockito erstellt zunächst eine mockierte Klasse mit der Funktion mock Erstellen Sie dann Mock-Instanzen basierend auf dieser Klasse mit der Funktion instance.
Um Ergebnisse zu simulieren, verwenden wir häufig die Funktion when. Verwenden Sie immer when auf eine verspottete Klasse und Anruf instance erst danach, sonst hat Ihre Scheininstanz nicht die definierten Verhaltensweisen.

Im Rest des Artikels werden wir systematisch ts-mockito verwenden

Der Leitfaden

Fall Nr. 1: Testen eines Dienstes ohne Abhängigkeit mit Angular

Beginnen wir mit dem Einfachsten, dem Service. In Angular ist ein Service weder mehr noch weniger als eine TypeScript-Klasse, die von der instanziiert wird IOC-Container von Angular und in alle anderen Elemente injiziert, die es als Abhängigkeiten definieren.
Wenn der Dienst keine von Angular stammende Abhängigkeit verwendet (wie die HttpClient zum Beispiel) können Sie es wie jede TypeScript-Klasse in jedem Projekt wie folgt testen:

import { MyService } from "./my.service";
import { instance, mock, when } from "ts-mockito";
import { MathLib } from "./my.service";
describe("MyService", () => {
  it("should add correctly two positive integers and multiply them by two", () => {
    // given
    const mathMock = mock(MathLib);
    when(mathMock.add(1, 2)).thenReturn(3);
    when(mathMock.multiply(3)).thenReturn(6);
    const myService = new MyService(instance(mathMock));
    // when
    const result = myService.addAndMultiplyByTwo(1, 2);
    // then
    expect(result).toBe(6);
  });
});

Erster Punkt, Sie werden das Gegenteil bemerken eckig-kli Wir erstellen keinen Test should be created. Dieser Test, mit dem die Existenz der Klasse verifiziert werden würde, wird de facto bereits von den anderen Unit-Tests verifiziert und ist daher nutzlos.
Zweiter und möglicherweise wichtigster Punkt dieses Artikels:
[bctt tweet=”Benutze das `TestBed` nur selten beim Angular Unit Testing.”] The TestBed, genauer gesagt der Aufruf seiner Methode configureTestingModule, ist lang und bringt mehr Komplexität in die Tests. Und aus gutem Grund der Methodenaufruf TestBed.configureTestingModule erstellt, wie der Name schon sagt, a Winkelmodul bei jedem Aufruf und instanziieren alle Elemente dieses „Fake-Moduls“ dank des IOC-Containers von Angular.
Wir haben in unseren Tests beobachtet, dass die Verwendung von TestBed dauert möglicherweise fünfmal länger als eine einfache Objektinstanziierung und ihre Mocks wie in unserem Beispiel (weitere Informationen hier): Angular TestBed ist zu lang)
Da haben wir nichts erfunden, das nennt sich a Isolierter Test im Angular-Dokument (wo ich den Link nicht mehr finden kann, weil er riesig ist :D)
Ok, aber was passiert, wenn ich benutze Module kommt von Angular wie die HttpModule ?

Fall Nr. 2: Testen eines Dienstes, der von einem Angular-Modul abhängt

Lassen Sie uns einen recht standardmäßigen Test eines Authentifizierungsdienstes durchführen, der die verwendet HttpClient :

import { AuthService } from "./auth.service";
import { inject, TestBed } from "@angular/core/testing";
import {
  HttpClientTestingModule,
  HttpTestingController
} from "@angular/common/http/testing";
describe("AuthService", () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [AuthService],
      imports: [HttpClientTestingModule]
    });
  });
  it(
    "should return null user when server responds HTTP error",
    inject(
      [HttpTestingController, AuthService],
      (httpMock: HttpTestingController, service: AuthService) => {
        service.user.subscribe(user => {
          expect(user).toBe(null);
        });
        httpMock.expectOne("auth/user").error(new ErrorEvent("401"));
        httpMock.verify();
      }
    )
  );
});

Wir haben keine andere Wahl, als die zu verwenden TestBed denn er ist es, der den Zugang zu den gewährt httpMock verantwortlich für die Simulation von Serverantworten.
[bctt tweet=”Wir werden das `TestBed` nur verwenden, um Komponenten oder Klassen zu testen, die vom Angular-Modul abhängig sind.”] Sie werden auch die Verwendung des kleinen Helfers bemerken inject, praktisch zum Abrufen von Instanzen, die im IOC-Container von Angular erstellt wurden. Vielleicht ist Ihnen auch schon aufgefallen, dass mein beforeEach enthält keinen Aufruf an async. Async wird tendenziell wahllos verwendet, aber diese Funktion ist dazu da, auf eine Asynchronität zu warten.
Schaut man sich die an amtliche Dokumentation configureTestingModule kehre nicht zurück Promise, es ist also nicht asynchron, also lohnt es sich nicht anzurufen async in diesem Fall.

Fall Nr. 3: Testen einer „Basis“-Komponente

Dies ist die Art von Komponente, die Sie häufig in Angular schreiben.
Wir werden im folgenden Fall sehen, dass die in der Hierarchie hoch liegenden Komponenten ebenfalls Gegenstand einer zusätzlichen Verarbeitung sind.
Um diesen Test zu veranschaulichen, stellen wir uns vor, wir befinden uns im Prozess der Überprüfung eines Bauteils ListComponent die eine von einem Dienst geladene Liste anzeigt ItemsService : ein Lehrbuchfall …
Der richtige Weg dazu sähe so aus:

import { ComponentFixture, TestBed } from "@angular/core/testing";
import { anyString, instance, mock, when } from "ts-mockito";
import { ListComponent } from "./list.component";
import { ItemsService } from "./item/items.service";
import { Item } from "./item/item.model";
import { of } from "rxjs/observable/of";
describe("ListComponent", () => {
  let component: ListComponent;
  let fixture: ComponentFixture<ListComponent>;
  let mockedItemsService: ItemsService;
  beforeEach(() => {
    mockedItemsService = mock(ItemsService);
  });
  async function configureTestingModule() {
    await TestBed.configureTestingModule({
      providers: [
        { provide: ItemsService, useValue: instance(mockedItemsService) }
      ],
      declarations: [ListComponent]
    }).compileComponents();
    fixture = TestBed.createComponent(ListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  }
  it("should display an empty list", async () => {
    // given
    when(mockedItemsService.loadItems()).thenReturn(of([new Item()]));
    // when
    await configureTestingModule();
    // then
    const nbEl = fixture.debugElement.queryAll(By.css("li")).length;
    expect(nbEl).toEqual(0);
  });
});

Das erste, was Sie vielleicht schockiert hat, war die Funktion configureTestingModule außerhalb beforeEach. In der Tat wird es notwendig sein, es in unseren Unit-Tests aufzurufen und nicht vorher, um die Aufrufreihenfolge der in der Präambel erwähnten Mockito-Funktionen beizubehalten:

mock -> when -> Instanz

mock wird in der aufgerufen beforeEach puis when zu Beginn des Unit-Tests und schließlich instance durch den Anruf von configureTestingModule mitten im Unit-Test.
Wenn wir uns auf . konzentrieren configureTestingModule, sehen wir, dass es asynchron ist, weil wir aufrufen compileComponents die ein Versprechen zurückgibt. Dies async „kontaminiert“ unseren Unit-Test, weil wir ihn als markieren müssen async auch.
Wir werden auch feststellen, dass wir den Anbieter damit überlasten ItemsService um die Instanz unseres ts-mockito-Mocks zu nennen:

{ bereitstellen: ItemsService, useValue: Instanz (mockedItemsService) }

Dadurch wird sichergestellt, dass die TestBed spritzt dieses Simulakrum von ItemsService an die Komponente, wenn sie erstellt wird.
Abschließend simulieren wir die Rückgabe der Methode loadItems des Dienstes, sodass er a zurückgibt Observable&lt;Item[]&gt; leer und wir überprüfen, ob die HTML-Liste der Elemente tatsächlich leer ist.

Fall Nr. 4: Testen einer High-Level-Komponente

Was ich „Top-Level“-Komponenten nenne, sind die Komponenten, die in der Komponentenhierarchie unserer Anwendung ganz oben stehen. Komponentenseiten, die viele Kinder enthalten, entsprechen perfekt dieser Bezeichnung. das AppComponent wird das verwendete Beispiel sein.
Das Ärgerliche, das Verhalten dieser Art von Komponenten zu überprüfen: Sie hängen per Definition von vielen Unterkomponenten und Untermodulen ab und es wird notwendig sein TOUS fügen Sie sie hinzu TestBed. Für die AppComponent Es ist, als würde man ihm fast die gesamte App in Abhängigkeit geben ...
Wenn ich dir außerdem gesagt habe, dass die TestBed.configureTestingModule in Fall 1 langsam war, wird es langsamer, wenn die Anzahl der Abhängigkeiten zunimmt.
Um dieses Problem zu umgehen, gibt es den Parameter schemas zur Verfügung gestellt werden TestBed.configureTestingModule mit dem Wert NO_ERRORS_SCHEMA :

import { TestBed } from "@angular/core/testing";
import { AppComponent } from "./app.component";
import { NO_ERRORS_SCHEMA } from "@angular/core";
describe("AppComponent", () => {
  let component: ListComponent;
  let fixture: ComponentFixture<ListComponent>;
  beforeEach(async () => {
    TestBed.configureTestingModule({
      providers: [],
      imports: [],
      declarations: [AppComponent],
      schemas: [NO_ERRORS_SCHEMA]
    }).compileComponents();
    fixture = TestBed.createComponent(ListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
  it("should render title in a h1 tag", async () => {
    // given
    const selectorText = compiled.querySelector("h1").textContent;
    // then
    expect(selectorText).toBe("My awesome app");
  });
});

Dieser magische Parameter konfiguriert die TestBed damit es keinen Fehler auslöst, wenn es den Anbieter einer Komponente oder eines Moduls, das in der/den Vorlage(n) der getesteten Komponente(n) verwendet wird, nicht finden kann. Diese Methode wird aufgerufen shallow testing. Wir testen nur die Komponente AppComponent und interpretieren Sie nicht seine Unterkomponenten.
Le NO_ERRORS_SCHEMA ist mit Vorsicht zu verwenden, da es überall angewendet Abhängigkeitssystemprüfungen inert macht und dazu führen kann, dass Sie vergessen, eine Komponente zu laden, wenn dies in Ihrem Testfall erforderlich ist.
[bctt tweet=”In Angular sollte `NO_ERRORS_SCHEMA` mit Vorsicht und nur zum Testen von Komponenten verwendet werden, die hoch in der Hierarchie stehen.”]

Fall Nr. 5: Rohre, Klassen, der Rest

Die Pipes werden wie die übrigen Elemente, aus denen Ihre Anwendung bestehen könnte, wie Dienste ohne Angular-Abhängigkeit getestet, nämlich grundlegende TypeScript-Klassen.
Stellen wir uns eine Pipe vor, deren Ziel es ist, alle Schlüssel eines Objekts, dessen Wert nicht null ist, in Form einer Zeichenkette zu verbinden. Wir würden es nennen AliciaKeys… (ja, wir waren ziemlich stolz auf den Namen)

import { Pipe, PipeTransform } from "@angular/core";
@Pipe({
  name: "aliciaKeys"
})
export class AliciaKeys implements PipeTransform {
  transform(obj: any, arg: string[]): string {
    return obj
      ? Object.entries(obj)
          .filter(([key, value]) => !!value)
          .map(([key]) => key)
          .join(", ")
      : "";
  }
}

Die angehängte Unit-Test-Datei ist trivial, da es sich um eine einfache Instanziierung der Klasse handelt AliciaKeys mit zwei Anrufen von transform in zwei verschiedenen Fällen:

import { AliciaKeys } from "./aliciakeys.pipe";
describe("AliciaKeys", () => {
  it("should return valued keys", () => {
    const result = new AliciaKeys().transform({
      validKey: {},
      inValidKey: null,
      anotherValidKey: 1
    });
    expect(result).toEqual("validKey, anotherValidKey");
  });
  it("should return blank when input is falsy", () => {
    const result = new AliciaKeys().transform(null;
    expect(result).toEqual("");
  });
});

Fazit & weiter

Wir hoffen, dass jede Fallbeschreibung auch Ihnen hilft, besser zu verstehen, wie Sie Ihre Angular-Anwendungen richtig testen.
Die wichtigen Punkte, an die Sie sich erinnern sollten, sind, dass die Klassen in Angular klassische TypeScript-Klassen bleiben und dass sie einfach getestet werden können. Der zweite Punkt ist, dass die TestBed ist nicht systematisch, dass es nur verwendet werden sollte, wenn Sie keine andere Wahl haben. Schließlich müssen Mocks in Angular mit einer Mock-Bibliothek verwaltet werden: In unserem Fall ts-mockito.
Sie sollten wissen, dass wir in unserem Projekt etwa 700 Komponententests hatten, deren Ausführung mehr als 30 Sekunden dauerte. Die Rationalisierung und Beschleunigung von Tests wurde für uns entscheidend.
Wir haben diese Suche nach Beschleunigung auch fortgesetzt, indem wir diese letzten bestanden haben ist. Dies ist durchaus möglich, indem Sie den Anweisungen im Repo folgen Scherz-Preset-Winkel und es spart viel Zeit!
Wir werden uns während unseres „JS-Talks“-Feedbacks (Technology Watch Day, den wir jeden Monat durchführen) mit weiteren Einzelheiten zu diesen Themen bei Ihnen melden. Einige abschließende Fotos, um zu sehen, wie es aussieht:

Gepostet von Matthew Breton CTO bei JS-Republic