Back openDesk Edu for a sovereign, open-source education — every vote counts.
Vote nowSave products you love by clicking the heart icon.
Evidenzbasierte Testing-Praxis aus Produktions-Codebasen — property-basierte Invarianten, gezielte Fehlerinjektion, Contract-Tests, benchmark-verifizierte Performance und Qualitäts-Gates, die durchgesetzt werden, nicht nur versprochen.
Jeder Maintainer kennt die Form dieses Fehlers: Ein Dependency-Bot öffnet eine routinemäßige Versions-Aktualisierung, CI wird rot, und die Differenz zwischen „läuft auf meinem Rechner" und „scheitert in der Pipeline" frisst einen Nachmittag. Dieser hier hat drei Tage gefressen — und die Ursache war eine Verhaltensänderung in jsdom, über die fast niemand geschrieben hat.
Die betroffene Seite ist eine Next.js-16-Anwendung mit einem typischen Enterprise-Test-Setup: Vitest im jsdom-Modus, rund 600 Unit-Tests, eine Pipeline, die bei jedem Push Typecheck, Lint, Unit-Tests und eine Audit-Schwelle ausführt. Die Pipeline war monatelang grün.
Dann landete ein Dependency-PR: next 16.2.12 → 16.3.4 (eine kritische
Sicherheits-Advisory, das musste rein), was auf dem Weg @types/react auf
19.2.x mitzog. Es folgten zwei Fehlerwellen — und die waren nicht verwandt.
Genau das machte das Debuggen so quälend.
Die React-19-Typdefinitionen haben mehrere Interfaces geteilt und verschärft.
ReactElement-Children-Props, die früher als ReactNode abgeleitet wurden,
wurden zu unknown, die OpenGraph- und Twitter-Metadaten-Typen drifteten
auseinander, und querySelector-Aufrufe ohne Generics lieferten plötzlich
Typen, die nicht mehr zu ihren Aufrufstellen passten. 69 Fehler in zwölf
Testdateien. Mühsam, aber ehrliche Arbeit: Casts, wo der Test mehr weiß als das
Typsystem, Generics auf den DOM-Queries, ein paar ungenutzte Variablen, die die
strengere Inferenz zutage förderte. Eine Stunde, erledigt.
Dieser Teil war erwartet. Was kam, nicht.
Mit grünem Typecheck leuchtete die Unit-Suite auf: ThemeProvider — und
schließlich über zwanzig Komponenten dahinter — stürzte beim Import ab mit
localStorage is not defined. Nicht „permission denied", kein Quota-Fehler.
Das Global existierte einfach nicht.
Der verwirrende Teil: Die Anwendung selbst war in Ordnung. In jedem echten
Browser existiert window.localStorage. Nur die Testumgebung hatte es
verloren. Der Übeltäter: jsdom 29 bindet keine localStorage-Implementierung
mehr standardmäßig in die Umgebung ein. Node gibt einen leisen Hinweis, wenn
man danach sucht — ExperimentalWarning: localStorage is not available because --localstorage-file was not provided — aber in einer Wand roter
Vitest-Ausgaben liest niemand Warnungen.
Die Fehleroberfläche war auch irreführend. Die abgestürzten Komponenten waren nicht die, die localStorage stark nutzen; es waren die, die es nur einmal, defensiv, innerhalb eines jahrealten Feature-Detection-Guards anfassen:
const stored = window.localStorage.getItem("theme"); // wirft, wenn localStorage undefined ist
Ein Guard, für Browser geschrieben — „wenn die API existiert, nutze sie" —
setzt voraus, dass die API entweder funktioniert oder nicht existiert. jsdom 29
schuf eine dritte Welt: Der Bezeichner existiert als Konzept, aber das Global
ist undefined, sodass Feature-Detection per Zugriff wirft.
Die verlockenden Fixes sind alle auf dieselbe Art schlecht:
Der richtige Fix liegt an der Testgrenze, in setup.ts — genau dort, wo
Umgebungsannahmen hingehören. Eine minimale, Map-basierte
Storage-Implementierung — der eigentliche Vertrag ist winzig: getItem,
setItem, removeItem, clear, key und ein length — definiert auf
globalThis und window:
class StoragePolyfill {
private map = new Map<string, string>();
get length() {
return this.map.size;
}
getItem(k: string) {
return this.map.has(k) ? this.map.get(k)! : null;
}
setItem(k: string, v: string) {
this.map.set(String(k), String(v));
}
removeItem(k: string) {
this.map.delete(k);
}
clear() {
this.map.clear();
}
key(i: number) {
return [...this.map.keys()][i] ?? null;
}
}
const storage = new StoragePolyfill();
Object.defineProperty(globalThis, "localStorage", { value: storage });
Object.defineProperty(window, "localStorage", { value: storage });
ZWanzig Zeilen, ein Ort, alle Tests grün — und es verhält sich für
Komponententests nah genug am Original: Werte überdauern Aufrufe innerhalb
eines Tests, JSON.parse(getItem(...)) funktioniert hin und zurück, clear()
setzt zwischen Suiten zurück. Wenn ein Test wirklich Quota-Fehler oder
Persistenz-Semantik braucht, ist das ein Signal, dass er Storage explizit
mocken sollte — das Polyfill ist der Boden, nicht die Decke.
Mit allen Tests lokal grün gepusht — und CI blieb rot. Gleicher Commit,
gleicher Code. Der Unterschied: Toolchain-Drift. Mein lokaler Checkout
löst TypeScript über einen Bun-Lockfile, der bereits TypeScript 6.x mit
anderem Strictness-Verhalten gezogen hatte. CI installiert aus
package-lock.json mit npm ci — TypeScript 5.9.3 — und sieht Fehler, die
der lokale Baum nicht zeigt. Lektion nochmal auf die harte Tour gelernt:
Pipeline-Fixes mit der Toolchain der Pipeline verifizieren. npm ci in
einem sauberen Worktree, Suite laufen lassen, dann pushen. Das Repo ist die
Quelle der Wahrheit, nicht dein Laptop.
@types/*-Änderungen und Testumgebungs-Dependencies verdienen dieselbe
Aufmerksamkeit wie next selbst.setup.ts, die prüft, dass die Browser-Globals, die
deine App voraussetzt, wirklich existieren — das verwandelt einen Nachmittag
Stack-Trace-Archäologie in einen expliziten, lesbaren Fehler.setup.ts existiert
genau dafür, dass Produktionscode nie erfährt, was jsdom ist.Die Fünf-Zeilen-Zusammenfassung: Deine Tests laufen nicht in einem Browser.
Etwa einmal im Jahr wird der Zwischenraum zwischen „browserförmig" und „ein
Browser" renoviert, und etwas, auf das du dich verlassen hast, hört still auf zu
existieren. Diesmal war es localStorage. Das nächste Mal ist es etwas
anderes — und ein Zehn-Zeilen-Canary in deiner Setup-Datei ist eine billige
Versicherung dagegen, es aus 600 roten Tests statt aus einem zu erfahren.