docs(testing): probe the DOM before theorising about a missing element (#2850)

Earned the hard way this week: three separate causes in `App.test.tsx`
and `board-mobile-view-switch.test.tsx` all presented **identically** as
a missing element, each with a DOM that looked healthy.

| what the test said | what was actually wrong |
|---|---|
| `Unable to find "+ New Task"` | `ListView` rendered its workflow
**skeleton**, which carries the same `list-view` class as the real body
— so the preceding `waitFor(".list-view")` passed |
| `Unable to find role="heading" "New Task"` | `NewTaskModal` **threw**
— an incomplete `vi.mock` was missing `isShortViewport` — and an
`ErrorBoundary` swallowed it |
| `Unable to find [data-testid="switch-to-board"]` | an uncaught render
error **unmounted the entire React root**; the DOM was already empty
three lines earlier |

The part worth recording is the hit rate. **Three theories were offered
before any probe — "the board renders nothing", "i18n is returning
keys", "the FloatingWindow rework" — and all three were wrong.** Three
probes each landed the cause on the first try. Those wrong theories cost
days; the probe is four lines.

The doc records the snippet, what each signal means (`error-boundary` in
the DOM = a swallowed throw, and its text names a missing mock export
exactly; empty DOM with no boundary = unmounted root, so trust the
*first* failing assertion not the reported one; container present but
contents absent = a skeleton standing in), and the corollary for writing
assertions:

> Wait on a marker only the real thing has.

A class shared with a loading or empty state turns *"the list rendered"*
into *"something rendered"*, and the test then fails one line later
against a DOM that looks fine. That is why `list-view-body` exists
(#2834).

Placed under the dashboard testing sections in `docs/testing.md`. Docs
only — no changeset.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 15:07:34 -07:00
committed by GitHub
parent 21e688e1f9
commit 7927c7b58a

View File

@@ -235,6 +235,46 @@ it to run — the curated-gate hole that silently skipped unenumerated files is
(see "Curated-gate completeness" below). Add a file to a curated `qualityApp*`/`qualityApi`
list only when you want it in a specific fast lane rather than the backfill catch-all.
## When a dashboard element is "missing", probe before theorising
`getByText` / `getByRole` failures name the element that was not found, which is almost never where
the fault is. Three separate causes in `App.test.tsx` and `board-mobile-view-switch.test.tsx` all
presented identically as a missing element, and in each case the DOM looked healthy:
| what the test said | what was actually wrong |
|---|---|
| `Unable to find "+ New Task"` | `ListView` rendered its workflow SKELETON, which carries the same `list-view` class as the real body, so the preceding `waitFor(".list-view")` passed |
| `Unable to find role="heading" "New Task"` | `NewTaskModal` THREW — an incomplete `vi.mock` was missing `isShortViewport` — and an `ErrorBoundary` swallowed it |
| `Unable to find [data-testid="switch-to-board"]` | an uncaught render error unmounted the entire React root; the DOM was empty three lines earlier |
**Three theories were offered for these before any probe, and all three were wrong** ("the board
renders nothing", "i18n is returning keys", "the FloatingWindow rework"). Three probes each landed
the cause on the first try. The technique is simply to print what is really there at the failing
assertion:
```ts
// eslint-disable-next-line no-console
console.log(
"PROBE testids:", JSON.stringify([...document.querySelectorAll("[data-testid]")]
.map((e) => e.getAttribute("data-testid")).slice(0, 12)),
"| boundary:", document.querySelector('[class*="error-boundary"]')?.textContent?.trim() ?? "none",
);
```
What each signal means:
- **an `error-boundary` class in the DOM** — a component threw and the boundary ate it. Read its
text; for a mock-related throw it names the missing export exactly.
- **an empty DOM with no boundary** — an uncaught render error unmounted the root. Everything after
it fails misleadingly, so trust the FIRST failing assertion, not the reported one.
- **the element's container present but its contents absent** — a skeleton or empty state is
standing in. Check whether the selector you waited on is shared with that state; `list-view` is,
which is why `list-view-body` exists.
Corollary for writing assertions: **wait on a marker only the real thing has.** A class shared with a
loading or empty state turns "the list rendered" into "something rendered", and the test then fails
one line later against a DOM that looks fine.
## Curated-gate completeness and the skip-list
The dashboard quality gate is a chain of curated lanes plus two backfill lanes.