Angular Unit-Tests von importierten ES6 Modulen

Wie bei vielen modernen Frameworks ist Dependency Injection in Angular fest eingebaut – aus gutem Grund. Auf anschauliche Art und Weise erklärt die Dokumentation uns, warum dieses Entwurfsmuster so wichtig ist. Aber viele JavaScript-Projekte stellen lediglich Module bereit, die nicht einfach instanziiert und damit per Dependency Injection injiziert werden können. Eine Lösungsmöglichkeit bieten Proxys.

ES6 Module in Angular und Typescript

Genau wie Imports in ECMAScript 6 sind importierte Module in TypeScript read-only Ansichten auf ihre Exporte. Im Prinzip bedeutet das, dass importierte Variablen wie lokale const Variablen behandelt werden können und importierte Objekte wie lokale, eingefrorene Objekte.

Stellt ein JavaScript-Framework ein UMD-Modul bereit, lässt es sich zwar ganz leicht importieren und benutzen:

import * as farmerModule from 'farmer-module';

@Component({selector: 'app-veggie', templateUrl: 'app-veggie.component.html'})
class VeggieComponent {
  // ...

  someMethod() {
    farmerModule.potato(this.salad);
  }
}

Doch ordentliche Software will getestet sein. Beginnt man, für solch eine Komponente Unit-Tests zu schreiben, stößt man schnell an die angesprochenen Grenzen dieses Ansatzes: Weder lässt sich das komplette Modul per Dependency Injection austauschen (es wird ja gar nicht injiziert), noch einzelne Funktionen im Modul durch gemockte ersetzen.

Proxy to the rescue

Mit ES6 haben jedoch Proxys in JavaScript Einzug gehalten, die ein beliebiges Objekt ummanteln können und Zugriffe auf Properties, Funktionsaufrufe und vieles mehr abfangen. Da dies auch problemlos für importierte Module funktioniert, bietet es uns die notwendigen Mittel, auch nicht-injizierbare, nicht-editierbare Objekte in Unit-Tests zu mocken.

Proxy als Wrapper für Dependency Injectable Module

Dazu erstellen wir eine Klasse, die einen Proxy vom Typ des Moduls erweitert, sodass sie in Typescript alle Funktionen des Moduls als Methoden anbietet. Aufgrund von Sprachbeschränkungen sieht die Notation etwas klobig aus:

import * as farmerModule from 'farmer-module';

@Injectable({
  providedIn: 'root',
  useFactory: Farmer.create
})
export class Farmer {
  static create(handler?: ProxyHandler<typeof farmerModule>) {
    if (typeof handler === 'undefined') {
      handler = {};
    }
    return new Proxy(farmerModule, handler);
  }

Wir können nun das ummantelte Modul wie jedes andere Injectable als Dependency notieren und verwenden.

@Component({
  selector: 'app-veggie', templateUrl: 'app-veggie.component.html'})
class VeggieComponent {
  constructor(
    private farmer: Farmer
  ){}
  // ...

  someMethod() {
    this.farmer.potato(this.salad)
  }
}

Mocks am Proxy anbringen

Das Modul als Dependency injizieren zu können ist schon die halbe Miete. Denn nun können wir theoretisch das ganze Objekt gegen einen Mock austauschen (nebenbei: dafür hätte die Factory am Injectable gereicht, der Proxy wäre gar nicht unbedingt notwendig gewesen). Was aber, wenn wir nur einzelne Methoden mocken möchten? Auch hierfür bieten uns die Proxys eine Möglichkeit, indem wir eine sogenannte Trap am Propertyzugriff anbringen. Dazu müssen wir zunächst ein Handler-Objekt erzeugen, das wir später dem Proxy mitgeben.

Mit diesem Handler können wir alle Zugriffe abfangen. Im Normalfall reichen wir sie jedoch einfach durch – nur wenn sie eine zu mockende Methode anfordern, springen wir ein. Den dazu notwendigen Code findest du am Ende des Artikels in der Funktion proxyMock. Die Verwendung dieser Funktion erleichtert das Anbringen eines Handlers, indem sie ein Objekt (den späteren Handler), einen String (den Namen der zu mockenden Funktion) und eine Factory zu einer Funktion annimmt. Im zugehörigen Test (farmer.spec.ts) ergänzen wir daher folgendes:

const farmerModuleHandlers: ProxyHandler<typeof farmerModule> = {}; 
let potatoCalled = false;

proxyMock(farmerModuleHandlers, 'potato', (oldPotato) => {
    return function (salad) { // do not use an arrow function here!
      /* Log the function call:
      potatoCalled = true;
      */

      /* Implement new functionality at will: 
      const tomato = this.growTomato();
      salad.add(tomato);
      */
      
      // or simply pass on the function call to the mocked function
      return oldPotato.apply(this, arguments);
    };
  });

Ein wichtiges Detail ist dabei, keine Arrow-Function zurückzugeben, da diese den Kontext der umgebenden Funktion fangen würde und this daher nicht auf das zu mockende Objekt zeigt. Schließlich fehlt uns nur noch, einen Mock zu erzeugen und ihn der Dependency Injection vorzuwerfen:

beforeEach(() => {
  TestBed.configureTestingModule({
    providers: [
      { provide: Farmer, useValue: new Proxy(farmerModule, farmerModuleHandlers); }
    ]
  })
});

Das war ja… leicht

Alles in allem bietet uns ES6 mit Proxys ein sehr mächtiges Werkzeug, das sich wunderbar zum Wrappen und Mocken von legacy UMD-Modulen in Angular einsetzen lässt. Mit ein wenig Boilerplate (folgt) lassen sich die meisten der Hürden, die diese Methode hat, verstecken. Wie gehst du mit legacy Modulen in deinen Angular Apps um? Ich freue mich auf Diskussion in den Kommentaren.

Anhang

Für eine proxyMock Funktion nehme man:

  • Etwas syntaktischen Zucker (obj as { [key: string]: any, [key: number]: any }), um implizite any Typen zu verhindern – eine häufig verwendete typescript-Option.
  • Eine default-Trap, die immer einspringt, wenn keine andere explizit an die Reihe kommt und einfach den ungemockten Wert ihres Properties zurückgibt.
  • Pro Aufruf der proxyMock-Methode eine Trap, die für die gemockte Funktion den Mock zurückliefert und ansonsten auf zuvor gesetzte Traps zurück fällt – in etwa wie in switch-case, allerdings durch Funktionsaufrufe. Falls es keine weiteren Mocks gibt, fällt sie auf die default-Trap zurück.
// n-wertige Funktion, die auf n-wertige Funktionen abbildet, die auf any abbilden
type wrapperFactory = (...args: any[]) => (...args: any[]) => any;

function proxyMock<T extends object>(targetHandler: ProxyHandler<T>,
    method: string, trapFactory: wrapperFactory):  void {

  // beim ersten Aufruf einen durchreichenden default-get Handler anlegen
  if (typeof targetHandler.get === 'undefined') {
    targetHandler.get = function (obj, prop): any {
      // symbole werden in ts nicht als index unterstützt
      if (typeof prop === 'symbol') { return null; }

      // Indexsignatur zu obj hinzufügen, um 'any' Typ explizit zu machen
      return (obj as { [key: string]: any, [key: number]: any })[prop];
    };
  }

  // weitere Aufrufe fügen neue Traps hinzu
  const oldTrap = targetHandler.get;
  targetHandler.get = function(obj, prop, receiver): any {
    // wenn die zu mockende Funktion angefragt wird, gib die Funktion aus der Factory zurück
    if (prop === method) {
      const property = (obj as { [key: string]: any, [key: number]: any })[prop];
      return trapFactory(property);
    }

    // ansonsten gib die Kontrolle an die vorherige Trap weiter
    return oldTrap(obj, prop, receiver);
  };
}
Tilman Adler
Letzte Artikel von Tilman Adler (Alle anzeigen)