November 2018

Band 33, Nummer 11

Als Programmierer mit dem MEAN-Stapel arbeiten: Testen im Angular-Stil

Von Ted Neward| November 2018

Ted NewardWillkommen zurück, Freunde des MEAN-Stapels.

Mit etwas Glück stellt die „Debatte“ um Komponententests von Code kein Thema mehr dar: Als Entwickler sollten Sie ohne Frage nach Möglichkeiten suchen, den von Ihnen geschriebenen Testcode zu automatisieren. Es mag weiter argumentiert werden, ob diese Tests vor oder nach dem betreffenden Code stattfinden sollten, aber es ist doch offensichtlich, dass Tests kein optionaler Bestandteil eines modernen Softwareprojekts mehr sind. Was dann natürlich die folgende Frage aufwirft: Wie testen Sie eine Angular-Anwendung? Ich bin damals in der Ausgabe aus Mai 2017 kurz auf die Testdateien eingegangen, als ich anfing, einige Angular-Komponenten zu erstellen, aber das war keine gründliche Behandlung dieses Themas (msdn.com/magazine/mt808505). Jetzt ist es an der Zeit, einen genaueren Blick darauf zu werfen.

Zurück zum Anfang

Lassen Sie uns einen Schritt zurück zum Anfang der Anwendung machen. Wenn „ng new“ ausgeführt wird, um das Gerüst für die Anfangsphasen der Anwendung bereitzustellen, werden alle notwendigen Tools, Hooks und Dateien erstellt, um sicherzustellen, dass die Anwendung getestet werden kann. Tatsächlich können Sie unmittelbar nach „ng new“, ohne auch nur eine einzige Änderung an einer Datei vorzunehmen, „ng test“ ausführen, um den Test Runner auszuführen, der wiederum den geschriebenen Code für den Testframeworkcode ausführt, der als Gerüst erstellt wurde.

Bei der Ausführung startet „npm test“ Karma, den Test Runner, der dann eine Browserinstanz startet und die Tests innerhalb dieser Instanz ausführt. Karma wird weiterhin ausgeführt und hält einen geöffneten WebSocket für den Browser bereit, sodass alle Änderungen an den Quelldateien sofort getestet werden können, wodurch ein Teil des Mehraufwands beim Testen entfällt und wir einem Code- und Testzyklus ohne Wartezeiten näher kommen. Das Projektgerüst stellt drei Tests bereit, die in der Datei „app.component.spec.ts“ codiert sind, die den entsprechenden app.component.ts-Code testet. In der Regel sollte jede Komponente in der Angular-Anwendung eine entsprechende Datei „.spec.ts“ besitzen, in der alle Tests gespeichert werden. Wenn jede Komponente durch die Angular-CLI erstellt wird, wird in der Regel eine entsprechende Testdatei generiert. Dabei gibt es einige Ausnahmen (z.B. „class“), in denen ein Argument „--spec“ für den Befehl „generate“ erforderlich ist, um die SPEC-Datei (kurz für „specification“) zu erstellen.

Lassen Sie uns eine schnelle Klasse („Speaker“, wie könnte es anders sein) generieren und dann einige Tests für sie schreiben. Erstellen Sie wie gewohnt die Komponente mit der Angular-CLI („ng generate class Speaker --spec“), und bearbeiten Sie die Datei „speaker.ts“ so, dass sie eine einfache Klasse mit fünf öffentlichen Konstruktoreigenschaften enthält:

export class Speaker {
  constructor(public id: number,
    public firstName: string,
    public lastName: string,
    public age: number,
    public bio?: string,
    )
  { }
}

Die entsprechenden Tests sollten die verschiedenen Eigenschaften überprüfen, um sicherzustellen, dass sie wie erwartet funktionieren:

import { Speaker } from './speaker';
describe('Speaker', () => {
  it('should create an instance', () => {
    expect(new Speaker(1, "Ted", "Neward", 47, "Ted is a big geek")).toBeTruthy();
  });
  it('should keep data handy', () => {
    var s = new Speaker(1, "Ted", "Neward", 47, "Ted is a big geek");
    expect(s.firstName).toBe("Ted");
    expect(s.firstName).toEqual("Neward");
    expect(s.age).toBeGreaterThan(40);   
  })
});

Wenn die Klasse komplizierter wird, sollten auch komplexere Tests hinzugefügt werden. Ein Großteil der Leistungsfähigkeit der Tests liegt im „expect“-Framework, das eine große Anzahl von „toBe“-Methoden zum Testen verschiedener Szenarien bereitstellt. Hier sehen Sie mehrere Varianten dieser Methoden, darunter „toEqual“, die einen Gleichheitstest durchführt, und „toBeGreaterThan“, die genau das überprüft, was ihr Name verspricht.

Aber diejenigen unter Ihnen, die dieses Beispiel am eigenen Computer nachvollziehen, werden erkennen, dass etwas nicht stimmt: Nachdem die SPEC-Datei gespeichert wurde, färbt sich die geöffnete Browserinstanz hässlich rot und weist darauf hin, dass „Ted“ nicht dasselbe ist wie „Neward“! Tatsächlich gibt es einen Fehler in der zweiten expect-Anweisung, in der versucht wird, „firstName“ zu vergleichen, wenn es „lastName“ sein sollte. Dies ist gut, denn während Testcode auf Fehler im Code testet, kann es auch manchmal vorkommen, dass sich der Fehler im Test befindet. Dieses Feedback zu erhalten, sobald Sie den Test schreiben, hilft dabei, Testfehler zu vermeiden.

Weitere Tests

Natürlich besteht eine Angular-App aus mehr als nur einfachen Klassen. Sie kann auch Dienste beinhalten, die im Allgemeinen ziemlich einfach zu testen sind, da sie dazu neigen, Verhalten und sehr wenig Zustand bereitzustellen. Im Falle von Diensten, die ein eigenes Verhalten aufweisen, z.B. Formatierung oder einfache Datentransformation, ist das Testen einfach und erinnert an das Testen einer Klasse wie „Speaker“. Aber Dienste müssen auch häufig in irgendeiner Weise mit der Welt um sie herum interagieren (z.B. HTTP-Anforderungen ausgeben wie der SpeakerService in einer früheren Kolumne), was bedeutet, dass das Testen schwieriger wird, wenn Sie die Abhängigkeiten nicht einbeziehen möchten. Das Senden von Anforderungen über HTTP würde die Tests beispielsweise den Unwägbarkeiten der Netzwerkkommunikation oder Serverausfällen aussetzen, was zu einigen falsch-negativen Fehlern führen und die Tests weniger deterministisch machen könnte. Das wäre schlecht.

Es sind genau diese Situationen, in denen Angular Abhängigkeitsinjektion so stark nutzt.

Beginnen wir zum Beispiel mit der Version von SpeakerService, die keine HTTP-Anforderungen ausgegeben hat, wie in Abbildung 1 dargestellt.

Abbildung 1: Die SpeakerService Klasse, die keine HTTP-Anforderungen vorgenommen hat

@Injectable()
export class SpeakerService {
  private static speakersList : Speaker[] = [
    new Speaker(1, "Ted", "Neward", 47,
      "Ted is a big geek living in Redmond, WA"),
    new Speaker(2, "Brian", "Randell", 47,
      "Brian is a high-profile speaker and developer of 20-plus years.
      He lives in Southern California."),
    new Speaker(3, "Rachel", "Appel", 39,
      "Rachel is known for shenanigans the world over. She works for Microsoft."),
    new Speaker(4, "Deborah", "Kurata", 39,
      "Deborah is a Microsoft MVP and Google Developer Expert in Angular,
      and works for the Google Angular team."),
    new Speaker(5, "Beth", "Massi", 39,
      "Beth single-handedly rescued FoxPro from utter obscurity
      and currently works for Microsoft on the .NET Foundation.")
  ]
  public getSpeakers() : Speaker[] { return SpeakerService.speakersList; }
  public getSpeakerById(id : number) : Speaker {
    return SpeakerService.speakersList.find( (s) => s.id == id);
  }
}

Das Testen dieser Version ist trivial, da sie synchron ist und keine externen Abhängigkeiten erfordert, wie in Abbildung 2 gezeigt.

Abbildung 2: Testen der SpeakerService-Klasse

describe('SpeakerService', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [SpeakerService]
    });
  });
  it('should be able to inject the SpeakerService', inject([SpeakerService],
    (service: SpeakerService) => {
    expect(service).toBeTruthy();
  }));
  it('should be able to get a list of Speakers', inject([SpeakerService],
    (service: SpeakerService) => {
    expect(service.getSpeakers()).toBeDefined();
    expect(service.getSpeakers().length).toBeGreaterThan(0);
  }));
});

Fallen Ihnen die Aufrufe von „inject“ in jedem Test auf? Auf diese Weise verwaltet Angular im Wesentlichen die Abhängigkeitsinjektion in der Testumgebung. Sie ermöglicht es Ihnen, jede Art von kompatiblem Dienst-Back-End (echt oder simuliert) für die Umgebung bereitzustellen.

Typischerweise leistet der Dienst ein wenig mehr als das, was im einfachen SpeakerService enthalten ist, und es ist mühsam, den „echten“ Dienst auszukommentieren und/oder durch einen simulierten Dienst ersetzen zu müssen, der keine Funktion hat, sodass hier die Verwendung eines „simulierten“ Diensts besser funktioniert. Angular weist ein nützliches Konstrukt namens „Spy“ auf, das sich in einen regulären Dienst einfügen und bestimmte Methoden außer Kraft setzen kann, um ein simuliertes Ergebnis zu erzielen:

it('should be able to get a list of Speakers',
    inject([SpeakerService], (service: SpeakerService) => {
  spy = spyOn(service, 'getSpeakers').and.returnValues([]);
  var speakers: Speaker[] = service.getSpeakers();
  expect(speakers).toBeDefined();
  expect(speakers.length).toBe(0);
}));

Mit Spy können Sie die im Test aufgerufene Methode „überschreiben“, um die Werte bereitzustellen, die Sie zurückgeben möchten.

Komponententests

Ein großer Teil der Erstellung einer Angular-Anwendung besteht jedoch aus dem Erstellen von Komponenten, die auf der Seite angezeigt werden können, und es ist wichtig, auch diese testen zu können. Um ein noch besseres Verständnis für das Testen einer visuellen Komponente zu erhalten, beginnen wir mit einer einfachen ein- und ausschaltbaren Komponente vom Typ Umschalter:

@Component({
  selector: 'app-toggle',
  template: `<button (click)="clicked()">
    I am {{isOn ? "on" : "off" }} -- Click me!
  </button>`,
  styleUrls: ['./toggle.component.css']
})
export class ToggleComponent {
  public isOn = false;
  public clicked() { this.isOn = !this.isOn; }
}

Um dies zu testen, können Sie das DOM buchstäblich vollständig ignorieren und einfach den Zustand der Komponente untersuchen, wenn verschiedene Aktionen aufgerufen werden:

it('should toggle off to on and off again', () => {
  const comp = new ToggleComponent();
  expect(comp.isOn).toBeFalsy();
  comp.clicked();
  expect(comp.isOn).toBeTruthy();
  comp.clicked();
  expect(comp.isOn).toBeFalsy();
});

Dieser Test überprüft das DOM jedoch überhaupt nicht, was einige kritische Fehler unterdrücken könnte. Nur weil sich die Eigenschaft „isOn“ geändert hat, bedeutet das nicht, dass die Vorlage z.B. die Eigenschaft richtig gerendert hat. Um dies zu überprüfen, können Sie die von der Vorrichtung erstellte Komponenteninstanz verwenden und das für sie gerenderte DOM untersuchen, wie es in diesem Beispiel der Fall ist:

it ('should render HTML correctly when clicked', () => {
  expect(fixture.componentInstance.isOn).toBeFalsy();
  const b = fixture.nativeElement.querySelector("button");
  expect(b.textContent.trim()).toEqual("Click me! I am OFF");
  fixture.componentInstance.clicked();
  fixture.detectChanges();
  expect(fixture.componentInstance.isOn).toBeTruthy();
  const b2 = fixture.nativeElement.querySelector("button");
  expect(b2.textContent.trim()).toEqual("Click me! I am ON");
});

Das „nativeElement“ ruft hier den DOM-Knoten für die Komponente ab, und ich benutze „querySelector“, um eine Abfrage im jQuery-Stil durchzuführen, um den relevanten DOM-Knoten innerhalb dieses Knotens zu finden: in diesem Fall die Schaltfläche, die der Umschalter erstellt. Von dort aus schnappe ich mir den Textinhalt (und kürze ihn, weil die vorhergehende Democodezeile an zwei Stellen umbricht, die im Test unangenehm zu replizieren wären) und vergleiche ihn mit den erwarteten Ergebnissen. Beachten Sie jedoch, dass nach dem „Klicken“ der Komponente ein Aufruf von "detectChanges" erfolgt. Dies liegt daran, dass Angular angewiesen werden muss, die DOM-relativen Änderungen zu verarbeiten, die der Ereignishandler möglicherweise verursacht hat, z.B. das Aktualisieren der interpolierten Zeichenfolgen in der Vorlage. Ohne diesen Schritt scheitern die Tests, obwohl die Komponente im Browser einwandfrei funktioniert. (Ich habe beim Schreiben dieses Artikels genau diesen Fehler gemacht, also machen Sie sich nichts daraus, wenn Sie das Erkennen der Änderungen vergessen haben.) Beachten Sie Folgendes: Wenn die Komponente innerhalb ihrer onInit-Methode eine signifikante Initialisierung durchführt, muss der Test aus dem gleichen Grund Änderungen erkennen, bevor er sinnvolle Aufgaben verrichten kann.

Zusammenfassung

Beachten Sie übrigens, dass all dieser Testcode für die Clientseite der Anwendung und nicht für die Serverseite gilt. Erinnern Sie sich an all den Express-Code, den ich geschrieben habe, um APIs zum Speichern von Daten in der Datenbank usw. bereitzustellen? All das liegt im Grunde genommen „außerhalb“ dieses Frameworks und muss daher separat verwaltet und ausgeführt werden. Sie können einige der von mir besprochenen „Build“tools verwenden, um sowohl die serverseitigen als auch die clientseitigen Tests als Teil eines größeren Testzyklus auszuführen und so sicherzustellen, dass sie als Teil jeder Änderung am Client oder Server ausgelöst werden. Angular unterstützt auch „E2E“-Tests (kurz für „End-to-End“), die außerhalb des Rahmens dieses Artikels liegen, aber genau dieses Szenario unterstützen sollen.

Viel Spaß beim Programmieren!


Ted Newardlebt in Seattle und ist Polytechnologieberater, Referent und Mentor. Zurzeit arbeitet er als Direktor von Engineering and Developer Relations bei Smartsheet.com. Er hat unzählige Artikel geschrieben und als Autor und Mitautor ein Dutzend Bücher verfasst. Er hält weltweit Vorträge. Sie erreichen ihn unter ted@tedneward.com, oder lesen Sie seinen Blog unter blogs.tedneward.com.

Unser Dank gilt dem folgenden technischen Experten für die Durchsicht dieses Artikels: Garvice Eakins (Smartsheet.com)


Diesen Artikel im MSDN Magazine-Forum diskutieren