Jest ade: Node's integrierter Test-Runner als Alternative
Node Test Runner vs. Jest: stabile Funktionen, Watch Mode, Snapshots, Fake Timers, Coverage, TypeScript-Support und Grenzen der Migration.
Nodes integrierter Test-Runner kann Jest für die meisten serverseitigen Testsuites ersetzen. Der Runner selbst ist seit Node 20.0.0 stable, und Watch-Modus, Snapshot-Testing sowie Fake Timers existieren heute allesamt – auch wenn ältere Migrationsleitfäden sie noch als fehlend auflisten.
Der Frust, der diese Migration antreibt, ist hinlänglich bekannt. Dein Service ist reines ESM, doch dein Test-Kommando zieht eine Transform-Pipeline, eine Konfigurationsdatei und einen Abhängigkeitsbaum mit sich, der bei Major-Upgrades bricht – und das alles nur, um Funktionen auszuführen und Ergebnisse zu prüfen. Die offene Frage ist nicht, ob node:test existiert, sondern welche Teile davon stabil genug sind, um sie in die CI einzubinden. Der folgende Text geht die Feature-Oberfläche der Reihe nach durch, nennt den Stabilitätsgrad jedes Bausteins gemäß der Dokumentation des Node-Test-Runners und legt dar, worauf du im Vergleich zu Jest und Vitest verzichtest.
Die wichtigsten Erkenntnisse
- Nodes Test-Runner ist seit v20.0.0 stable, Coverage erfordert jedoch weiterhin das experimentelle Flag
--experimental-test-coverage, und auch der Watch-Modus ist als experimentell gekennzeichnet. - Snapshot-Testing kam mit v22.3.0 und wurde in v23.4.0 stable; Fake Timers über
mock.timerssind seit v23.1.0 stable und könnenDatemocken. - ES-Modul-Exporte sind eingefroren, daher kann
mock.methodkeinen benannten Export ersetzen; exportiere stattdessen ein Objekt oder nutze das experimentellemock.module()hinter--experimental-test-module-mocks. .ts-Testdateien laufen ohne Loader, da Type Stripping standardmäßig aktiv und seit v24.12.0 stable ist.- Was du beim Abschied von Jest aufgibst, sind keine Features, sondern Ergonomie: das Matcher-Vokabular, die jsdom-Umgebung und einzeilige Stub-Helfer wie
mockResolvedValue.
Was bekommst du ohne jede Abhängigkeit?
Die Zero-Dependency-Basis besteht aus node:test für die Struktur und node:assert für Assertions, ausgeführt mit node --test. Du erhältst describe/it (Aliase für suite/test), die Hooks before/after/beforeEach/afterEach, Subtests, skip und todo sowie einen Exit-Code ungleich null im Fehlerfall.
// math.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert';
describe('add', () => {
it('sums two numbers', () => {
assert.strictEqual(1 + 2, 3);
});
});
node --test
Die Stabilität gilt pro Feature, nicht pro Modul – und genau diese Unterscheidung zählt, bevor du eine Pipeline darauf festlegst. So sieht der Stand der einzelnen Bausteine aus:
| Feature | Flag / API | Status | Version |
|---|---|---|---|
| Runner-Kern | node --test | Stable | Stable seit v20.0.0 |
| Watch-Modus | --watch | Experimentell | Hinzugefügt in v19.2.0 |
| Snapshots | t.assert.snapshot() | Stable | Hinzugefügt in v22.3.0, stable ab v23.4.0 |
| Fake Timers | mock.timers | Stable | Stable seit v23.1.0 |
| Coverage | --experimental-test-coverage | Experimentell | – |
| Modul-Mocking | mock.module() | Frühe Entwicklungsphase | Hinzugefügt in v22.3.0 / v20.18.0 |
| Test-Tags | --experimental-test-tag-filter | Frühe Entwicklungsphase | Hinzugefügt in v26.2.0, backportiert auf v24.19.0 |
| TypeScript Type Stripping | standardmäßig aktiv | Stable | Stable seit v24.12.0 |
Ausführen und Filtern mit dem Node-Test-Runner
Ohne Argumente findet node --test Dateien, die auf **/*.test.{cjs,mjs,js}, **/*-test.{cjs,mjs,js}, **/*_test.{cjs,mjs,js}, **/test-*.{cjs,mjs,js}, **/test.{cjs,mjs,js} und **/test/**/*.{cjs,mjs,js} passen – zusätzlich dieselben sechs Muster mit {cts,mts,ts}, sofern du Type Stripping nicht mit --no-strip-types abschaltest. Du kannst auch explizite Globs als Argumente übergeben.
Das Filtern lässt sich direkt auf gewohnte Jest-Praktiken übertragen:
node --test --test-name-pattern="parses headers" # like jest -t
node --test --test-skip-pattern="integration" # inverse filter
node --test --test-only # honor { only: true }
--test-only ist der Baustein, den Jest-Nutzer als Erstes vermissen: Einen Test mit { only: true } zu markieren, bewirkt nichts, solange das Flag nicht übergeben wird. Test-Tags kamen mit --experimental-test-tag-filter in v26.2.0 und wurden in v24.19.0 auf die LTS-Linie backportiert – beide in früher Entwicklungsphase. Die Filtersyntax ist auf beiden Linien nicht identisch: v26 akzeptiert boolesche Ausdrücke und Wildcards, während 24.x auf literale Tag-Namen matcht. So oder so ist „frühe Entwicklungsphase“ zu unreif, um eine Pipeline davon abhängig zu machen.
Watch-Modus
Den Watch-Modus gibt es, und er wird mit node --test --watch aufgerufen. Er behält deine Testdateien und die von ihnen eingebundenen Module im Blick und führt anschließend alles erneut aus, was von einer Änderung betroffen ist. Die Dokumentation kennzeichnet den Watch-Modus weiterhin als Stability 1, Experimental, hinzugefügt in v19.2.0. In der Praxis heißt das: Als lokale Entwicklungsschleife ist er völlig in Ordnung, aus CI-Skripten sollte er dagegen herausbleiben – dort wird er ohnehin nicht gebraucht.
{
"scripts": {
"test": "node --test",
"test:watch": "node --test --watch"
}
}
Coverage steckt weiterhin hinter einem Flag
Code Coverage erfordert nach wie vor --experimental-test-coverage; eine Pipeline mit Coverage-Gate setzt damit bewusst auf eine instabile Oberfläche. Grenze mit den Globs --test-coverage-include und --test-coverage-exclude ein, was gemessen wird, und gib für die CI maschinenlesbare Ausgaben über den lcov-Reporter aus:
node --test --experimental-test-coverage \
--test-coverage-include='src/**' \
--test-reporter=lcov --test-reporter-destination=lcov.info
Auch Schwellenwerte lassen sich erzwingen, über --test-coverage-lines, --test-coverage-branches und --test-coverage-functions oder über die entsprechenden Optionen lineCoverage, branchCoverage und functionCoverage der programmatischen run()-API. Die weiteren eingebauten Reporter sind spec (der Standard), tap, dot und junit.
Mocking: Spies, Timer und die Mauer eingefrorener Exporte
Das mock-Objekt aus node:test deckt Spies (mock.fn), Method-Stubs (mock.method) und Fake Timers (mock.timers) ab. Ein mockResolvedValue gibt es nicht; asynchrone Ergebnisse stubbst du über eine asynchrone mockImplementation. Assertions liest du statt über Matcher aus mock.callCount() und mock.calls[n].arguments:
import { test } from 'node:test';
import assert from 'node:assert';
test('spy records calls', (t) => {
const fn = t.mock.fn();
fn('a');
assert.strictEqual(fn.mock.callCount(), 1);
assert.deepStrictEqual(fn.mock.calls[0].arguments, ['a']);
});
Fake Timers sind seit v23.1.0 stable und mocken setTimeout, setInterval, setImmediate sowie Date; vorgespult wird mit tick() oder runAll(). Eine Lücke solltest du kennen: Holst du dir einen Timer per Destrukturierung aus einem Modul, etwa mit import { setTimeout } from 'node:timers', greift der Mock dafür nicht.
test('advances mocked time and Date together', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100 });
const fn = t.mock.fn();
setTimeout(fn, 200);
t.mock.timers.tick(200);
assert.strictEqual(fn.mock.callCount(), 1);
assert.strictEqual(Date.now(), 300);
});
Die eigentliche Einschränkung ist das Modul-Mocking. ES-Modul-Exporte sind eingefroren, daher kann mock.method keinen benannten Export ersetzen; der dauerhaft tragfähige Workaround besteht darin, ein Objekt zu exportieren und die Methode darauf zu mocken:
// before: cannot be stubbed
export function fetchUser(id) { /* ... */ }
// after: stubbable with mock.method(api, 'fetchUser')
export const api = {
fetchUser(id) { /* ... */ },
};
Node liefert durchaus eine offizielle Alternative mit: mock.module() mockt ESM-, CJS-, JSON- und Builtin-Module, sitzt aber hinter --experimental-test-module-mocks und befindet sich in früher Entwicklungsphase. Nutze es zum Experimentieren, nicht als Fundament einer CI-Suite.
TypeScript ohne Loader
Node führt .ts-, .mts- und .cts-Testdateien direkt über Type Stripping aus. Dieses ist standardmäßig aktiviert (seit v23.6.0 und v22.18.0) und seit v24.12.0 stable, also stable auf der LTS-Linie 24.x. Der Test-Runner erfasst TypeScript-Dateimuster automatisch, sofern du nicht --no-strip-types übergibst. Das ältere Rezept, einen Loader wie tsx einzubinden – beschrieben in Mehul Kars Migrationsbeitrag aus der Node-20-Ära –, gehört für die Testausführung inzwischen der Vergangenheit an. Da Stripping allerdings lediglich Typen entfernt, benötigen enum und andere zur Laufzeit relevante TS-Syntax weiterhin eine Transformation.
Worauf verzichtest du gegenüber Jest und Vitest?
Der ehrliche Kompromiss betrifft die Ergonomie, nicht den Funktionsumfang. Drei Einbußen sind real. Erstens das Matcher-Ökosystem: Jests expect bietet dir toHaveBeenNthCalledWith und Hunderte Community-Matcher, während dich node:assert Assertions aus deepStrictEqual und mock.calls zusammensetzen lässt. Der Erweiterungspunkt ist assert.register(), hinzugefügt in v23.7.0 und v22.14.0, womit sich eigene Assertions auf dem Test-Kontext definieren lassen. Zweitens browserähnliche Umgebungen: Ein Äquivalent zu jsdom oder happy-dom existiert nicht, daher sollten Komponententests mit DOM-Zugriff bei Vitest oder Jest bleiben. Drittens der Komfort beim Stubben: kein mockResolvedValue, kein test.each (eine for...of-Schleife erledigt das), und Stubbing pro Aufruf läuft über mockImplementationOnce statt über verkettete Helfer. Erick Wendels Migrationsleitfaden bildet diese Entsprechungen Paar für Paar ab, allerdings stammt der Abschnitt zu Fake Timers aus der Zeit vor der fertigen mock.timers-API und liest sich wie ein Entwurfsvorschlag.
Was bedeutet das für eine zu migrierende Testsuite?
Für einen Node-Service, ein CLI oder eine Library ohne DOM-Berührung deckt der integrierte Runner den stabilen Kern dessen ab, was Jest geleistet hat – ohne Abhängigkeiten und ohne Transform-Schicht; die verbleibenden experimentellen Ränder sind Coverage, Watch-Modus, Modul-Mocking und Tags. Ein risikoarmer Weg besteht darin, ein Paket zu konvertieren, das Coverage-Gating so lange beim bestehenden Tooling zu belassen, bis das Flag wegfällt, und matcherlastige Assertions dann umzuschreiben, wenn du sie ohnehin anfasst. Führe node --test gegen eine einzelne konvertierte Datei aus und sieh nach, wie viel von deinem Konfigurationsverzeichnis du löschen kannst.
FAQs
Führt node --test Testdateien parallel aus?
Ja. Prozessisolation ist der Standard, jede Testdatei erhält also ihren eigenen Kindprozess, und --test-concurrency legt fest, wie viele davon gleichzeitig laufen dürfen. Innerhalb einer einzelnen Datei laufen Tests weiterhin nacheinander, sofern du keine concurrency-Option auf test oder describe setzt. Wenn sich deine Suites eine Datenbank, einen Port oder globalen Zustand teilen, beschränkt --test-concurrency=1 die Ausführung auf jeweils eine Datei.
Kann ich Jest und node:test während einer Migration parallel betreiben?
Ja. Die Runner sind voneinander unabhängig, du kannst also getrennte npm-Skripte pflegen und Datei für Datei migrieren. Der Haken ist die überlappende Dateierkennung: Beide matchen standardmäßig Dateien wie *.test.js, grenze daher jeden Runner mit expliziten Globs, getrennten Verzeichnissen oder Jests testMatch-Einstellung ein, damit konvertierte Dateien nicht doppelt laufen und nicht konvertierte Dateien nicht unter node --test fehlschlagen.
Funktioniert node:test mit CommonJS-Projekten?
Ja. Der Runner ist gegenüber dem Modulsystem agnostisch: require('node:test') und require('node:assert') funktionieren in CommonJS-Dateien, und die Standard-Erkennungsmuster schließen .cjs neben .mjs und .js ausdrücklich ein. Die einzige Voraussetzung ist das node:-Schema, weshalb require('test') oder import test from 'test' fehlschlägt. Eine gemischte Codebasis kann ESM- und CJS-Testdateien im selben node --test-Aufruf ausführen.
Welche Node-Version sollte ich anvisieren, um node:test in der CI einzusetzen?
Node 24 LTS deckt den stabilen Kern ab: den Runner (stable seit v20.0.0), Snapshot-Testing, Fake Timers über mock.timers und das standardmäßige TypeScript Type Stripping. Coverage und Watch-Modus bleiben auf jeder Release-Linie experimentell. Zwei neuere Test-Runner-Features erreichten 24.x per Backport statt auf der Current-Linie zu bleiben – Test-Tags in v24.19.0 und die Randomisierung der Ausführungsreihenfolge in v24.16.0 –, doch beide befinden sich in früher Entwicklungsphase, baue also noch keine CI-Gates darauf auf.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k