|

Testing, CI, and Code Quality — Breaking Things Before Users Do (Part 5/6)

Building features is the fun part. Making sure they actually work — and keep working — is the responsible part. In this post, we’ll set up the testing pipeline, write meaningful tests for our planning poker app, and wire up GitHub Actions so nothing broken reaches production.

The testing stack

ToolPurpose
VitestTest runner (built for Vite, fast, ESM-native)
Testing LibraryDOM assertions + user event simulation
jsdomBrowser environment in Node
@vitest/coverage-v8Coverage reporting with V8

Why Vitest instead of Jest? Because the project is Vite-based. Vitest reuses your existing Vite config — same aliases, same transforms, same everything. Zero configuration friction.

Setting up Vitest

// 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 pages — tested at integration level
        "src/components/Layout/**",
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 80,
        statements: 80,
      },
    },
  },
});

80% coverage threshold across the board. Not 100% — that incentivizes gaming the metric instead of writing useful tests. 80% means: test the important stuff, skip the trivial getters.

Notice the exclude on pages and Layout — these are UI integration surfaces better tested with E2E tools. Unit testing a 500-line React component that renders conditionally based on room state is fragile. Better to test the store and utilities that those components depend on.

Test setup

// __tests__/setup.ts
import "@testing-library/jest-dom";

beforeEach(() => {
  sessionStorage.clear();
  localStorage.clear();
});

// Polyfills for 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() {}
  };
}

Three things happening:

  1. Import Testing Library matcherstoBeInTheDocument(), toHaveTextContent(), etc.
  2. Clean storage between tests — no state leakage between encryption key tests and identity tests
  3. Polyfill jsdom gapsmatchMedia and ResizeObserver don’t exist in jsdom but are used by HeroUI components

Testing the crypto module

This is the most important module to test. If encryption breaks, the whole app breaks:

describe("crypto", () => {
  it("generates unique key material", () => {
    const key1 = generateKeyMaterial();
    const key2 = generateKeyMaterial();
    expect(key1).not.toBe(key2);
    // base64url: only alphanumeric, -, _
    expect(key1).toMatch(/^[A-Za-z0-9_-]+$/);
  });

  it("encrypts and decrypts JSON 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("rejects decryption with wrong key", 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("stores key in session write-once", () => {
    const stored1 = storeKeyInSession("room1", "key-aaa");
    expect(stored1).toBe(true);
    
    const stored2 = storeKeyInSession("room1", "key-bbb");
    expect(stored2).toBe(false); // refused — write-once
    
    expect(loadKeyFromSession("room1")).toBe("key-aaa"); // original kept
  });
});

The round-trip test is the most critical. If encryptJSONdecryptJSON doesn’t give you back exactly what you put in, game over.

The wrong-key test verifies AES-GCM’s authentication: decryption with a different key should throw, not silently return garbage. This is important because AES-GCM is an authenticated cipher — it detects tampering.

Testing the deck config

Seemingly trivial, but decks drive the entire voting UI:

describe("decks", () => {
  it("every deck has a unique ID matching its key", () => {
    Object.entries(DECKS).forEach(([key, deck]) => {
      expect(deck.id).toBe(key);
    });
  });

  it("every deck has at least 3 cards", () => {
    Object.values(DECKS).forEach((deck) => {
      expect(deck.cards.length).toBeGreaterThanOrEqual(3);
    });
  });

  it("fibonacci deck includes ? and ☕", () => {
    expect(DECKS.fibonacci.cards).toContain("?");
    expect(DECKS.fibonacci.cards).toContain("☕");
  });

  it("has at least 10 unique player emojis", () => {
    const unique = new Set(PLAYER_EMOJIS);
    expect(unique.size).toBe(PLAYER_EMOJIS.length);
    expect(PLAYER_EMOJIS.length).toBeGreaterThanOrEqual(10);
  });
});

If someone accidentally deletes the ? card from the Fibonacci deck, this test catches it before it reaches production. Small test, big safety net.

Testing the Zustand store

The store is where the business logic lives, so it gets the most thorough testing:

describe("usePokerStore", () => {
  it("sets and persists player identity", () => {
    const { setPlayer, playerId, playerName, playerEmoji } = 
      usePokerStore.getState();
    
    setPlayer("p1", "Alice", "🦊");
    
    const state = usePokerStore.getState();
    expect(state.playerId).toBe("p1");
    expect(state.playerName).toBe("Alice");
    expect(state.playerEmoji).toBe("🦊");
  });

  it("rejects room updates with mismatched ID", () => {
    const store = usePokerStore.getState();
    // Set the current room
    store._setRoom({ id: "room-1", /* ... */ } as Room);
    // Try to push an update for a different room
    store._setRoom({ id: "room-2", /* ... */ } as Room);
    // Should be ignored
    expect(usePokerStore.getState().room?.id).toBe("room-1");
  });
});

That room ID mismatch test is subtle but important. During SSE reconnections, there’s a tiny window where an old subscription might deliver an update for a previous room. The store’s _setRoom method silently drops these.

Testing the adapter

The UpstashAdapter tests mock fetch globally:

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("routes commands through /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("uses pipeline for writes", async () => {
    // ... verify that write operations hit /upstash/pipeline
    // with SET + PUBLISH commands in the pipeline body
  });

  it("uses keepalive for room deletion", async () => {
    fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) });
    
    adapter.destroyRoom("room-1");
    
    expect(fetchMock).toHaveBeenCalledWith(
      expect.any(String),
      expect.objectContaining({ keepalive: true }),
    );
  });
});

The keepalive: true test for room deletion is critical. When a user closes the tab, beforeunload fires a “delete room” request. Without keepalive, the browser would cancel the request as the page unloads. With keepalive, the request survives the page death.

Testing components

Component tests use Testing Library’s user-centric approach:

describe("VotingDeck", () => {
  it("renders all cards in the deck", () => {
    const cards = ["1", "2", "3", "5", "?"];
    render(<VotingDeck cards={cards} selectedCard={null} onSelect={() => {}} />);
    
    cards.forEach((card) => {
      expect(screen.getByText(card)).toBeInTheDocument();
    });
  });

  it("highlights the selected card", () => {
    const { getByText } = render(
      <VotingDeck cards={["1", "2", "3"]} selectedCard="2" onSelect={() => {}} />
    );
    
    expect(getByText("2").closest("button")).toHaveClass("ring-accent");
  });

  it("calls onSelect when a card is clicked", 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");
  });
});

No internal state inspection. No wrapper.instance(). Just “does the right thing appear on screen?” and “does clicking it do the right thing?” — the way a real user would interact.

The logger

Even the logger gets a test:

describe("logger", () => {
  it("suppresses output when VITE_DEBUG_LOGGING is not 'true'", () => {
    const spy = vi.spyOn(console, "log").mockImplementation(() => {});
    logger.log("test message");
    expect(spy).not.toHaveBeenCalled();
    spy.mockRestore();
  });
});

Feature flags in tests catch accidental logging leaks. You don’t want [Upstash] Subscribing to SSE for room abc123 showing up in production console.

GitHub Actions CI

The CI pipeline is minimal and effective:

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

Four gates: formatlinttest. If any step fails, the PR can’t merge.

  • pnpm format:check — Prettier verification. No “but it looks fine on my machine.”
  • pnpm lint — ESLint with --max-warnings 0. Not even one warning gets through.
  • pnpm test — Full Vitest suite including coverage thresholds.

Coding conventions enforced by ESLint

A few rules that keep the codebase consistent:

  • TypeScript strict modenoUnusedLocals, noUnusedParameters. If you declare it, you use it.
  • No console.log — use the logger utility gated by VITE_DEBUG_LOGGING.
  • clsx for conditional class names — no template literal ternaries that stretch across the screen.
  • Section comments use // ─── Title ─── separators — visual landmarks in long files.

What’s next

Tests are green, CI is gated, the code is clean. One step left: shipping it.

In Part 6 — the finale — we’ll deploy the entire thing to Cloudflare Workers. Secrets management, the build pipeline, the wrangler deploy command, and the satisfying moment when your planning poker app goes live at a real URL.

Keep pushing forward and savor every step of your coding journey.