Skip to content

Latest commit

 

History

History
366 lines (276 loc) · 14.3 KB

File metadata and controls

366 lines (276 loc) · 14.3 KB

Testing

How this app is tested, why the tools are the ones they are, what they cannot reach, and how to add a check of your own.

There are two test environments and four browser scripts. The split is not ceremony: each catches a class of fault the other is blind to, and every one of them exists because something got through.


The two environments

Unit suite Browser checks
Runner Vitest, environment: 'node' playwright-core against system Chrome
Renders with renderToStaticMarkup The real thing, from dist/
Sees The first commit of a component Effects, state, interaction, layout, contrast
Blind to Anything an effect does Nothing much — but cannot inject failure
Speed 494 tests in ~7s ~30s per script
Files 48 under src/**/*.test.ts(x) 4 under scripts/

Running them

npm test
npm run verify

verify is typecheck + unit tests + npm audit. It does not build or open a browser, so it is the fast loop.

The browser scripts need a build first. Each one serves dist/ exactly as production would:

npm run build && npm run behaviour-check

Also available: npm run layout-check, npm run a11y-check, and node scripts/smoke-test.mjs.

The full gate — everything, plus gitleaks and the probes that prove each tool still rejects what it must — is what CI runs:

pwsh scripts/test-gate.ps1

The four browser scripts

Each answers a different question, and each exists because of a specific failure.

smoke-test.mjsdoes the built app stand up at all? Boots the page, renders the board, starts a game, waits for bundled Stockfish to answer a real move. Unit tests cannot catch a broken bundle, a missing .wasm, or a worker path typo.

layout-check.mjsis everything on the screen, at every width? Four screens × four viewports, presence first, then containment, tap targets, text size, board dimensions, and — on a phone — whether the thing you came to the screen to do is above the fold. It exists because the settings panel was absent from every phone while four separate checks called the screen clean — each asked "is anything here wrong?" and none asked "is everything here?"

Presence turned out not to be the whole question either. Every phone screen was laid out for a desktop and left to stack, so Show hint, Start game and the replay transport all existed — 1,100 to 1,400 points down a page, below the board they act on, which is present and unusable. Hence the fold assertion.

Two of the four viewports are phones, because they disagree. The phone rules size the boards against the viewport height, so at 393×727 a board is bounded by the height and at 360×800 by the width. Checked against only the latter, the puzzle's hint button finished five points under the navigation bar and nothing said so.

behaviour-check.mjsdoes using it work? Paging appends, searching replaces, chips clear the box, sort reverses, reset clears the sort too, arrow keys move the selection, the two library scopes stay separate, a move played reaches the move list. It exists because the archive's scope never reached its query for a while, and nothing that ran on a commit could have noticed.

a11y-check.mjscan any of it be used? axe-core over four screens at two widths, against WCAG 2.1 A and AA.

paths-check.mjsis anything tracked that should not be? Two questions, because one of them cannot be asked yet at the moment it matters. First: which tracked files does the repository's own .gitignore now disown — git ls-files --cached --ignored --exclude-standard, which must come back empty. Second: a denylist of paths that must never be tracked whatever .gitignore says, since the first check cannot fire until somebody remembers to write the rule.

Both run against the index, and the denylist also runs across every commit a change adds. That last part is the one that would have mattered: .claude/ was deleted from the tip and added to .gitignore five hours before this repository first existed on GitHub, and it went up anyway, because git push sends history and deleting a file in a later commit does not take it back out. A check that examined the tip would have passed while it was exposed.

It runs in the gate and in the pre-commit hook — the hook first, because that is the only moment when the fix is still free.


Playwright, not jsdom

Component behaviour could be tested in a fake DOM instead. It is not, and the reasoning is worth keeping because it is a genuine trade rather than an obvious call.

What was chosen

playwright-core driving the system Chrome — which was already here for the smoke test — extended to cover behaviour and accessibility.

Why

  • The browser was already in CI. test-gate.ps1 has run browser checks on every pull request for as long as the gate has existed. Adding behaviour tests there cost no new infrastructure and no new dependency.
  • The faults that actually happened needed a real render. A scope missing from an effect's dependency list, and a settings panel that a shared CSS class turned into a 72px strip. jsdom runs effects, so it would have caught the first; it has no layout engine at all, so it could never have caught the second.
  • One environment fewer. jsdom would have made three: node units, jsdom components, real-Chrome end-to-end. Three places to ask "where does this test belong?" is worse than two.

What it costs

  • Seconds, not milliseconds. A browser script is ~30s against ~7s for 494 unit tests. Fine at four scripts; it would not be fine at four hundred tests.
  • Failure cannot be injected. This is the real loss. The browser gets the real SQLite database, which always succeeds, so "what does the screen do when the query throws?" is not reachable. Those paths stay the unit suite's job, where a stand-in service can be handed in through ServicesProvider.
  • Setup, not assertion, is where the time goes. See the trap below.

If you want jsdom anyway

Nothing above rules it out — it is a reasonable choice for fine-grained component and hook tests, and it would reach the paths listed under What it costs.

npm install --save-dev jsdom @testing-library/react @testing-library/user-event

Do not flip environment in vite.config.ts. Opt in per file, so the 494 existing tests stay on node and keep their speed:

// @vitest-environment jsdom
import { render, screen } from '@testing-library/react'

Regenerate the lock file the way SUPPLY-CHAIN.md requires, or the Linux deploy breaks.


What is not covered, and why

Honesty about the holes matters more than the number of tests.

Not covered Why
Error paths in screens — a failed query, a rejected import The browser gets the real database, which succeeds. Needs jsdom with an injected service, or a fault-injection seam.
PlayerSearch's suggestion list Only exists after an effect resolves, so a static render is always null. A test asserting that would pass whether or not the component worked.
useArchiveQuery's wiring The decisions it makes are extracted into archiveQuery.ts and tested directly; the effects around them are not.
StockfishEngine, the SQLite worker, storage persistence Need a real browser environment plus the engine binary. The smoke test proves the engine answers; nothing tests its UCI handling in isolation.
Real devices Everything runs in headless Chrome, which has no collapsing URL bar, no home-indicator inset, and no touch. See UI-REDESIGN.md.
Pieces having accessible names react-chessboard gives every piece role="button" with no name and hardcodes roleDescription. Recorded as a known finding in a11y-check.mjs and printed on every run.

That last row is a policy as much as an entry: the accessibility script keeps a KNOWN map of faults that are real, are not ours, and cannot be fixed from here. They are printed every run rather than filtered out, because a check that quietly drops what it cannot fix is how a known problem becomes a forgotten one. Anything not on that list fails the build.


Writing a new browser check

All four scripts share a shape. Copy the nearest one rather than starting blank — the server spawn, the Chrome lookup and the teardown are the same everywhere.

import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { chromium } from 'playwright-core'

const PORT = 4321                       // one per script; they run in sequence
const URL = `http://localhost:${PORT}`

// playwright-core ships no browser, so the system Chrome is found by path
// and CHROME_PATH overrides it.
function chromePath() { /* copy from any existing script */ }

const server = spawn('npx', ['vite', 'preview', '--port', String(PORT), '--strictPort'], {
  shell: process.platform === 'win32',
  stdio: 'ignore',
})

const failures = []
const check = (what, condition, detail) => {
  if (!condition) failures.push(`${what}${detail === undefined ? '' : ` — ${detail}`}`)
}

let browser = null
try {
  await waitForServer(30_000)
  browser = await chromium.launch({ executablePath: chromePath(), headless: true })
  // …drive the page, call check() …
} finally {
  if (browser !== null) await browser.close()
  server.kill()
}

if (failures.length > 0) {
  console.error('WHATEVER FAILURES')
  for (const failure of failures) console.error(`  - ${failure}`)
  process.exit(1)
}
console.log('WHATEVER OK')

Then add it in two places:

  • package.jsonscripts, so it can be run alone
  • scripts/test-gate.ps1 → an Invoke-Gate line, so CI runs it

House rules

Collect failures; do not throw on the first. One run should report everything wrong, not the first thing wrong. That is why check() pushes to an array instead of asserting.

Say what you saw, not just that it failed. check('paging appends', a > b, \${b} then ${a}`)` turns a red line into a diagnosis.

Navigate by aria-label, not by visible text. The rail's labels change with width — "Championships" becomes "Titles" on a phone — but the aria-label is stable:

page.locator('.app-rail button[aria-label="Championships"]')

Wait for a condition, never for a duration. The archive opens a database and seeds the library on first visit; how long that takes is not knowable in advance.

The trap: waiting for the wrong signal

This one cost real time, and it will catch the next person too.

Typing into the archive's search box restarts the paging immediately, but the query itself is debounced. So the row count goes:

80 rows  →  40 rows (the OLD question, back at page one)  →  23 rows (the new one)

A helper that waits for the count to change returns at 40 — the old question's first page — and every assertion after it runs against the wrong state. In the first draft of behaviour-check.mjs this produced a passing check that the search had narrowed the list, because 40 is indeed fewer than 80.

It was caught only because the assertion after it failed, and because the failure got instrumented rather than waited-out with a longer timeout.

Wait for the count to stop changing:

async function settled(page, timeoutMs = 15_000) {
  const deadline = Date.now() + timeoutMs
  let last = -1
  let stable = 0
  while (Date.now() < deadline) {
    const count = await rows(page)
    stable = count === last ? stable + 1 : 0
    last = count
    if (stable >= 3) return count      // spans the debounce and the query
    await page.waitForTimeout(150)
  }
  return last
}

A browser test that goes green after a timing change deserves more suspicion than one that goes red. Red says the assertion is wrong or the app is; green after a sleep says only that something arrived, and not that it was the thing you meant.


Writing a new unit test

The suite renders to static markup, so a component's markup is a string:

import { renderToStaticMarkup } from 'react-dom/server'

const markup = renderToStaticMarkup(<ClockPanel whiteMs={30_000}  />)
expect(markup).toContain('clock-face--low')

Two things follow from that, and both have bitten:

Effects do not run. useEffect never fires, so anything fetched, measured or subscribed is absent. A component whose content arrives via an effect renders as null, and a test asserting that proves nothing.

Portals do not appear. The setup screen's settings render into the shell's right rail through createPortal, which needs a DOM node that does not exist here. What such a screen can assert is that it renders at all before its portal target exists — which is a real thing to check, and not the same as checking the panel.

Where a decision cannot be reached by a static render, extract it and test it directly rather than reaching for a heavier environment. That is why archiveQuery.ts exists apart from useArchiveQuery.ts: query construction, page accumulation, sort toggling, keyboard movement and the active-filter list are plain functions with 35 tests, and only the wiring needs a browser.

For screens that need services, hand in a stand-in — ServicesProvider takes an optional value for exactly this, so no test opens a real database:

renderToStaticMarkup(
  <ServicesProvider value={{ services: { archive:  }, factory: {} } as never}>
    <PlayScreen  />
  </ServicesProvider>,
)

One more rule, learned the expensive way

vite.config.ts includes both *.test.ts and *.test.tsx. It once included only the first, and two component test files sat inert for their entire existence while the suite reported green — an unmatched test does not exist as far as the runner is concerned, and unlike a skipped one it announces nothing.

When a merge adds test files, watch the file count, not just the pass count.


See also

  • LESSONS-LEARNED.md — the bugs behind most of the above, and the wrong explanation that looked right first in each case.
  • UI-ARCHITECTURE.md — what the layout check asserts, and why presence rather than pixels.
  • SUPPLY-CHAIN.md — what the gate enforces, and the lock file procedure any new dependency has to respect.