Skip to content

Verwendung des Test-Runners von Node.js

Node.js verfügt über einen flexiblen und robusten integrierten Test-Runner. Dieser Leitfaden zeigt Ihnen, wie Sie ihn einrichten und verwenden.

bash
example/

 src/
 app/…
 sw/…
 test/
 globals/

 IndexedDb.js
 ServiceWorkerGlobalScope.js
 setup.mjs
 setup.units.mjs
 setup.ui.mjs
bash
npm init -y
npm install --save-dev concurrently
json
{
  "name": "example",
  "scripts": {
    "test": "concurrently --kill-others-on-fail --prefix none npm:test:*",
    "test:sw": "node --import ./test/setup.sw.mjs --test './src/sw/**/*.spec.*'",
    "test:units": "node --import ./test/setup.units.mjs --test './src/app/**/*.spec.*'",
    "test:ui": "node --import ./test/setup.ui.mjs --test './src/app/**/*.test.*'"
  }
}

HINWEIS

Globs erfordern Node v21+, und die Globs selbst müssen in Anführungszeichen stehen (ohne dies erhalten Sie ein anderes Verhalten als erwartet, bei dem es zuerst so aussehen mag, als würde es funktionieren, es aber nicht tut).

Es gibt einige Dinge, die Sie immer möchten, also legen Sie sie in eine Basis-Setup-Datei wie die folgende. Diese Datei wird von anderen, spezielleren Setups importiert.

Allgemeines Setup

js
import { register } from 'node:module';
register('some-typescript-loader');
// TypeScript wird ab hier unterstützt
// ABER andere test/setup.*.mjs-Dateien müssen immer noch reines JavaScript sein!

Erstellen Sie dann für jedes Setup eine dedizierte setup-Datei (stellen Sie sicher, dass die Basisdatei setup.mjs in jede importiert wird). Es gibt eine Reihe von Gründen, die Setups zu isolieren, aber der offensichtlichste Grund ist YAGNI + Leistung: Vieles von dem, was Sie möglicherweise einrichten, sind umgebungsspezifische Mocks/Stubs, die recht kostspielig sein können und Testläufe verlangsamen. Sie möchten diese Kosten vermeiden (buchstäbliches Geld, das Sie für CI bezahlen, Zeit, die Sie warten, bis Tests abgeschlossen sind usw.), wenn Sie sie nicht benötigen.

Jedes der folgenden Beispiele wurde aus realen Projekten übernommen; sie sind möglicherweise nicht für Ihre geeignet/anwendbar, aber jedes demonstriert allgemeine Konzepte, die breit anwendbar sind.

ServiceWorker-Tests

ServiceWorkerGlobalScope enthält sehr spezifische APIs, die in anderen Umgebungen nicht existieren, und einige seiner APIs ähneln scheinbar anderen (z. B. fetch), haben aber ein erweitertes Verhalten. Es ist nicht erwünscht, dass diese in nicht verwandte Tests übergreifen.

js
import { beforeEach } from 'node:test';

import { ServiceWorkerGlobalScope } from './globals/ServiceWorkerGlobalScope.js';

import './setup.mjs'; // 💡

beforeEach(globalSWBeforeEach);
function globalSWBeforeEach() {
  globalThis.self = new ServiceWorkerGlobalScope();
}
js
import assert from 'node:assert/strict';
import { describe, mock, it } from 'node:test';

import { onActivate } from './onActivate.js';

describe('ServiceWorker::onActivate()', () => {
  const globalSelf = globalThis.self;
  const claim = mock.fn(async function mock__claim() {});
  const matchAll = mock.fn(async function mock__matchAll() {});

  class ActivateEvent extends Event {
    constructor(...args) {
      super('activate', ...args);
    }
  }

  before(() => {
    globalThis.self = {
      clients: { claim, matchAll },
    };
  });
  after(() => {
    global.self = globalSelf;
  });

  it('sollte alle Clients beanspruchen', async () => {
    await onActivate(new ActivateEvent());

    assert.equal(claim.mock.callCount(), 1);
    assert.equal(matchAll.mock.callCount(), 1);
  });
});

Snapshot-Tests

Diese wurden von Jest populär gemacht; mittlerweile implementieren viele Bibliotheken solche Funktionalitäten, einschließlich Node.js ab v22.3.0. Es gibt verschiedene Anwendungsfälle, wie z. B. die Überprüfung der Ausgabe von Komponenten-Renderings und Infrastructure as Code-Konfigurationen. Das Konzept ist unabhängig vom Anwendungsfall dasselbe.

Es ist keine spezielle Konfiguration erforderlich, außer der Aktivierung der Funktion über --experimental-test-snapshots. Um die optionale Konfiguration zu demonstrieren, würden Sie wahrscheinlich etwas wie das Folgende zu einer Ihrer bestehenden Testkonfigurationsdateien hinzufügen.

Standardmäßig generiert Node einen Dateinamen, der mit der Syntaxhervorhebungserkennung inkompatibel ist: .js.snapshot. Die generierte Datei ist eigentlich eine CJS-Datei, daher wäre ein geeigneterer Dateiname .snapshot.cjs (oder kürzer .snap.cjs wie unten); dies wird auch in ESM-Projekten besser gehandhabt.

js
import { basename, dirname, extname, join } from 'node:path';
import { snapshot } from 'node:test';

snapshot.setResolveSnapshotPath(generateSnapshotPath);
/**
 * @param {string} testFilePath '/tmp/foo.test.js'
 * @returns {string} '/tmp/foo.test.snap.cjs'
 */
function generateSnapshotPath(testFilePath) {
  const ext = extname(testFilePath);
  const filename = basename(testFilePath, ext);
  const base = dirname(testFilePath);

  return join(base, `${filename}.snap.cjs`);
}

Das folgende Beispiel demonstriert Snapshot-Tests mit der Testing-Bibliothek für UI-Komponenten; beachten Sie die beiden verschiedenen Arten, auf assert.snapshot zuzugreifen):

js
import { describe, it } from 'node:test';

import { prettyDOM } from '@testing-library/dom';
import { render } from '@testing-library/react'; // Beliebiges Framework (z. B. Svelte)

import { SomeComponent } from './SomeComponent.jsx';


describe('<SomeComponent>', () => {
  // Für Personen, die die "Fat-Arrow"-Syntax bevorzugen, ist das Folgende wahrscheinlich besser für die Konsistenz
  it('sollte Standardwerte rendern, wenn keine Props bereitgestellt werden', (t) => {
    const component = render(<SomeComponent />).container.firstChild;

    t.assert.snapshot(prettyDOM(component));
  });

  it('sollte `foo` verwenden, wenn es bereitgestellt wird', function() {
    const component = render(<SomeComponent foo="bar" />).container.firstChild;

    this.assert.snapshot(prettyDOM(component));
    // `this` funktioniert nur, wenn `function` verwendet wird (nicht "Fat Arrow").
  });
});

WARNING

assert.snapshot stammt aus dem Testkontext (t oder this), nicht von node:assert. Dies ist notwendig, da der Testkontext Zugriff auf einen Bereich hat, der für node:assert unmöglich ist (Sie müssten ihn jedes Mal manuell bereitstellen, wenn assert.snapshot verwendet wird, wie z. B. snapshot (this, value), was ziemlich mühsam wäre).

Unit-Tests

Unit-Tests sind die einfachsten Tests und erfordern im Allgemeinen relativ wenig Spezielles. Der Großteil Ihrer Tests wird wahrscheinlich Unit-Tests sein, daher ist es wichtig, dieses Setup minimal zu halten, da eine geringe Verringerung der Setup-Leistung sich verstärkt und kaskadiert.

js
import { register } from 'node:module';

import './setup.mjs'; // 💡

register('some-plaintext-loader');
// Plaintext-Dateien wie GraphQL können nun importiert werden:
// import GET_ME from 'get-me.gql'; GET_ME = '
js
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';

import { Cat } from './Cat.js';
import { Fish } from './Fish.js';
import { Plastic } from './Plastic.js';

describe('Cat', () => {
  it('sollte Fisch essen', () => {
    const cat = new Cat();
    const fish = new Fish();

    assert.doesNotThrow(() => cat.eat(fish));
  });

  it('sollte KEINEN Kunststoff essen', () => {
    const cat = new Cat();
    const plastic = new Plastic();

    assert.throws(() => cat.eat(plastic));
  });
});

Tests der Benutzeroberfläche

UI-Tests erfordern im Allgemeinen ein DOM und möglicherweise andere browserspezifische APIs (wie z. B. IndexedDb, die unten verwendet wird). Diese sind in der Regel sehr kompliziert und teuer einzurichten.

Wenn Sie eine API wie IndexedDb verwenden, diese aber sehr isoliert ist, ist ein globaler Mock wie unten vielleicht nicht der richtige Weg. Verschieben Sie stattdessen dieses beforeEach in den spezifischen Test, in dem auf IndexedDb zugegriffen wird. Beachten Sie, dass, wenn auf das Modul, das auf IndexedDb (oder was auch immer) zugreift, selbst weit zugegriffen wird, entweder dieses Modul mocken (wahrscheinlich die bessere Option) oder dies hier behalten.

js
import { register } from 'node:module';

// ⚠️ Stellen Sie sicher, dass nur 1 Instanz von JSDom instanziiert wird; mehrere führen zu vielen 🤬
import jsdom from 'global-jsdom';

import './setup.units.mjs'; // 💡

import { IndexedDb } from './globals/IndexedDb.js';

register('some-css-modules-loader');

jsdom(undefined, {
  url: 'https://test.example.com', // ⚠️ Das Nichtangeben dessen führt wahrscheinlich zu vielen 🤬
});

// Beispiel für die Dekoration einer globalen Variable.
// JSDOMs `history` behandelt keine Navigation; das Folgende behandelt die meisten Fälle.
const pushState = globalThis.history.pushState.bind(globalThis.history);
globalThis.history.pushState = function mock_pushState(data, unused, url) {
  pushState(data, unused, url);
  globalThis.location.assign(url);
};

beforeEach(globalUIBeforeEach);
function globalUIBeforeEach() {
  globalThis.indexedDb = new IndexedDb();
}

Sie können 2 verschiedene Ebenen von UI-Tests haben: einen Unit-ähnlichen (bei dem Externe und Abhängigkeiten gemockt werden) und einen eher End-to-End-ähnlichen (bei dem nur Externe wie IndexedDb gemockt werden, der Rest der Kette aber real ist). Ersteres ist im Allgemeinen die reinere Option, und letzteres wird im Allgemeinen auf einen vollständig automatisierten End-to-End-Usability-Test über etwas wie Playwright oder Puppeteer verschoben. Nachfolgend ein Beispiel für Ersteres.

js
import { before, describe, mock, it } from 'node:test';

import { screen } from '@testing-library/dom';
import { render } from '@testing-library/react'; // Jedes Framework (z.B. Svelte)

// ⚠️ Beachten Sie, dass SomeOtherComponent KEIN statischer Import ist;
// dies ist notwendig, um das Mocken seiner eigenen Importe zu ermöglichen.


describe('<SomeOtherComponent>', () => {
  let SomeOtherComponent;
  let calcSomeValue;

  before(async () => {
    // ⚠️ Die Reihenfolge ist wichtig: Der Mock muss eingerichtet werden, BEVOR sein Konsument importiert wird.

    // Erfordert, dass `--experimental-test-module-mocks` gesetzt ist.
    calcSomeValue = mock.module('./calcSomeValue.js', { calcSomeValue: mock.fn() });

    ({ SomeOtherComponent } = await import('./SomeOtherComponent.jsx'));
  });

  describe('wenn calcSomeValue fehlschlägt', () => {
    // Dies sollten Sie nicht mit einem Snapshot handhaben, da dies brüchig wäre:
    // Wenn unwesentliche Aktualisierungen an der Fehlermeldung vorgenommen werden,
    // würde der Snapshot-Test fälschlicherweise fehlschlagen
    // (und der Snapshot müsste ohne echten Wert aktualisiert werden).

    it('sollte anmutig fehlschlagen, indem eine hübsche Fehlermeldung angezeigt wird', () => {
      calcSomeValue.mockImplementation(function mock__calcSomeValue() { return null });

      render(<SomeOtherComponent>);

      const errorMessage = screen.queryByText('unable');

      assert.ok(errorMessage);
    });
  });
});