News
ZurückType-safe Mocking von Interfaces in TypeScript
Wie wir schon in einigen Posts erklärt haben, lieben wir bei cloudscale.ch automatisiertes Testen. Ausserdem sind wir grosse Fans von typensicheren Sprachen. Seit wir in unserem Front-end GUI immer stärker auf TypeScript setzen, stellt sich dementsprechend auch vermehrt die Frage, wie wir gute Tests für unseren TypeScript Code schreiben können. In diesem Beitrag werden wir daher ein bisschen tiefer in die Unit-Testing und TypeScript Welt eintauchen und mit euch ein Code-Snippet teilen, welches unser Leben massiv vereinfacht hat.
Der Code, welchen wir für die Beispiele in diesem Beitrag verwenden, ist eine etwas vereinfachte Version von tatsächlichem Code, welcher die Daten für folgende React Komponente berechnet (unseren Usern dürfte diese View bekannt vorkommen):
Aus den von der API gelesenen Daten wird sowohl die Server-Liste als auch die Zusammenfassung generiert.
Es geht also darum, für eine gegebene Liste von Servern folgende Information zu ermitteln:
- Anzahl Server
- Summe des Memory der Server
- Summe der Grösse aller Volumes der Server
- Summe der täglichen Kosten der Server
Verschaffen wir uns als Erstes einen Überblick über den entsprechenden TypeScript Code:
export interface Server {
name: string
daily: number
memory: number
volumes: Volume[]
}
export interface Volume {
type: 'ssd' | 'bulk'
size: number
}
export interface ServerSummary {
count: number
totalMemory: number
totalStorage: number
totalCost: number
}
export const getServerSummary = (servers: Server[]): ServerSummary => {
const count = servers.length;
const totalCost = servers.reduce((accu, s) => accu + s.daily, 0);
const totalMemory = servers.reduce((accu, s) => accu + s.memory, 0);
const volumes = servers.reduce<Volume[]>((accu, s) => accu.concat(s.volumes), []);
const totalStorage = volumes.reduce((accu, v) => accu + v.size, 0);
return {count, totalCost, totalMemory, totalStorage};
}
Die beiden ersten Interfaces Server
und Volume
sind die Datentypen für den Input. Als Nächstes folgt das Interface ServerSummary
, welches die vier zu berechnenden Werte enthält. Als Letztes sehen wir die Funktion getServerSummary
, unser Testsubjekt. Für die Berechnung der Summen verwenden wir hier Array.reduce()
.
Im nächsten Schritt schauen wir uns den dazugehörigen Unit-Test an, welcher ebenfalls in TypeScript implementiert wurde:
test('test getServerSummary', () => {
// arrange
const servers: Server[] = [
{name: 'server1', daily: 1, memory: 4, volumes: [{size: 50, type: 'ssd'}]},
{name: 'server2', daily: 2, memory: 8, volumes: [{size: 10, type: 'ssd'}, {size: 200, type: 'bulk'}]},
]
// act
const actual = getServerSummary(servers)
// assert
const expected: ServerSummary = {
count: 2,
totalCost: 3,
totalMemory: 12,
totalStorage: 260,
};
expect(actual).toEqual(expected)
});
Dies ist ein klassischer Unit-Test gemäss dem Arrange, Act and Assert (AAA) Pattern:
- Arrange: wir definieren zwei
Server
Instanzen mit Testdaten. - Act: wir rufen die Funktion
getServerSummary
auf. - Assert: wir vergleichen das effektive mit dem erwarteten Resultat.
Dieser Test funktioniert einwandfrei. Aber bei genauer Betrachtung fällt uns Folgendes auf: Da wir TypeScript verwenden und die Properties Server.name
und Volume.type
nicht optional sind, müssen wir sie in den Testdaten (siehe Arrange) auch befüllen, obwohl sie für den Test-Case nicht relevant sind. Wir können name
und type
zwar entfernen und der Unit-Test läuft weiterhin durch, aber der TypeScript Compiler gibt uns in diesem Fall eine Fehlermeldung.
In diesem kleinen Beispiel mag es noch okay sein, wenn man diese nicht benötigten Testdaten spezifizieren muss, aber bei komplizierteren, geschachtelten Strukturen kann dies sehr schnell unangenehm werden.
Ein erster naiver Versuch um das Problem zu lösen waren TypeScript Type Assertions:
const servers: Server[] = [
{daily: 1, memory: 4, volumes: [{size: 50}]} as unknown as Server,
{daily: 2, memory: 8, volumes: [{size: 10, type: 'ssd'}, {size: 200,}]} as unknown as Server,
]
Nun müssen wir die überflüssigen Attribute nicht mehr angeben, aber wir haben auch die Type-safety eingebüsst. Denn wenn wir jetzt fälschlicherweise bei {size: 50}
einen falschen Namen oder Typen verwenden wie z.B. {sizeGb: 'x'}
, erhalten wir keinen Kompilierfehler und ein unintuitives Test-Resultat.
Nach einigem Experimentieren und mehreren Verbesserungen sind wir bei folgendem Test-Helper angelangt:
function mockPartially<T extends object>(mockedProperties: Partial<T> = {}): T {
const handler = {
get(target: T, prop: keyof T & string) {
if (prop in mockedProperties) {
return mockedProperties[prop];
}
throw new Error(`Mock does not implement property: ${prop}, but it was accessed.`);
},
};
return new Proxy<T>({} as T, handler);
}
mockPartially()
erstellt für einen beliebigen Typen T
ein Proxy-Objekt. Als mockedProperties
kann ein Objekt mit einem beliebigen Subset von Properties von T
übergeben werden. Dies geschieht mithilfe des Typen-Konstruktors Partial
. Partial<T>
erstellt einen neuen Typ, bei welchem alle Properties von T
auf optional gesetzt werden. Mithilfe des Proxy-Objekts können wir ein beliebiges Verhalten beim Zugriff auf Properties des Objekts implementieren. In unserem Fall geben wir eine Fehlermeldung zurück, wenn die fragliche Property in mockedProperties
nicht angegeben wurde. Wurde die Property angegeben, dann wird ihr Wert unverändert zurückgegeben.
mockPartially
importieren wir jeweils als mP
und können damit die Testdaten wie folgt definieren:
const servers: Server[] = [
mP<Server>({daily: 1, memory: 4, volumes: [mP<Volume>({size: 50})]}),
mP<Server>({daily: 2, memory: 8, volumes: [mP<Volume>({size: 10}), mP<Volume>({size: 200})]}),
]
Diese Lösung hat für uns folgende Vorteile:
- Die unnötigen Input-Daten können wir weglassen.
- Wenn wir benötigte Daten vergessen, erhalten wir eine einfach verständliche Fehlermeldung, wie z.B.:
Mock does not implement property: volumes, but it was accessed.
- Bei falschen Testdaten wie
{sizeGb: 50}
oder{size: 'x'}
erhalten wir einfach verständliche Fehler vom TypeScript Compiler.
Wir hoffen, dieser etwas detailliertere Einblick in den Alltag unserer Software-Entwickler hat dich interessiert, und vielleicht kannst du unseren mockPartially
Helper ja sogar selber brauchen.
(Not) mocking!
Dein cloudscale.ch-Team