Recap – ViTest statt Jest

Meine Erfahrung beim Umzug von Jest zu ViTest – mit dem Schwerpunkt auf Test mit dem Dateisystem. Es hat sich gelohnt und ich verrate euch warum!

7 Minuten
Hero image

Die aktuelle Situation

Beim Aufsetzen des Tech-Stacks für mein aktuelles Projekt stellte ich mir die Frage, welches Test-Framework für das NodeJS Backend zum Einsatz kommt. Ich habe in jüngster Vergangenheit öfter von ViTest als gute Alternative zu Jest gelesen. Insbesondere die Geschwindigkeit, in der Jest-Tests ausgeführt werden, hat meinen Arbeitsfluss regelmäßig behindert. Dies ging stellenweise so weit, dass ich die Tests unregelmäßig ausgeführt habe. Mit dem Fokus auf Systeme, die Gewohnheiten unterstützen, musste ich mich dringend anpassen. Denn testgetriebene Entwicklung ist ein fundamentaler Baustein meiner Arbeitsweise. Passenderweise hat ViTest als besonderen Vorteil gegenüber Jest die Performance aufgelistet – also probiere ich es einfach mal aus.

Zusätzlich zum Umstieg spielt bei dem aktuellen Projekt das Dateisystem eine zentrale Rolle. Entgegen bisheriger Nutzung von Cloud-Storage gilt es in diesem Fall, im lokalen Dateisystem zu arbeiten. Um sowohl die Ausführungszeit akzeptabel zu halten, als auch die Testisolation und -stabilität zu gewährleisten, habe ich nach einer In-Memory-Dateisystem-Lösung für die Tests gesucht und bin fündig geworden – ViTest benennt in der eigenen Dokumentation eine unterstützte Bibliothek: memfs.

In den kommenden Abschnitten beschreibe ich meinen Umzug von Jest zu ViTest inklusive der Konfiguration von ViTest mit memfs und meinen Learnings dabei.

Der Umstieg

ViTest Set-up

Der erste große Schritt ist die Einrichtung des neuen Frameworks. Im Zusammenspiel mit TypeScript hat sich dies regelmäßig als schwierig herausgestellt. Sei es, die Importe der eigenen Dateien relativ zum src Verzeichnis via Präfix einzurichten und darauf aus dem tests Verzeichnis Zugriff zu haben – sowohl bei der Testausführung als auch beim AutoComplete in der IDE.

Glücklicherweise erwies sich dies bei ViTest als sehr einfach, mithilfe einer einzigen Config-Datei konnte ich alles intuitiv und einfach einrichten und es lief auf Anhieb. Hier meine vitest.config.js im Root-Verzeichnis meines Projekts – unter resolve findet sich das Geheimnis für den lokalen Import Alias:

import path from 'path'

export default {
  test: {
    globals: true,
    environment: 'node',
    clearMocks: true,
    globalSetup: 'tests/helper/globalSetup.ts',
    setupFiles: [
      'dotenv/config',
      'tests/helper/setupTests.ts',
      'tests/helper/setupTestEnvVars.ts',
      'tests/helper/customMatchers/toHaveBeenCalledWithScopes.ts',
    ],
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
    },
  },
}

Weiter interessant sind die Set-up-Konfigurationen. Ich habe mir angewöhnt, im tests Verzeichnis einen Ordner helper anzulegen und dort meine Testkonfiguration und weiteren Hilfsklassen abzulegen. Diesen Ordner kann ich von Projekt zu Projekt kopieren und meine allgemeine Einstellung zügig übernehmen, Zeit bei der Entwicklung sparen und schrittweise weiterentwickeln, was meinen Kunden zugutekommt.

Set-up Helper

In der globalSetup bereite ich die InMemory-MongoDb Instanz für meine Integrationstests vor. In der Regel möchte ich mich jenseits meiner Unit-Tests absichern, dass der Durchstich funktioniert – z. B. ein API-Create-Aufruf zu Event → Event gelesen und in Read-Model → API-Get-Aufruf das Ergebnis beinhaltet.

In der setupTests bereite ich all die Services, die ich standardmäßig gemockt wissen möchte, vor. Unter anderem auch mein Dateisystem-Mock – mehr dazu weiter unten.

In der setupTestEnvVars definiere ich, wie der Name bereits verrät, die Werte meiner Environment-Variablen als process.env Wert.

Danach folgen meine Custom Matcher, die ich für die Projekte benötige – oben illustriert mit customMatchers/toHaveBeenCalledWithScopes.

Umstellung der Mocks

Ich bin sehr dankbar, dass ViTest zu 99 % die gleiche Syntax nutzt wie Jest. Somit konnte ich aus sämtlichen Jest-Aufrufen Vi-Aufrufe machen – hier das einfachste Beispiel: jest.fn() wird vi.fn(). Genauso ist es mit der Syntax für Mocks. Das macht den Umstieg selbst innerhalb eines Projekts schlank und einfach.

ViTest und das Dateisystem

Das Set-up

Um das In-Memory-Dateisystem zu nutzen, gilt es das NPM-Paket zu installieren

npm install -D memfs

Im Rahmen der Hilfsdateien stelle ich sicher, dass sämtliche Aufrufe auf Node-Dateisystem-Funktionen standardmäßig gemockt sind. Sowohl die normalen als auch die Promise-basierten Aufrufe. Dazu füge ich folgenden Code in die setupTests Datei:

// --- tests/helper/setupTest.ts

// mock fs
vi.mock('node:fs', async () => {
  const memfs: { fs: typeof fs } = await vi.importActual('memfs')

  return { default: memfs.fs, ...memfs.fs }
})

// mock fs.promises
vi.mock('node:fs/promises', async () => {
  const memfs: { fs: typeof fs } = await vi.importActual('memfs')

  return { default: memfs.fs.promises, ...memfs.fs.promises }
})

// reset the state of in-memory fs on each test 
beforeEach(() => {
  vol.reset()
})

Diese Art und Weise funktioniert zum einen für TypeScript. Zum anderen benötigst du keine weiteren Dateien in dem tests/__mocks__ Ordner.

Der erste Test

Mit dem obigen Set-up kannst du in deinen Tests die Inhalte deines Dateisystems definieren. Ich habe ein wenig gebraucht, zu verstehen, wie ich ein leeres Verzeichnis erstelle, darum das Beispiel für einen einfachen Test, der prüft, ob das Verzeichnis wirklich leer ist:

// simulate an empty dir this way
vol.fromNestedJSON({ 'empty-dir-name': {} }, 'path-to-empty-dir')

// read dir
const result = await fs.readdir('path-to-empty-dir/empty-dir-name')
expect(result).toEqual([])

Special-Case Zugriffsrechte beim Löschen

In einem meiner Testfälle galt es, folgende Anforderungen zu prüfen:

  1. Löschen einer Datei, wenn sie vorhanden ist
  2. Schritt übergehen, wenn die Datei nicht vorhanden ist
  3. Wenn beim Löschen Zugriffsrechte fehlen, den Fehler zu ignorieren und fortzufahren
  4. Bei anderen Fehlern, den Fehler durchzureichen

Mit memfs war es einfach die Punkte 1 und 2 zu prüfen. Leider unterstützt memfs keine Zugriffsrechte, sodass ich für Anforderung 3 und 4 einen Work-around bauen musste, den ich hier teilen möchte.

Als Erstes habe ich mir eine Hilfsklasse für den Dateisystem-Fehler gebaut:

// test helper class
class ErrorWithCode extends Error {
  public readonly code: string

  constructor(message: string, code: string) {
    super(message)
    this.code = code
  }
}

export default ErrorWithCode

In meinem Test klemme ich mich in die unlink Funktion ein und werfe meine neue Fehlerklasse. Für Anforderung 3 mit dem Error-Code EACCES und für Anforderung 4 einen beliebigen anderen Error-Code.

// in test 
vi.spyOn(fs, 'unlink').mockImplementation(() => {
    throw new ErrorWithCode('EACCES: permission denied', 'EACCES')
  })

Das Ergebnis im Produktivcode ist wie folgt:

try {
  await fs.unlink('some/path')
} catch (e: unknown) {
  if (e && typeof e === 'object' && 'code' in e && e.code == 'EACCES') {
    // handle this case
    return
  }

  // handle other cases
}

Special-Case Zugriffsrechte beim Schreiben

Um zu testen, wie sich das System bei fehlenden Schreibrechten verhält, kann ich ähnlich vorgehen. Ich überschreibe das passende writeFile und werfe einen Fehler. Hierbei musst du darauf achten, ob du im Code die Variante mit Promise oder ohne nutzt und die entsprechende Methode mocken. Ich habe anfangs nicht genau hingeschaut und mich gewundert, warum mein Mock nicht funktioniert.

Hier ein Beispiel zur Veranschaulichung

 import { fs } from 'memfs'

 // depending on which fs method you use in your prod code
 // -> mock the fs.promise.writeFile or fs.writeFile
 fs.promises.writeFile = vi.fn().mockRejectedValueOnce(
  new Error('Sth went wrong')
)

Fazit

Der Umstieg war außergewöhnlich einfach und schnell erledigt. Die Konfiguration der Tests und TypeScript ist problemlos in wenigen Minuten erledigt. Dadurch, dass ich bereits mit Jest vertraut war, schreiben sich die Tests wie gewohnt in ViTest.

Die Performance ist grandios. Die Ausführung blitzschnell. Ich kann meine Tests wieder im Watch-Mode laufen und mir instantan Feedback geben lassen, ob das funktioniert, was ich tue. Die Entscheidung zu wechseln war goldrichtig und kann ich dir nur ans Herz legen.

Und der großartige Bonus, mithilfe von memfs ein In-Memory-Dateisystem zu haben, ist unschlagbar. Das hat mir viel Zeit erspart und ist für meine Testfälle mehr als genügend. Einzig nervig war die Notwendigkeit für Hacks bei den Zugriffsrechten. Gemäß den Diskussionen in den Github Threads wäre dieses Feature nicht so einfach umzusetzen und daher verständlich, dass es nicht verfügbar ist.

call to action background image

Abonniere meinen Newsletter

Erhalte einmal im Monat Nachrichten aus den Bereichen Softwareentwicklung und Kommunikation gespikt mit Buch- und Linkempfehlungen.