Tests, CI und Code-Qualität — Dinge kaputt machen, bevor Benutzer es tun (Teil 5/6)
Features bauen ist der spaßige Teil. Sicherstellen, dass sie tatsächlich funktionieren — und weiter funktionieren — ist der verantwortungsvolle Teil. In diesem Beitrag richten wir die Test-Pipeline ein, schreiben sinnvolle Tests für unsere Planning-Poker-App und verkabeln GitHub Actions, damit nichts Kaputtes in Produktion landet.
Der Testing-Stack
| Tool | Zweck |
|---|---|
| Vitest | Test-Runner (für Vite gebaut, schnell, ESM-nativ) |
| Testing Library | DOM-Assertions + User-Event-Simulation |
| jsdom | Browser-Umgebung in Node |
@vitest/coverage-v8 | Coverage-Reporting mit V8 |
Warum Vitest statt Jest? Weil das Projekt Vite-basiert ist. Vitest nutzt deine bestehende Vite-Config wieder — gleiche Aliase, gleiche Transforms, alles gleich. Null Konfigurationsreibung.
Vitest einrichten
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["__tests__/setup.ts"],
globals: true,
coverage: {
provider: "v8",
reporter: ["text", "html"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/pages/**", // UI-Seiten — auf Integrationsebene getestet
"src/components/Layout/**",
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});
80% Coverage-Schwelle über die gesamte Breite. Nicht 100% — das incentiviert, die Metrik zu gamen, statt nützliche Tests zu schreiben. 80% bedeutet: teste das Wichtige, überspringe die trivialen Getter.
Beachte den exclude auf Pages und Layout — das sind UI-Integrationsoberflächen, die besser mit E2E-Tools getestet werden. Unit-Tests für eine 500-Zeilen-React-Komponente, die bedingt basierend auf dem Raum-State rendert, sind fragil. Besser den Store und die Utilities testen, von denen diese Komponenten abhängen.
Test-Setup
// __tests__/setup.ts
import "@testing-library/jest-dom";
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
});
// Polyfills für jsdom
if (!window.matchMedia) {
window.matchMedia = (q) => ({
matches: false,
media: q,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
onchange: null,
});
}
if (!window.ResizeObserver) {
window.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
}
Drei Dinge passieren hier:
- Testing-Library-Matcher importieren —
toBeInTheDocument(),toHaveTextContent()usw. - Storage zwischen Tests leeren — kein State-Leaking zwischen Verschlüsselungsschlüssel-Tests und Identity-Tests
- jsdom-Lücken polyfilllen —
matchMediaundResizeObserverexistieren in jsdom nicht, werden aber von HeroUI-Komponenten verwendet
Das Crypto-Modul testen
Das ist das wichtigste Modul zum Testen. Wenn Verschlüsselung kaputt geht, geht die ganze App kaputt:
describe("crypto", () => {
it("generiert einzigartiges Schlüsselmaterial", () => {
const key1 = generateKeyMaterial();
const key2 = generateKeyMaterial();
expect(key1).not.toBe(key2);
// base64url: nur alphanumerisch, -, _
expect(key1).toMatch(/^[A-Za-z0-9_-]+$/);
});
it("verschlüsselt und entschlüsselt JSON im Round-Trip", async () => {
const keyB64 = generateKeyMaterial();
const cryptoKey = await importKeyMaterial(keyB64);
const data = { room: "test", players: [{ name: "Alice" }] };
const encrypted = await encryptJSON(data, cryptoKey);
const decrypted = await decryptJSON(encrypted, cryptoKey);
expect(decrypted).toEqual(data);
});
it("lehnt Entschlüsselung mit falschem Schlüssel ab", async () => {
const key1 = await importKeyMaterial(generateKeyMaterial());
const key2 = await importKeyMaterial(generateKeyMaterial());
const encrypted = await encryptJSON({ secret: "data" }, key1);
await expect(decryptJSON(encrypted, key2)).rejects.toThrow();
});
it("speichert Schlüssel in Session write-once", () => {
const stored1 = storeKeyInSession("room1", "key-aaa");
expect(stored1).toBe(true);
const stored2 = storeKeyInSession("room1", "key-bbb");
expect(stored2).toBe(false); // abgelehnt — write-once
expect(loadKeyFromSession("room1")).toBe("key-aaa"); // Original behalten
});
});
Der Round-Trip-Test ist der kritischste. Wenn encryptJSON → decryptJSON dir nicht exakt das zurückgibt, was du reingesteckt hast, ist Game Over.
Der Falsch-Schlüssel-Test verifiziert die Authentifizierung von AES-GCM: Entschlüsselung mit einem anderen Schlüssel sollte werfen, nicht still Müll zurückgeben. Das ist wichtig, weil AES-GCM ein authentifiziertes Chiffre ist — es erkennt Manipulation.
Die Deck-Config testen
Scheinbar trivial, aber Decks treiben die gesamte Abstimmungs-UI:
describe("decks", () => {
it("jedes Deck hat eine eindeutige ID, die seinem Key entspricht", () => {
Object.entries(DECKS).forEach(([key, deck]) => {
expect(deck.id).toBe(key);
});
});
it("jedes Deck hat mindestens 3 Karten", () => {
Object.values(DECKS).forEach((deck) => {
expect(deck.cards.length).toBeGreaterThanOrEqual(3);
});
});
it("Fibonacci-Deck enthält ? und ☕", () => {
expect(DECKS.fibonacci.cards).toContain("?");
expect(DECKS.fibonacci.cards).toContain("☕");
});
it("hat mindestens 10 einzigartige Spieler-Emojis", () => {
const unique = new Set(PLAYER_EMOJIS);
expect(unique.size).toBe(PLAYER_EMOJIS.length);
expect(PLAYER_EMOJIS.length).toBeGreaterThanOrEqual(10);
});
});
Wenn jemand versehentlich die ?-Karte aus dem Fibonacci-Deck löscht, fängt dieser Test es ab, bevor es in Produktion landet. Kleiner Test, großes Sicherheitsnetz.
Den Zustand-Store testen
Im Store lebt die Geschäftslogik, also bekommt er die gründlichsten Tests:
describe("usePokerStore", () => {
it("setzt und persistiert die Spieler-Identität", () => {
const { setPlayer } = usePokerStore.getState();
setPlayer("p1", "Alice", "🦊");
const state = usePokerStore.getState();
expect(state.playerId).toBe("p1");
expect(state.playerName).toBe("Alice");
expect(state.playerEmoji).toBe("🦊");
});
it("lehnt Raum-Updates mit falscher ID ab", () => {
const store = usePokerStore.getState();
// Aktuellen Raum setzen
store._setRoom({ id: "room-1", /* ... */ } as Room);
// Update für einen anderen Raum versuchen
store._setRoom({ id: "room-2", /* ... */ } as Room);
// Sollte ignoriert werden
expect(usePokerStore.getState().room?.id).toBe("room-1");
});
});
Der Raum-ID-Mismatch-Test ist subtil, aber wichtig. Während SSE-Wiederverbindungen gibt es ein winziges Fenster, in dem eine alte Subscription ein Update für einen vorherigen Raum liefern könnte. Die _setRoom-Methode des Stores verwirft diese still.
Den Adapter testen
Die UpstashAdapter-Tests mocken fetch global:
describe("UpstashAdapter", () => {
let adapter: UpstashAdapter;
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
adapter = new UpstashAdapter({ workerUrl: "https://worker.example.com" });
});
it("routet Befehle durch /upstash", async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ result: null }),
});
await adapter.getRoom("room-1");
expect(fetchMock).toHaveBeenCalledWith(
"https://worker.example.com/upstash",
expect.objectContaining({
method: "POST",
body: JSON.stringify(["GET", "pp:room:room-1"]),
}),
);
});
it("nutzt keepalive für Raum-Löschung", async () => {
fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) });
adapter.destroyRoom("room-1");
expect(fetchMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ keepalive: true }),
);
});
});
Der keepalive: true-Test für die Raum-Löschung ist kritisch. Wenn ein Benutzer den Tab schließt, feuert beforeunload einen „Raum löschen"-Request. Ohne keepalive würde der Browser den Request abbrechen, während die Seite sich entlädt. Mit keepalive überlebt der Request den Seitentod.
Komponenten testen
Komponenten-Tests nutzen den benutzerzentrierten Ansatz von Testing Library:
describe("VotingDeck", () => {
it("rendert alle Karten im Deck", () => {
const cards = ["1", "2", "3", "5", "?"];
render(<VotingDeck cards={cards} selectedCard={null} onSelect={() => {}} />);
cards.forEach((card) => {
expect(screen.getByText(card)).toBeInTheDocument();
});
});
it("hebt die ausgewählte Karte hervor", () => {
const { getByText } = render(
<VotingDeck cards={["1", "2", "3"]} selectedCard="2" onSelect={() => {}} />
);
expect(getByText("2").closest("button")).toHaveClass("ring-accent");
});
it("ruft onSelect auf, wenn eine Karte geklickt wird", async () => {
const onSelect = vi.fn();
render(<VotingDeck cards={["1", "2", "3"]} selectedCard={null} onSelect={onSelect} />);
await userEvent.click(screen.getByText("3"));
expect(onSelect).toHaveBeenCalledWith("3");
});
});
Keine interne State-Inspektion. Kein wrapper.instance(). Nur „Erscheint das Richtige auf dem Bildschirm?" und „Tut ein Klick darauf das Richtige?" — so wie ein echter Benutzer interagieren würde.
Der Logger
Sogar der Logger bekommt einen Test:
describe("logger", () => {
it("unterdrückt die Ausgabe, wenn VITE_DEBUG_LOGGING nicht 'true' ist", () => {
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
logger.log("test message");
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
});
Feature-Flags in Tests fangen versehentliche Logging-Lecks ab. Du willst nicht, dass [Upstash] Subscribing to SSE for room abc123 in der Produktionskonsole auftaucht.
GitHub Actions CI
Die CI-Pipeline ist minimal und effektiv:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v5
- uses: actions/setup-node@v6
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Format check
run: pnpm format:check
- name: Lint
run: pnpm lint
- name: Test
run: pnpm test
Vier Tore: Format → Lint → Test. Wenn ein Schritt fehlschlägt, kann der PR nicht gemergt werden.
pnpm format:check— Prettier-Verifizierung. Kein „aber auf meiner Maschine sieht es gut aus."pnpm lint— ESLint mit--max-warnings 0. Nicht mal eine einzige Warnung kommt durch.pnpm test— Vollständige Vitest-Suite inklusive Coverage-Schwellenwerten.
Coding-Konventionen, erzwungen durch ESLint
Einige Regeln, die die Codebasis konsistent halten:
- TypeScript Strict Mode —
noUnusedLocals,noUnusedParameters. Wenn du es deklarierst, benutzt du es. - Kein
console.log— verwende daslogger-Utility, gesteuert durchVITE_DEBUG_LOGGING. clsxfür bedingte Klassennamen — keine Template-Literal-Ternaries, die sich über den ganzen Bildschirm erstrecken.- Abschnitts-Kommentare verwenden
// ─── Titel ───-Trennzeichen — visuelle Orientierungspunkte in langen Dateien.
Was als Nächstes kommt
Tests sind grün, CI ist gesichert, der Code ist sauber. Ein Schritt noch: ausliefern.
In Teil 6 — dem Finale — deployen wir das Ganze auf Cloudflare Workers. Secrets-Management, die Build-Pipeline, der wrangler-deploy-Befehl und der befriedigende Moment, wenn deine Planning-Poker-App unter einer echten URL live geht.
Weiter voran und genieße jeden Schritt deiner Coding-Reise.
