GEDOPLAN
Webprogrammierung

Angular, testen mit Karma/Jasmine

Webprogrammierung

Anwendungen zu testen ist ein leidiges Thema und wird gerne aus Zeitgründen vernachlässigt. Jeder der schon mal ein größeres Refactoring durchgeführt hat wird aber eine gute Testabdeckung zu schätzen wissen. Angular macht es uns einfach dank AngularCLI zu einer getesteten Anwendung zu kommen.

app.png

Projekte die mit AngularCLI erstellt und entwickelt werden liefern schon sehr viel was nötig ist um seine Anwendung zu testen. Neben einer einheitlichen Anwendungsstruktur generiert uns das Werkzeug auch Test-Hüllen die wir lediglich mit Leben füllen müssen. So kann eine solche Anwendung direkt per Befehl: ng test einem Testlauf unterzogen werden. Da wir bis hierher noch kaum einen Handschlag getan haben sind diese Tests natürlich rein technischer Natur, so wird zumindest geprüft ob die Templates der Komponenten geparst werden können.  Die Test-Dateien die während des Testings herangezogen werden erhalten bei der Generierung den Postfix “.spec.ts”. Hier ein einfaches Beispiel was, ganz ohne eigene Arbeit, von Angular generiert wird:

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

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ HelloWorldComponent ]
    })
    .compileComponents();
  }));

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

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

Was wir hier sehen ist ein Jasmine-Testfile in dem Gruppen von Testfällen erstellt werden (describe) in denen dann einzelne Tests (it) definiert werden. AngularCLI fügt bereits die benötigten Initialisierung ein in der die Komponente, die es zu testen gilt, erstellt wird und auch einen entsprechenden Testfall um die erfolgreich Erstellung zu prüfen.

Komponente zum Test

Schauen wir uns also den Test einer Komponente an. Die Basis bildet eine sehr einfache Komponente die eine Liste von Benutzern entgegen nimmt und lediglich einige der Informationen darstellen soll ( aus Platzgründen sparen wir uns hier die Implementierung dieser Komponente, zu finden ist sie aber im Github )

  it('should show unser infos', () => {
    component.users = [
      {username: 'mocked', mail: 'mocked@mock.de'}
    ];

    fixture.detectChanges();
    const compiled: Element = fixture.debugElement.nativeElement;

    const firstUserName = compiled.querySelectorAll('h2').item(0).innerText;
    expect(firstUserName).toBe('mocked');
  });

Dank der Initialisierung der Testdatei haben wird die Komponente bereits für uns erzeugt. Diese könne wir nun mit Daten versorgen (Zeile: 2) um das Verhalten der Komponente zu testen. Die User-Daten werden in der “echten” Anwendung von der übergeordneten Komponente übergeben und werden vermutlich über einen Rest-Service gelesen. An dieser Stelle ist die Datenherkunft aber irrelevant, wir konzentrieren uns beim testen der Komponente lediglich auf das Verhalten der Komponente selbst, sodass wir in aller Regel darauf verzichten werden hier irgendwelche Services auf zu rufen um Daten zu bekommen.

Nach dem setzen der benötigen Daten rufen wir fixture.detectChanges();  damit Angular seinen Change Detecetion durchläuft. Anschließend können wir auf das native Element zugreifen  (Zeile: 7 ) und mittels Queries die Ausgabe im Template auf ihre Richtigkeit prüfen.

Formular zum Test

Mit diesem Ansatz lasse sich jetzt auch Formulare testen. In unserem Fall haben wir uns für das Template-Driven Design entschieden. Um dies zu testen benötigen wir Zugriff auf das Formular, das wir in unserer Komponente per

  @ViewChild(NgForm)
  form: NgForm;

injizieren lassen können. Somit erhalten wir Zugriff auf das Fomular und können dieses über die entsprechende API mit Daten versorgen. Unser Test z.B. prüft ob die Benutzer Liste korrekt gefiltert wird wenn das entsprechende Feld mit der maximalen Anzahl der an zu zeigenden Benutzer gefüllt wird:

  it('should load limit users', async(() => {
    const maxUser = component.form.form.get('maxUser');
    const btn = fixture.nativeElement.querySelector('button[name="loadBtn"]');

    expect(maxUser).toBeTruthy();
    expect(btn).toBeTruthy();

    maxUser.setValue(1);
    btn.click();
    fixture.detectChanges();
    let elecount = fixture.nativeElement.querySelectorAll('.portfolio-item').length;
    expect(elecount).toBe(1);
  }));

Wir erhalten Zugriff auf das “maxUser” Feld über das Formular. Den Button ermitteln wir über die bereits gesehene “querySelector” Methode. Nun können wir das Feld mit Daten versorgen und einen Button-Klick emulieren um dann zu prüfen ob die erwartete Anzahl an Benutzer auf der Oberfläche dargestellt wird.

Komponente ohne Service

In aller Regel verwenden unserer Komponente Services um Daten z.B. von einer Rest-Schnittstelle zu beziehen. Diese Services können entweder im NgModule als Provider registriert werden oder wie hier zu sehen auf der Ebene der Komponente:

 @Component({
  ...
  providers: [UserService]
})
export class AppComponent {...}

Der Test unserer AppComponent wäre nun diekt von der korrekten Funktion des UserServices abhängig. Wollen wir unsere Komponente aber autark testen kann dieser Service durch eine Mock Implementierung ausgetauscht werden. Ein Mock zum UserService implementiert dieselbe Schnittstelle wie der “echte” Service, führt aber zum Beispiel keine HTTP-Requests durch, sondern liefert definierte Rückgaben:

 @Injectable()
export class UserServiceMock {

  constructor(private http: Http) { }

  getUsers(): Observable<any[]> {
    return Observable.of(USER_DATA);
  }
}

export const USER_DATA = [...]

Nun muss lediglich sicher gestellt werden, dass unser Mock-Service herrangezogen wird wenn unser Test die Komponente instanziiert. Sollten wir den Service über das NgModule registriert haben reicht in unserem Test die Angabe eines eigenen Provider-Arrays (s. “Service zum Test”, Einbindung von HttpModule). In unserem Fall deklariert die Komponente über ein eigenes Provider-Array seine Abhängigkeiten die beim testen nun “überschrieben” werden müssen. Dazu bietet uns “TestBed” eine entsprechende Methode.

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

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpModule, FormsModule],
      declarations: [
        AppComponent, UserListComponent
      ]
    }).overrideComponent(AppComponent, {
      set: {
        providers: [
          { provide: UserService, useClass: UserServiceMock }
        ]
      }
    }).compileComponents();
  });

Service zum Test

Als Basis dient folgender Service der User-Daten abruft:

@Injectable()
export class UserService {

  constructor(private http: Http) { }

  getUsers(): Observable<any[]> {
    return this.http.get('http://jsonplaceholder.typicode.com/users').map(r => r.json());
  }
}

Der entsprechende Test ist ebenfalls sehr überschaubar:

  it('should load a least some users', async(inject([UserService], (service: UserService) => {
    expect(service).toBeTruthy();
    service.getUsers().subscribe(r => {
      expect(r.length).toBeGreaterThan(5);
    });
  })));

Zwei Dinge kommen hier hinzu die für den korrekte Ablauf wichtig sind. Zum einen wrappen wir unsere Test-Funktion in einen “async”-Funktion. Damit weisen wir unseren Testlauf an auf die Abarbeitung von asynchronen Methoden zu warten. Lasse wir dies weg würde unser Test immer erfolgreich durchlaufen, da unsere Prüfung asynchron über die “subsribe” Methode implementiert ist und der Test bereits als erfolgreich deklariert worden wäre ohne auf die Antwort zu warten.

Würden wir den Test nun mit der Standard Initialisierung von Angular laufen lassen, würde wir einen Fehler erhalten: Error: No provider for Http! Um den Fehler richtig zu interpretieren ist es wichtig zu verstehen das im Falle eines Testes Angular ein eigenes Modul aufbaut und nicht das “normale” NgModule verwendet welches wir ja für die Anwendung definieren. Damit wird klar: das HTTPModule ist war im Modul unserer Anwendung deklariert nicht aber in unserem speziellen Test-Modul. Solche Abhänigkeiten müssen hier speziell für den Test definiert werden. Dazu erweitern wir die inital von AngularCLI generierte Definition unserer beforeEach-Methode:

describe('UserService', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpModule],
      providers: [UserService]
    });
  });
...

und importieren das HttpModule auch für unserern Testfall.

Streng genommen verletzten wir mit diesem Test allerdings den Gedanken eines Unit-Testes. Wir rufen einen externen Service auf der autark gepflegt wird und damit ist unserer Testfall nicht nur von der Logik unserer eigenen Komponente abhänig sondern auch von der adressierten Rest-Schnittstelle. Eine Änderung der gelieferten Daten über diese Schnittstelle würde bei uns nun zu einem Fehler führen obwoh unser Programm immer nocht korrekt arbeitet. Das sollte man bedenken wenn solche Tests imlementiert werden.

Services zum Test, aber ohne externe Aufrufe bitte!

Um generell Aufrufe über HTTP zu unterbinden und stattdessen eine definierte Antwort bereit zu stellen bietet Angular die Klasse “MockBackend” welche das normale XHRBackend mit einer Implementierung ersetzt.

import { TestBed, inject, async } from '@angular/core/testing';
import { XHRBackend, ResponseOptions, Response } from '@angular/http';
import { MockBackend } from '@angular/http/testing';

import { UserService } from './user.service';
import { HttpModule } from '@angular/http';;

describe('UserService (with mocked Backend)', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpModule],
      providers: [
        { provide: XHRBackend, useClass: MockBackend },
        UserService
      ]
    });
  });

  it('should load a least some users', async(inject([UserService, XHRBackend], (service: UserService, mockBackend: MockBackend) => {
    mockBackend.connections.subscribe((connection) => {
      connection.mockRespond(new Response(new ResponseOptions({
        body: JSON.stringify([{"username": "mock"}])
      })));
    });

    expect(service).toBeTruthy();
    service.getUsers().subscribe(r => {
      console.log(r);
      expect(r.length).toBeGreaterThan(0);
    });
  })));
});

Wir greifen mit den “Providers” in die Depedency Injection von Angular ein und bringen so das angesprochene MockBackend ins Spiel. Nun können wir in unseren Testfällen die Instanz des MockBackend injizieren lassen und auf eingehende Verbindungen reagieren. Hier haben wir nun die Möglichkeit definierte HTTP-Response zu schicken, ohne das eine echte Verbindung nach außen aufgebaut wird

Spies

Ein weiteres nettes Feature von Jasmine ist es so genannte “Spies” auf bestimmte Methoden zu legen. Mit dessen Hilfe können wir Methoden-Aufrufe aufzeichnen und so prüfen ob Interaktionen mit der Komponente auch korrekt z.B. zu einem Service durchgreicht werden:

   it('should call load method on button click', async(() => {
    const userService = (<any>component).userService;
    const btn = fixture.nativeElement.querySelector('button[name="loadBtn"]');

    const spy = spyOn(userService, 'getUsers');
    btn.click();
    btn.click();
    btn.click();

    expect(spy.calls.count()).toBe(3);
  }));

Dieser Test funktioniert wie erwartet. Wichtig zu beachten ist aber folgendes: ein solcher Spy führt dazu das die Service-Methode “getUsers” gar nicht aufgerufen wird. Wir simulieren hier also den Methodenaufruf nur. Falls wir in diesem Testfall auch Prüfungen implementieren wollen die das Ergebniss dieser Methoden-Aufrufe berücksichtigt muss dies mit folgendem Methoden-Aufruf festgelegt werden:

spy.and.callThrough();

Test-Code zu schreiben und vor allem aktuell zu halten stellt immer ein Overhead da der im Projekt berücksichtigt werden muss. Für komplexe Anwendungen ist das Testen jedoch ein wichtiger Baustein für eine stabile Software. Angular tut sein Bestes die Arbeit mit Tests so einfach zu machen wie möglich, nur das Schreiben der Testfälle ist und bleibt eine Aufgabe des Entwicklers.

Wie immer. Alles. Live. In Farbe. Bei Github

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Bitte füllen Sie dieses Feld aus.
Bitte füllen Sie dieses Feld aus.
Bitte gib eine gültige E-Mail-Adresse ein.
Sie müssen den Bedingungen zustimmen, um fortzufahren.

Autor

Diesen Artikel teilen

LinkedIn
Xing

Gibt es noch Fragen?

Fragen beantworten wir sehr gerne! Schreibe uns einfach per Kontaktformular.

Kurse

weitere Blogbeiträge

Work Life Balance. Jobs bei Gedoplan

We are looking for you!

Lust bei GEDOPLAN mitzuarbeiten? Wir suchen immer Verstärkung – egal ob Entwickler, Dozent, Trainerberater oder für unser IT-Marketing! Schau doch einfach mal auf unsere Jobseiten! Wir freuen uns auf Dich!

Work Life Balance. Jobs bei Gedoplan

We are looking for you!

Lust bei GEDOPLAN mitzuarbeiten? Wir suchen immer Verstärkung – egal ob Entwickler, Dozent, Trainerberater oder für unser IT-Marketing! Schau doch einfach mal auf unsere Jobseiten! Wir freuen uns auf Dich!