In unserer Angular Anwendung KreditSmart nutzen wir zum Testen Karma, Jasmine angereichert mit Chai und Sinon. In vielen Komponenten- und Service-Tests versuchen wir die Abhängigkeiten zu mocken bzw. zu stubben. Das kann schon mal einige extra Zeilen Quellcode mit sich bringen. Beim Zugriff auf die Stubs möchten wir zudem gerne Codevervollständigung seitens der IDE oder des Editors beim Zugriff und Typsicherheit haben. Der Stub soll die gleichen Methoden wie der gemockte Service haben. Als Rückgabewert sollen die Methoden jedoch Sinon-Stubs liefern. Das ermöglicht uns in den Tests Rückgabewerte zu definieren oder Prüfungen auf Methodenaufrufe durchzuführen. Die Typen der Service-Feldern können erhalten bleiben.

Typisierung

Der Typ unseres Mocks ist Mock<T>, wobei T der zu mockende Service oder eine andere Klasse sein kann. Die TypeScript Deklaration sieht folgendermaßen aus:

import { SinonStub, stub } from "sinon";

type GenericMethod = (...args: any[]) => any | void;

export type Mock<T> = {
    -readonly [K in keyof T]: T[K] extends GenericMethod ? SinonStub : Partial<T[K]> 
};

GenericMethod stellt eine beliebige Funktion dar. Gehen wir nun den ersten Teil der Typdeklaration von Mock<T> durch.

-readonly [K in keyof T]

Hier wird über alle Properties K (also Felder und Methoden) des zu mockenden Services T iteriert. Durch -readonly wird die readonly-Eigenschaft  sämtlicher Properties entfernt und wir können auch diese Felder im Test setzen.

T[K] extends GenericMethod ? SinonStub : Partial<T[K]>

Wenn Property K eine Methode (respektive Funktion) ist, so ist der Rückgabewert vom Typ SinonStub. Andernfalls ist es ein Feld, dessen Typ aus dem Originalservice übernommen wird.

Jedoch wrappen wir den Typ noch in ein Partial. Somit können wir bei Bedarf auch Methoden des Feldes mocken.  In unserer Anwendung haben wir etwa Felder vom Typ RxJS-Subject und wollen dessen `next`-Methode für den Test stubben. Würden wir das Partial weglassen, müssten wir sämtliche Methoden von Subject stubben - das wollen wir nicht ;) .

Automatisiertes Mocken aller Methoden

Um automatisch sämtliche Methoden einer Klasse zu mocken, bedienen wir uns der Hilfsmethode mock<T>:

type Constructor<T> = new (...args: any[]) => T;

export function mock<T>(constructor: Constructor<T>): Mock<T> {
  const result = {};
  Object.keys(constructor.prototype).forEach((method) => {
    result[method] = stub();
  });
  return result as Mock<T>;
}

Constructor<T> ist, wie der Name schon sagt, eine Konstruktor-Funktion. Im TypeScript Umfeld ist es also unsere Klasse, die wir Mocken wollen. Dies liegt daran, dass JavaScript's Klassenkonzept nicht Klassen-basiert ist wie in Java oder C++, sondern Prototypen-basiert. Mehr zum Vergleich der beiden Konzepte. Die Verwendung von Klassen in TypeScript und auch neueren JavaScript-Versionen ist syntaktischer Zucker.

Schauen wir uns die Deklaration der Methode mock<T> genauer an:

mock<T>(constructor: Constructor<T>, initial?: Partial<Mock<T>>): Mock<T>

mock<T> erwartet also eine TypeScript-Klasse als Parameter und liefert einen Mock zurück. Ein Mock ist ein Objekt bei dem alle Methoden erhalten bleiben, jedoch als Rückgabewert einen SinonStub liefern. Die Felder bleiben erhalten. Bei der Verwendung der Methode ist es jedoch nicht notwendig
mock<MyService>(MyService) zu schreiben. Hier reicht mock(MyService) aus, da TypeScript T in der Funktion ableiten kann.

const result = initial || {};
Object.keys(constructor.prototype).forEach((method) => {
  result[method] = stub();
});
return result as Mock<T>;

Die Implementierung iteriert daraufhin alle Properties der Klasse durch. Achtung - zur Laufzeit gehen Typinformationen verloren. Über den Prototyp eines Objektes können wir aber zumindest auf die Methoden des Objektes bzw. der Klasse zugreifen. Das reicht für unseren Fall aus, da die Implementierung der Klassen-Felder zur Laufzeit unverändert bleibt und wir nur die Typisierung zur Compile-Zeit brauchen.

Falls die obige Implementierung noch etwas funktionaler sein soll, lässt sich die mock-Funktion auch folgendermaßen abändern:

export const mock = <T>(constructor: Constructor<T>, intial?: Partial<Mock<T>>): Mock<T> =>
   Object.keys(constructor.prototype)
     .reduce((res, method) => ({
       ...res,
       [method]: stub()
     }), initial || {}) as Mock<T>;

Anwendung

Wie sieht das ganze nun in der Anwendung aus? Bevor wir unsere Hilfskonstrukte hatten, sah das Setup in den Test folgendermaßen aus:

const myService = {
	oneMethod: stub(),
    anotherMethod: stub(),
    thirdMethod: stub(),
    oneObservable$: { next: stub() },
    someProperty : 42 
}
const anotherService = {
	fooMethod: stub(),
    barMethod: stub()
}

Mit unseren Konstrukten können wir das nun folgendermaßen schreiben:

const myService: Mock<MyService> = mock(MyService, {
	oneObservable$: {next: stub()},
    someProperty: 42
});
const anotherService: Mock<AnotherService> = mock(AnotherService);

Aufgrund von Type-Inference könnten wir sogar noch die Typdeklarationen der beiden Services weglassen:

const myService = mock(MyService, {
	oneObservable$: {next: stub()},
    someProperty: 42
});
const anotherService = mock(AnotherService);

Nicht nur ist der Code weniger verbose, wir erhalten auch noch gute Codevorschläge im jeweiligen Editor und mehr Typsicherheit.

Credits

Die obigen Konstrukte sind in Zusammenarbeit mit Robin Baum und Marc Redemske entstanden.