Zum Inhalt
wannacode

Tests mit node:test

Kurz gesagt: Ein Unit-Test ist Code, der bei jeder Änderung ein kleines Stück prüft, meist eine Funktion; in Node.js schreibst du ihn mit node:test und assert.equal.

Problem

Der Rabatt, der leise kaputtging

Die Regel des Shops lautet: Der Rabatt ist in Prozent, und bei einem Einkauf ab 100 € kommen noch 5% dazu. Du schreibst die Funktion und probierst sie von Hand aus:

main.js
// Rabatt in Prozent. Ab 100 € kommen noch 5% dazu.
function applyDiscount(price, percent) {
if (price > 100) {
percent = percent + 5;
}
return price * (100 - percent) / 100;
}
console.log(applyDiscount(50, 10));
console.log(applyDiscount(200, 10));
Ausgabe
45
170

50 € mit 10% Rabatt sind 45 €, und 200 € mit 15% sind 170 €. Das sieht richtig aus, also gibst du es frei. Eine Woche später zahlt eine Kundin mit einem Einkauf von genau 100 € 90 € statt 85 €. Die Bedingung ist price > 100, sie müsste price >= 100 sein.

Niemand hat genau 100 € ausprobiert. Und bei jeder weiteren Änderung an der Funktion musst du alle Fälle wieder von Hand ausprobieren.

Die Idee

Code, der Code prüft

Statt die Ausgabe mit eigenen Augen zu lesen, schreibst du kleine Funktionen, die deinen Code aufrufen und das Ergebnis mit dem erwarteten vergleichen. Das ist ein Test. Du führst alle Tests mit einem Klick aus, nach jeder Änderung, und sie sagen dir, was kaputtgegangen ist.

Art Was sie prüft Beispiel
Unit-Test ein kleines Stück, meist eine Funktion, getrennt vom Rest applyDiscount(100, 10) gibt 85 zurück
Integrationstest wie die Stücke zusammenarbeiten: mit einer Datenbank, einer Datei, einem anderen Dienst die Bestellung wird in der Datenbank gespeichert und wieder gelesen
End-to-End-Test die ganze Anwendung, so wie ein Mensch sie benutzt die Kundin legt etwas in den Warenkorb, bezahlt und bekommt eine E-Mail
In dieser Lektion schreiben wir Unit-Tests. Node.js hat dafür das eingebaute Modul node:test, und die Prüfungen kommen aus node:assert: Es muss nichts installiert werden.

Warum es sich lohnt:

  • Du siehst den Fehler sofort, solange du noch weißt, was du geändert hast, und nicht erst, wenn sich die Kundin beschwert;
  • der Test zeigt, wie der Code benutzt wird: welcher Fall was zurückgibt;
  • du kannst den Code mutig aufräumen. Wenn die Tests nach der Änderung immer noch grün sind, hat sich das Verhalten nicht geändert.
Genauer, für Neugierige

Es gibt auch andere Arten von Tests: funktionale (erfüllt das Programm die Anforderungen), Performancetests (wie viele Anfragen es aushält), Sicherheitstests (gibt es Lücken) und Tests der Benutzeroberfläche (die Buttons, die Formulare). Alle bauen auf Unit-Tests auf: Von ihnen gibt es die meisten, und sie sind am schnellsten und am billigsten zu pflegen.

Neben node:test wirst du Jest und Vitest begegnen: eigene Bibliotheken, die besonders für Code im Browser benutzt werden. Die Ideen sind dieselben: test, expect, beforeEach.

Sieh es dir an

Der erste Test

applyDiscount steht in der Datei discount.js, mit export vor function, damit eine andere Datei sie importieren kann. Ihre Tests stehen in einer eigenen Datei, discount.test.js:
discount.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyDiscount } from "./discount.js";
test("10% auf 50 €", () => {
assert.equal(applyDiscount(50, 10), 45);
});
test("weitere 5% ab 100 €", () => {
assert.equal(applyDiscount(100, 10), 85);
});
  • test(Name, Funktion) registriert einen Test. Der Name ist ein Text, der sagt, was geprüft wird.
  • assert.equal(tatsächlich, erwartet) vergleicht die beiden Werte mit ===. Wenn sie verschieden sind, schlägt der Test fehl. Wir importieren es aus node:assert/strict: Dort ist jede Prüfung streng.

Wir führen die beiden Tests gegen den Code aus der ersten Karte aus:

Ergebnis
FEHLER weitere 5% ab 100 €: 90 !== 85
OK 10% auf 50 €
Tests: 2, bestanden: 1, fehlgeschlagen: 1

Der erste Test besteht. Der zweite schlägt fehl und sagt genau, was passiert ist: Er hat 85 erwartet, und die Funktion hat 90 zurückgegeben. Du änderst die Bedingung in price >= 100, führst sie noch einmal aus, und beide sind grün:

Ergebnis
OK 10% auf 50 €
OK weitere 5% ab 100 €
Tests: 2, bestanden: 2, fehlgeschlagen: 0

Jeder Test macht drei Dinge: Er bereitet die Werte vor, ruft den Code auf und prüft das Ergebnis.

test("10% auf 50 €", () => {
assert.equal(applyDiscount(50, 10), 45);
});
test("weitere 5% ab 100 €", () => {
assert.equal(applyDiscount(100, 10), 85);
});
Schritt für Schritt, in Worten
  1. Die Datei hat zwei Tests. node:test führt jeden einzeln aus und schaltet ein Lämpchen daneben ein.
  2. Test 1, vorbereiten: ein Preis von 50 € und 10% Rabatt.
  3. Ausführen: applyDiscount(50, 10) gibt 45 zurück.
  4. Prüfen: assert.equal vergleicht den erwarteten Wert 45 mit dem zurückgegebenen 45. Sie sind gleich, also besteht der Test.
  5. Test 2: genau 100 € und 10%. Wir erwarten 85, weil es ab 100 € noch 5% gibt.
  6. Die Bedingung in der Funktion ist price > 100, und 100 ist nicht größer als 100. Die Funktion gibt 90 zurück.
  7. Erwartet 85, zurückgegeben 90: Der Test schlägt fehl und sagt genau das.
  8. Du änderst die Bedingung in price >= 100 und führst die Tests noch einmal aus.
  9. Jetzt erfüllt 100 die Bedingung, und die Funktion gibt 85 zurück.
  10. Erwartet und zurückgegeben stimmen überein. Beide Tests sind grün.
Genauer, für Neugierige
  • Im Projekt sind der Code und die Tests Dateien nebeneinander. Testdateien enden auf .test.js, damit node --test sie findet:
Struktur
discount.js der Code (mit export)
discount.test.js die Tests dazu
  • Du führst sie im Terminal aus. Dort gibt node --test jeden Test mit ✔ oder ✖ aus, wie lange er gedauert hat, und die Einzelheiten zu den fehlgeschlagenen:
Befehle
node --test findet *.test.js und führt sie aus
node --test discount.test.js nur diese Datei
node --test --watch führt sie bei jeder Änderung erneut aus
  • Hier auf der Seite werden die Dateien in einen temporären Ordner geschrieben und mit run() aus node:test ausgeführt. Die Funktion unten hört auf die Ereignisse test:pass und test:fail und gibt die Ergebnisse nach Namen sortiert aus, damit die Ausgabe bei jedem Lauf gleich ist:
Der Test-Runner
import { run } from "node:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
async function runTests(files) {
const dir = mkdtempSync(join(tmpdir(), "tests-"));
for (const [name, code] of Object.entries(files)) {
writeFileSync(join(dir, name), code);
}
const tests = Object.keys(files).filter((name) => name.endsWith(".test.js")).map((name) => join(dir, name));
const lines = [];
let failed = 0;
for await (const { type, data } of run({ files: tests })) {
if (data.details?.type !== "test") {
continue;
}
if (type === "test:pass") {
lines.push(`OK ${data.name}`);
} else if (type === "test:fail") {
failed++;
const error = data.details.error.cause ?? data.details.error;
const message = error.operator === "strictEqual" ? `${error.actual} !== ${error.expected}` : error.message;
lines.push(`FEHLER ${data.name}: ${message}`);
}
}
lines.sort();
for (const line of lines) {
console.log(line);
}
console.log(`Tests: ${lines.length}, bestanden: ${lines.length - failed}, fehlgeschlagen: ${failed}`);
}

Probier es aus

Mehrwertsteuer mit einer Kommazahl

Der Preis mit Mehrwertsteuer ist der Preis mal 1,2. Zwei Tests:

Was zeigen die Tests?

export function withVat(price) {
return price * 1.2;
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { withVat } from "./vat.js";
test("Kaffee", () => {
assert.equal(withVat(10), 12);
});
test("Tee", () => {
assert.equal(withVat(3), 3.6);
});
Antwort

Die Idee

Ja oder nein, und eine erwartete Exception

Neben Gleichheit prüfst du oft eine Bedingung oder dass der Code ablehnt. Hier ist Account aus dem Modul über Exceptions, mit einer neuen Methode canPay:

account.js
export class InsufficientFundsError extends Error {
constructor(shortfall) {
super(`Es fehlen ${shortfall} €`);
this.name = "InsufficientFundsError";
}
}
export class Account {
#balance;
constructor(balance) {
this.#balance = balance;
}
get balance() {
return this.#balance;
}
canPay(amount) {
return amount <= this.#balance;
}
withdraw(amount) {
if (amount <= 0) {
throw new RangeError("Der Betrag muss positiv sein");
}
if (amount > this.#balance) {
throw new InsufficientFundsError(amount - this.#balance);
}
this.#balance -= amount;
}
}
account.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { Account } from "./account.js";
test("kann zahlen, was da ist", () => {
const account = new Account(100);
assert.ok(account.canPay(100));
assert.equal(account.canPay(101), false);
});
test("mehr abheben, als da ist", () => {
const account = new Account(100);
assert.throws(() => account.withdraw(130), {
name: "InsufficientFundsError",
message: "Es fehlen 30 €",
});
assert.equal(account.balance, 100);
});
Ausgabe
OK kann zahlen, was da ist
OK mehr abheben, als da ist
Tests: 2, bestanden: 2, fehlgeschlagen: 0
  • assert.ok(Bedingung) schlägt fehl, wenn die Bedingung nicht wahr ist. Für „ist nicht wahr“ vergleichen wir mit false.
  • assert.throws(Funktion, was) schlägt fehl, wenn die Funktion nicht wirft. Den Code übergibst du als Pfeilfunktion: assert.throws führt sie selbst aus und fängt den Fehler. Das zweite Argument beschreibt den erwarteten Fehler: hier seinen Namen und seine Meldung.

Der zweite Test prüft auch etwas, das man leicht vergisst: Nach der Ablehnung ist der Kontostand derselbe.

Die Idee

Jeder Test beginnt sauber

Hier teilen sich die beiden Tests ein Konto mit 100 €:

account.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { Account } from "./account.js";
const account = new Account(100);
test("40 € abheben lässt 60 € übrig", () => {
account.withdraw(40);
assert.equal(account.balance, 60);
});
test("70 € abheben lässt 30 € übrig", () => {
account.withdraw(70);
assert.equal(account.balance, 30);
});
Ausgabe
FEHLER 70 € abheben lässt 30 € übrig: Es fehlen 10 €
OK 40 € abheben lässt 60 € übrig
Tests: 2, bestanden: 1, fehlgeschlagen: 1

account wird einmal für die ganze Datei erstellt. node:test führt die Tests in der Reihenfolge der Datei aus, deshalb ist der Test für 40 € der erste. Beim zweiten sind noch 60 € übrig, und er hebt 70 € ab. Es reicht, dass jemand die Tests umstellt oder nur den zweiten ausführt, und das Ergebnis ist ein anderes. Ein Test, der von einem anderen Test abhängt, ist eine Falle. Deshalb wird das Konto vor jedem Test neu erstellt, in beforeEach:

account.test.js
import { test, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { Account } from "./account.js";
let account;
beforeEach(() => {
account = new Account(100);
});
test("40 € abheben lässt 60 € übrig", () => {
account.withdraw(40);
assert.equal(account.balance, 60);
});
test("70 € abheben lässt 30 € übrig", () => {
account.withdraw(70);
assert.equal(account.balance, 30);
});
Ausgabe
OK 40 € abheben lässt 60 € übrig
OK 70 € abheben lässt 30 € übrig
Tests: 2, bestanden: 2, fehlgeschlagen: 0
beforeEach aus node:test wird vor jedem Test in der Datei aufgerufen. Der Name des Tests ist ein Text, deshalb kann er ein ganzer Satz sein.
Genauer, für Neugierige
Weitere Dinge aus node:test, denen du begegnen wirst:
node:test
beforeEach / afterEach vor / nach jedem Test (Aufräumen)
before / after einmal vor / nach allen Tests
describe("...", () => { ... }) eine Gruppe zusammengehöriger Tests
test.skip("...", () => { ... }) der Test wird vorerst übersprungen
test.todo("...") ein Test, der noch nicht geschrieben ist
test("...", { timeout: 5000 }, () => { ... })
schlägt fehl, wenn er länger als 5 Sekunden dauert
node --test --test-name-pattern="abheben"
nur die Tests mit „abheben“ im Namen

Die Idee

Eine Prüfung, viele Fälle

Vier Fälle für den Rabatt bedeuten vier fast gleiche Tests. Da test eine normale Funktion ist, rufst du sie in einer Schleife auf: ein Test für jeden Fall „Preis, Prozent, erwartet“:

discount.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyDiscount } from "./discount.js";
const cases = [
[50, 10, 45],
[99, 0, 99],
[100, 10, 85],
[200, 20, 150],
];
for (const [price, percent, expected] of cases) {
test(`${price} € mit ${percent}% = ${expected} €`, () => {
assert.equal(applyDiscount(price, percent), expected);
});
}
Ausgabe
OK 100 € mit 10% = 85 €
OK 200 € mit 20% = 150 €
OK 50 € mit 10% = 45 €
OK 99 € mit 0% = 99 €
Tests: 4, bestanden: 4, fehlgeschlagen: 0

Jeder Fall ist ein eigener Test mit einem eigenen Ergebnis, und sein Name wird mit einem Template-Literal aus den Werten gebildet. Ein neuer Fall ist eine neue Zeile im Array, kein neuer Test. Die wichtigen Fälle liegen an den Grenzen: genau 100 €, 99 €, 0%.

Probier es aus

Finde den Fehler anhand des Tests

Die Lieferung kostet 4 €, ab 50 € ist sie kostenlos. Der Test:

delivery.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { deliveryPrice } from "./delivery.js";
for (const [total, expected] of [[20, 4], [50, 0], [80, 0]]) {
test(`Bestellung über ${total} €: Lieferung ${expected} €`, () => {
assert.equal(deliveryPrice(total), expected);
});
}
Ausgabe
FEHLER Bestellung über 50 €: Lieferung 0 €: 4 !== 0
OK Bestellung über 20 €: Lieferung 4 €
OK Bestellung über 80 €: Lieferung 0 €
Tests: 3, bestanden: 2, fehlgeschlagen: 1

In welcher Zeile des Codes ist der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Zuerst der Test

Die Kasse soll den Gutscheincode HERBST10 für 10% Rabatt annehmen, egal ob er klein oder groß geschrieben ist. Diesmal schreibst du zuerst die Tests:

promo.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { promoPercent } from "./promo.js";
test("bekannter Code", () => {
assert.equal(promoPercent("HERBST10"), 10);
});
test("Groß- und Kleinschreibung egal", () => {
assert.equal(promoPercent("herbst10"), 10);
});
test("unbekannter Code", () => {
assert.equal(promoPercent("KEINER"), 0);
});
  1. Rot. Vorerst gibt die Funktion 0 zurück. Die Tests schlagen fehl, und das ist gut: Es bedeutet, dass sie wirklich etwas prüfen.
export function promoPercent(code) {
return 0;
}
Ausgabe
FEHLER Groß- und Kleinschreibung egal: 0 !== 10
FEHLER bekannter Code: 0 !== 10
OK unbekannter Code
Tests: 3, bestanden: 1, fehlgeschlagen: 2
  1. Grün. Du schreibst den einfachsten Code, mit dem sie bestehen:
export function promoPercent(code) {
if (code.toUpperCase() === "HERBST10") {
return 10;
}
return 0;
}
Ausgabe
OK Groß- und Kleinschreibung egal
OK bekannter Code
OK unbekannter Code
Tests: 3, bestanden: 3, fehlgeschlagen: 0
  1. Aufräumen. Jetzt kannst du die Codes in eine Map verschieben, neue hinzufügen und die Funktion aufräumen. Die Tests sagen dir, wenn du etwas kaputt machst.

Das ist TDD (test-driven development, testgetriebene Entwicklung). Wenn du den Test zuerst schreibst, musst du entscheiden, was genau die Funktion tun soll, bevor du darüber nachdenkst, wie.

Genauer

Suites und Ersatzobjekte

In node:test ist eine Gruppe zusammengehöriger Tests describe: Die Tests darin werden zusammen angezeigt, unter dem Namen der Gruppe. Die Tests der Lieferung, in einer Gruppe gesammelt:

delivery.test.js
import { describe, test } from "node:test";
import assert from "node:assert/strict";
import { deliveryPrice } from "./delivery.js";
describe("Lieferung", () => {
for (const [total, expected] of [[20, 4], [50, 0], [80, 0]]) {
test(`Bestellung über ${total} €: Lieferung ${expected} €`, () => {
assert.equal(deliveryPrice(total), expected);
});
}
});

Eine Suite aus mehreren Dateien ist einfach ein Befehl, der sie zusammen ausführt. Hier sind die drei Dateien der Kasse:

node --test discount.test.js account.test.js delivery.test.js
Ausgabe
OK 10% auf 50 €
OK 40 € abheben lässt 60 € übrig
OK 70 € abheben lässt 30 € übrig
OK Bestellung über 20 €: Lieferung 4 €
OK Bestellung über 50 €: Lieferung 0 €
OK Bestellung über 80 €: Lieferung 0 €
OK weitere 5% ab 100 €
Tests: 7, bestanden: 7, fehlgeschlagen: 0
Genauer, für Neugierige
  • Manchmal hängt der Code von etwas ab, das du in einem Test nicht aufrufen willst: ein Wechselkurs von einer Bank, eine Zahlung, eine E-Mail. Dann übergibst du ein Ersatzobjekt (Mock), das zurückgibt, was du ihm sagst. node:test hat mock.fn(): eine Funktion, die zurückgibt, was du ihr sagst, und sich merkt, wie sie aufgerufen wurde:
converter.test.js
import { test, mock } from "node:test";
import assert from "node:assert/strict";
class Converter {
constructor(rates) {
this.rates = rates;
}
toUsd(eur) {
return eur * this.rates.eurTo("USD");
}
}
test("rechnet mit dem heutigen Kurs um", () => {
const rates = { eurTo: mock.fn(() => 1.1) };
const converter = new Converter(rates);
assert.ok(Math.abs(converter.toUsd(10) - 11) < 0.001);
assert.equal(rates.eurTo.mock.callCount(), 1);
assert.deepEqual(rates.eurTo.mock.calls[0].arguments, ["USD"]);
});
Ausgabe
OK rechnet mit dem heutigen Kurs um
Tests: 1, bestanden: 1, fehlgeschlagen: 0

Das Ersatzobjekt ist ein normales Objekt mit einer Methode eurTo. mock.callCount() sagt dir, wie oft sie aufgerufen wurde, und mock.calls[0].arguments, womit.

  • Weitere Assertions neben assert.equal, assert.ok und assert.throws:
Assertions
assert.notEqual(a, b) sind sie verschieden
assert.deepEqual(a, b) Arrays und Objekte mit demselben Inhalt
assert.notDeepEqual(a, b)
assert.ok(x) die Bedingung ist wahr
assert.match(text, /Muster/) der Text passt zu einem regulären Ausdruck
assert.throws(() => ...) der Code wirft eine Exception
assert.rejects(async () => ...) das Promise endet mit einem Fehler
assert.fail("Meldung") der Test schlägt sofort fehl

Übung

Schreib die Tests

Löse die Aufgaben in deinem Editor, mit node:test (es kommt mit Node selbst).

1. Der Rabattrechner. Schreib discount.test.js für applyDiscount aus der ersten Karte. Deck diese Fälle ab: 80 € mit 10% Rabatt sind 72 €, 100 € mit 0% sind 95 €, 150 € mit 20% sind 112,50 €, 0 € mit 30% sind 0 €, und genau 100 € mit 10% sind 85 €. Führ sie auch gegen die kaputte Version mit price > 100 aus. Genau die beiden Fälle mit 100 € sollen fehlschlagen:

Ergebnis gegen die kaputte Version
FEHLER 100 € mit 0% = 95 €: 100 !== 95
FEHLER genau 100 € bekommt noch 5%: 90 !== 85
OK 0 € mit 30% = 0 €
OK 150 € mit 20% = 112.5 €
OK 80 € mit 10% = 72 €
Tests: 5, bestanden: 3, fehlgeschlagen: 2

2. Das Konto. Schreib account.test.js für Account aus der Karte „Ja oder nein, und eine erwartete Exception“. Erstelle das Konto mit 100 € in beforeEach. Prüfe, dass:

  • 30 € abheben 70 € übrig lässt;
  • du alles abheben kannst und 0 übrig bleibt;
  • 150 € abheben einen InsufficientFundsError mit der Meldung „Es fehlen 50 €“ wirft und der Kontostand 100 € bleibt;
  • 0 und -20 € einen RangeError werfen (Tests in einer Schleife über die beiden Beträge).

3. Der Fehler im Notendurchschnitt. Die Funktion sieht richtig aus:

grades.js
export function average(grades) {
let total = 0;
for (const grade of grades) {
total += grade;
}
return Math.trunc(total / grades.length);
}
Schreib zwei Tests: Der Durchschnitt von 4 und 6 ist 5, und der Durchschnitt von 5 und 6 ist 5,5. Hier reicht assert.equal: 5,5 wird binär genau gespeichert. Einer davon schlägt fehl:
Ergebnis
FEHLER Durchschnitt mit Nachkommateil: 5 !== 5.5
OK ganzer Durchschnitt
Tests: 2, bestanden: 1, fehlgeschlagen: 1

Finde die Ursache und korrigiere die Funktion, damit beide bestehen.

Test

Teste dich

1. Was prüft ein Unit-Test?
2. assert.equal(applyDiscount(100, 10), 85) schlägt fehl mit „90 !== 85“. Was hat die Funktion zurückgegeben?
3. Warum wird das Konto in beforeEach erstellt und nicht einmal ganz oben in der Datei?
4. Wie prüfst du, dass withdraw(130) einen InsufficientFundsError wirft?
5. Was ist die Reihenfolge bei TDD?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·