|

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

ToolZweck
VitestTest-Runner (für Vite gebaut, schnell, ESM-nativ)
Testing LibraryDOM-Assertions + User-Event-Simulation
jsdomBrowser-Umgebung in Node
@vitest/coverage-v8Coverage-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:

  1. Testing-Library-Matcher importierentoBeInTheDocument(), toHaveTextContent() usw.
  2. Storage zwischen Tests leeren — kein State-Leaking zwischen Verschlüsselungsschlüssel-Tests und Identity-Tests
  3. jsdom-Lücken polyfilllenmatchMedia und ResizeObserver existieren 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 encryptJSONdecryptJSON 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: FormatLintTest. 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 ModenoUnusedLocals, noUnusedParameters. Wenn du es deklarierst, benutzt du es.
  • Kein console.log — verwende das logger-Utility, gesteuert durch VITE_DEBUG_LOGGING.
  • clsx fü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.