Angular Komponenten-Tests oder: wie ich lernte Spione zu lieben

angular-komponent-test-titel

Weiter geht es mit meiner Serie zum Thema Unit-Tests mit Angular. Dieses Mal geht es darum, einen Unit-Test für eine GUI-Komponente zu schreiben. Wie immer findet ihr den vollständigen Quellcode auf GitHub.

Der Umfang des Projektes ist schnell erklärt. Die Anwendung zeigt eine Komponente „TodoComponent" an. Diese lädt über den „TodoService" ein JSON-Objekt. Dieses Objekt beinhaltet die Einträge einer Aufgabenliste die dann angezeigt werden. Die Herausforderung ist nun herauszufinden, wie diese Komponente getestet werden kann. Denn während unseres Unit-Tests möchten wir keine REST-Schnittstelle ansprechen, die uns benötigte Daten zur Verfügung stellt. Wäre diese Schnittstelle - aus welchen Gründen auch immer - nicht erreichbar, würde unser Test fehlschlagen. Das wollen wir auf keinen Fall! Von daher müssen wir irgendwie hinbekommen, dass der "TodoService" keinen wirklichen http-Request absendet.

Um den nachfolgenden Service geht es. Dieser stellt die beschriebene Funktionalität zur Verfügung, um Einträge einer Aufgabenliste per REST-Request zu laden.

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Todo } from './todo';

@Injectable({
  providedIn: 'root'
})
export class TodoService {
  constructor(private http: HttpClient) {}

  public getTodos(): Observable<Todo[]> {
    return this.http.get<Todo[]>('https://jsonplaceholder.typicode.com/todos/');
  }
}
 
Dieser Service stellt eine Abhängigkeit für die „TodoComponent" dar. Die Komponente braucht diesen Service, um die Aufgabenliste zu laden und diese im Anschluss anzuzeigen. Nachfolgend der Quellcode der „TodoComponent".
import { Component, OnInit } from '@angular/core';
import { TodoService } from '../todo.service';
import { Todo } from '../todo';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-todo',
  templateUrl: './todo.component.html',
  styleUrls: ['./todo.component.scss']
})
export class TodoComponent implements OnInit {
  public todos$: Observable<Todo[]>;
  constructor(private todoService: TodoService) {}

  ngOnInit() {
    this.todos$ = this.todoService.getTodos();
  }

  clickOnTodo(todo: Todo): void {
    console.log(JSON.stringify(todo));
  }
}
 
<h1>Todos</h1>
<ng-container *ngIf="todos$ | async as todos">
  <div class="todos" *ngFor="let todo of todos">
    <a [id]="'todo-' + todo.id" (click)="clickOnTodo(todo)">{{
      todo | json
    }}</a>
  </div>
</ng-container>
 
Führt man dieses Projekt mit "npm start" aus, sieht das Ergebnis so aus:
Nun zum Unit-Test der "TodoComponent". Wir möchten überprüfen, ob wirklich alle vom "TodoService" bereitgestellten Aufgaben der Aufgabenliste angezeigt werden. Die angular-cli hat uns dazu bereits die passende Datei für den Unit-Test erzeugt. Der Dateiname ist: "todo.component.spec.ts". Als erstes schauen wir uns die grundsätzliche Struktur der Datei an.
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TodoComponent } from './todo.component';
import { HttpClientModule } from '@angular/common/http';
import { TodoService } from '../todo.service';
import { Todo } from '../todo';
import { of } from 'rxjs';
import { By } from '@angular/platform-browser';

describe('TodoComponent', () => {
  let component: TodoComponent;
  let fixture: ComponentFixture<TodoComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [TodoComponent],
      imports: [HttpClientModule],
      providers: [TodoService]
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(TodoComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
});
 
Der Funktion "TestBed.configureTestingModule" übergeben wir einige Angaben, ohne die unser Test nicht laufen würde. Die Angaben haben eine große Ähnlichkeit zu einer Moduldefinition. Da die "TodoComponent" den "TodoService" benötigt, ist dieser unter "providers" aufgeführt. Da der Service selbst eine Abhängigkeit zum "HttpClient" hat, müssen wir das "HttpClientModule" importieren.

Der zweite "beforeEach" Block bereitet unsere Komponente für die nachfolgenden Tests vor.​ Der Fokus dieses Blog-Posts liegt nicht auf diesen Details. Dies kann man in der offiziellen Angular Dokumentation nachlesen.

Nun können wir uns den Unit-Test selbst anschauen. Würde der Test loslaufen, würde aktuell der "TodoService" den http-Request ausführen. Genau das wollen wir nicht! Als erstes holen wir uns die Instanz des Services und definieren Daten, die statt der Antwort des echten http-Requests benutzt werden sollen. Mit der "spyOn" Angabe erreichen wir, dass die originale Implementierung der "getTodos" überschrieben wird und etwas anderes zurückgibt. Und zwar ein Observable der selbstdefinierten Aufgaben ("mockTodos").
const service: TodoService = TestBed.get(TodoService);
const mockTodos: Todo[] = [
  { id: 1, userId: 1, title: 'MockTitle 1', completed: true },
  { id: 2, userId: 2, title: 'MockTitle 2', completed: false },
  { id: 3, userId: 3, title: 'MockTitle 3', completed: false }
];

spyOn(service, 'getTodos').and.returnValue(of(mockTodos)); 
Als nächstes müssen wir die Komponente dazu bringen die Aufgabenliste zu laden. Der Ladevorgang wird in der "ngOnInit" Funktion gestartet. Mit dem Aufruf von "detectChanges" wird manuell die "Change Detection" gestartet. Ohne diesen Aufruf würden wir keine Änderung des HTMLs sehen. Dies muss man in Unit-Tests immer explizit starten.
component.ngOnInit();
fixture.detectChanges(); 
Nun folgt der eigenliche Test. Über das Objekt "fixture" können wir uns im DOM "bewegen" und diesen auf Korrektheit überprüfen. Jedes Element der Aufgabenliste wird mit einem "HTML-Anker-Element" (<a></a>) angezeigt. Also suchen wir mit  "queryAll" nach allen "a" Knoten im DOM der "TodoComponent". Nach diesen Schritt können wir mit dem ersten "expect"-Angabe überprüfen, ob die Anzahl der "HTML-Anker-Elemente" der Zahl der vom "TodoService" zurückgegeben Aufgabenliste entspricht. Würde es hier zu Unstimmigkeiten kommen, würde das bedeuten, dass nicht alle Aufgaben der Aufgabenliste angezeigt werden. Der Test würde fehlschlagen.

const allAHrefElements = fixture.debugElement.queryAll(By.css('a'));
expect(allAHrefElements.length).toBe(mockTodos.length); 

Der Benutzer kann auf jedes einzelne Aufgabe klicken. Dabei wird die "clickOnTodo" Funktion ausgeführt und die ID der Aufgabe auf die Konsole geschrieben. Wir überprüfen nun, ob die Funktion mit den richtigen Übergabeparameter aufgerufen wird. Dazu erzeugen wir ein "spy"-Objekt, welches die "clickOnTodo" Funktion überwacht. Danach wird durch den Test auf das zweite Element der Aufgabenliste geklickt. Mit der letzten "expect"-Angabe überprüfen wir das "spy"-Objekt, ob die Funktion wirklich mit dem richtigen "Todo"-Objekt aufgerufen wurde.


const spyOnClickOnTodo = spyOn(component, 'clickOnTodo');
const todo2Test = 1;
allAHrefElements[todo2Test].nativeElement.click();
expect(spyOnClickOnTodo).toHaveBeenCalledWith(mockTodos[todo2Test]); 

Zwei Sachen werden also überprüft.

  1. Wird die gesamte Aufgabenliste anzeigt.
  2. Funktioniert ein Klick auf eine einzelne Aufgabe richtig.

Fazit! 

​Das war's! Wir haben einen Unit-Test für eine Komponente geschrieben. Die Abhängigkeiten zu einen Service macht uns keine Probleme. Wir wissen nun, wie man eine Funktion eines Services überschreiben kann, ohne die originale Funktionalität aufrufen zu müssen. Diese Vorgehensweise bietet uns die Möglichkeit beliebige Abhängigkeiten aufzulösen bzw. zu entfernen. Dadurch wird unser Komponenten-Test übersichtlich und hat keine Abhängigkeiten nach Extern.

Nachfolgend noch der gesamte Inhalt der "todo.component.spec.ts" Datei. Außerdem ist das gesamte Beispiel auf GitHub zu finden.

import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TodoComponent } from './todo.component';
import { HttpClientModule } from '@angular/common/http';
import { TodoService } from '../todo.service';
import { Todo } from '../todo';
import { of } from 'rxjs';
import { By } from '@angular/platform-browser';

describe('TodoComponent', () => {
  let component: TodoComponent;
  let fixture: ComponentFixture<TodoComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [TodoComponent],
      imports: [HttpClientModule],
      providers: [TodoService]
    }).compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(TodoComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should display a list of todos', () => {
    const service: TodoService = TestBed.get(TodoService);
    const mockTodos: Todo[] = [
      { id: 1, userId: 1, title: 'MockTitle 1', completed: true },
      { id: 2, userId: 2, title: 'MockTitle 2', completed: false },
      { id: 3, userId: 3, title: 'MockTitle 3', completed: false }
    ];

    spyOn(service, 'getTodos').and.returnValue(of(mockTodos));
    component.ngOnInit();
    fixture.detectChanges();
    const allAHrefElements = fixture.debugElement.queryAll(By.css('a'));
    expect(allAHrefElements.length).toBe(mockTodos.length);

    const spyOnClickOnTodo = spyOn(component, 'clickOnTodo');
    const todo2Test = 1;
    allAHrefElements[todo2Test].nativeElement.click();
    expect(spyOnClickOnTodo).toHaveBeenCalledWith(mockTodos[todo2Test]);
  });
});
 
 

Kommentare

Derzeit gibt es keine Kommentare. Schreibe den ersten Kommentar!
Gäste
Samstag, 21. September 2019

Sicherheitscode (Captcha)