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
| Tool | Purpose |
|---|---|
| Vitest | Test runner (built for Vite, fast, ESM-native) |
| Testing Library | DOM assertions + user event simulation |
| jsdom | Browser environment in Node |
@vitest/coverage-v8 | Coverage 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:
- Import Testing Library matchers —
toBeInTheDocument(),toHaveTextContent(), etc. - Clean storage between tests — no state leakage between encryption key tests and identity tests
- Polyfill jsdom gaps —
matchMediaandResizeObserverdon’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 encryptJSON → decryptJSON 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: format → lint → test. 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 mode —
noUnusedLocals,noUnusedParameters. If you declare it, you use it. - No
console.log— use theloggerutility gated byVITE_DEBUG_LOGGING. clsxfor 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.
