2023
Juli
13
2023

Type-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

Zurück zur Übersicht