[READ-ONLY] Mirror of https://github.com/lukebennett88/luke-ui. ui.lukebennett.dev
luke-ui docs TESTING.md
11 kB · 190 lines
Markdown
at main

Testing #

Test types #

  • Unit tests (*.test.ts) run in Node for pure, non-DOM logic.
  • Component tests (*.browser.test.tsx) run in Chromium. Each component has one file for behaviour, axe, and visual captures.

Two tests sit outside these types. The packed-consumer harness runs in Node and drives Chromium for hydration. See Package consumption. The theme build test runs vp pack --watch. See Theme build.

Do not add another test type.

Component tests #

Test Luke UI's contract, not React Aria Components (RAC). RAC owns its interaction and ARIA semantics. Test only what Luke UI changes or composes.

Import components from public package exports. Import test utilities relatively. Use render(). Do not mount React yourself.

Assert observable public behaviour. Prefer roles and accessible names, userEvent, and local setup. Do not test private functions, implementation details, or computed appearance. Use computed styles only when layout is the contract.

Do not add production props, context defaults, or code paths that exist only to make tests easier. Exercise the real loading and rendering path, and supply fixtures through the same seams the product uses.

Do not use test.each, it.each, or describe.each. Parameterise with a for…of loop that calls test() (or describe()) inside, and put the distinguishing value in the title:

for (const ratio of ratios) {
	test(`locks the frame to ${ratio}`, () => {
		// …
	});
}

Shared assertions take concrete elements and values. Keep the test, fixture, and contract choice in the component's test file.

Fixture themes name Inter. Browser setup awaits loadInter() before each test so mounts use the Inter file the theme packages ship from the first paint, not a host fallback. captureVisual awaits the same loader. Code surfaces keep the production --luke-font-family-code stack. Do not override component font styles in the test harness. Visual baseline and compare jobs run in a digest-pinned Playwright container so browser, OS fonts, and font rendering stay aligned — see VISUAL_TESTING.md. A non-visual test whose layout depends on real font metrics, such as Capsize trims or the cap height, can still call await loadInter(). Compare positions with Inter's published metrics (INTER_CAP_HEIGHT_EM) and measured baselines, not with the CSS under test.

Package component tests pin window.screen.width to a desktop value in @luke-ui/react browser setup so useIsMobileDevice stays desktop. Vitest's browser iframe is 414px wide. Playwright mirrors that onto screen.width, which would otherwise look like a phone. Call mockScreenWidth for tray or mobile-modal cases. The docs app browser setup does not pin screen width.

Cascade contracts that need a real third-party stylesheet live beside layer-order.browser.test.ts. tailwind-integration.browser.test.tsx is the Tailwind v4 consumer fixture. It runs in the tailwind-integration Vitest project so it can compile Tailwind without loading the default browser suite's global stylesheet.

Browser tests render with the built @luke-ui/react/stylesheet.css from dist, but a test's own imports of .css.ts modules compile with debug identifiers. A createVar imported into a test therefore names a different custom property from the one the built stylesheet declares. When a test must read a private custom property, give it a stable name in source, as core/styles/surface.ts does for its --luke-internal-foreground-* overrides, and import helpers from a plain .ts module. A .css.ts module that exports a function fails to load in a browser test.

Timing and synchronisation #

Almost never sleep in a test. Do not use setTimeout, a fixed delay(), or an arbitrary frame count to "give something time". Identify the event or observable state the test needs, then wait for that.

  • For asynchronous behaviour, control completion (for example with a promise gate or an injected clock) or assert the observable result with the browser/test framework's polling assertions. Avoid new test-only production APIs, and do not test implementation details.
  • Treat negative assertions specially: an absence observed after an arbitrary delay does not prove something will never happen. Control the event or clock, or assert the completed operation and its final state — for a failure, await the failure outcome itself.
  • Use fake time when elapsed time is the contract (debouncing, temporary feedback, spinner thresholds, timeout handling). Advance it deliberately, test the boundary when it matters, and restore real timers after the test.
  • Distinguish a wait's maximum failure deadline from a delay the test must sit through. Bounded polling and execution limits protect against hangs. They are not themselves a reason to fail a review. Do not inflate a timeout to hide a slow or broken dependency, and give any unusually large deadline a comment saying what slow step it covers.
  • Wait for an input's observable effect before sending the next input when it depends on that effect. Independent native discrete key events can be batched when the test asserts the final outcome.
  • Use browser animation APIs and frame synchronisation only where rendering or animation genuinely matters. Prefer explicit animation completion over guessed milliseconds.
  • Choose the smallest test that proves Luke UI's own contract. Avoid waiting for a large integration path when a smaller component test can establish the behaviour.

Legitimate deadlines stay: packed-consumer and theme-build execution limits, feedback-expiry tests on a fake clock, and visual-capture animation waits all guard real work. This section bans guessed durations, not timeout, expect.poll, or fake timers as such.

Accessibility #

Run axe with expectNoAxeViolations in component tests. Axe is a floor. Use behavioural assertions for accessibility contracts it cannot express. Gradients can produce an incomplete colour-contrast result. To check text over a gradient or a translucent layer, such as the control finish, measure the screenshot pixels behind it with lowestRenderedTextContrast.

Visual regression #

Visual cases are visual-tagged browser tests. render() compiles React-owned copies of the Tactile and Paper inputs from src/theme/__fixtures__/. Those copies can diverge from the published theme packages, except in their solid fills, on-solid text, and control finish, which the packed-consumer harness requires to match. Capture each visually meaningful component in its representative fixture across Tactile light, Tactile dark, Paper light, and Paper dark. This is not an exhaustive state matrix. Add another capture only for a materially different state.

flatAppearances renders Tactile's colours with every shadow and control finish set to none. Use it, outside the theme matrix, for a capture that proves states stay distinct without materials.

Comparison is per-pixel with includeAA: true and threshold: 0.1. There is no canvas-wide mismatch allowance. Remove nondeterminism. Do not add an allowance.

VISUAL_TESTING.md explains the baseline and review workflow.

Type contracts #

check:types protects normal source types. Add a unit test only when a public type contract can regress while compilation still succeeds, such as a union widening.

Use expectTypeOf for positive shape and equality contracts. Use assertType with @ts-expect-error for rejected values and prop combinations. Put those checks inside test() blocks. Do not wrap rejected assignments in a runtime expect([...]).toHaveLength(...) or similar just to create an assertion or keep consts referenced.

Package consumption #

packages/@luke-ui/react/src/core/packed-consumer/ tests the packages the way an application installs them. Its global setup, setup.ts, packs the workspace builds of React, Paper, and Tactile, installs the tarballs with npm in directories outside the repository, so no workspace link can satisfy an import, and builds each consumer once. The scenarios read those consumers and never install or build. react-package.test.ts covers React, theme-packages.test.ts the theme packages, and fonts.test.ts font loading and Capsize trims. The consumer files they need live in react-fixture.ts and theme-fixture.ts. The comprehensive pnpm test includes the harness and requires network access. Run it separately with pnpm test:consumer. The consumer-tests CI job runs it on every pull request and on main.

Keep it to the package boundary: tarball contents, dependencies, peers, assets, server rendering, a client build, hydration in Chromium, type checking, and what small imports bundle. Check installed support packages for undeclared JavaScript and declaration imports too. For the theme packages it also runs the docs' compile-theme.ts sample with Node 24, checks each stylesheet rule by rule against the token contract, and renders the packages' Inter and a Lora display font from @fontsource/lora. Component behaviour belongs in component tests.

One consumer installs the lowest published version each peer range allows, and the first release of the TypeScript version in MINIMUM_TYPESCRIPT. That run is the evidence for those floors. Change a peer range or MINIMUM_TYPESCRIPT only when the harness passes with the new floor.

A runtime import must not pull in theme-generation code, styling-authoring code, or components it does not render. A failing bundle-boundary check means one does. Fix the import graph. Change a boundary only when the code it flags has intentionally become runtime code.

Set LUKE_UI_REACT_SPEC to a published version or dist-tag to test that package from the registry instead of the workspace build. That run skips the theme package checks and prints why.

Theme build #

packages/@luke-ui/theme-build/theme-package.test.ts runs vp pack --watch with the shared theme build on a small theme package in a temporary directory. It edits the theme input twice and checks the stylesheet after each rebuild. The comprehensive pnpm test includes this suite. Run it separately with pnpm test:integration. The package-tests CI job runs it.

The React theme tests compile the copies in src/theme/__fixtures__/, not the published inputs. Each theme package's src/input.test.ts compiles its published input with the public defineTheme, so a contract or gate change that rejects Paper or Tactile fails there too. Tactile's test also checks the custom-canvas contrast failures against the published input.

Docs #

Docs examples must type-check and build. They are not another test corpus.

Bug fixes #

Add a regression test when the intention needs protection. Put it with the contract it covers:

  • pure logic: unit test
  • component behaviour or axe: browser test
  • meaningful appearance: visual-tagged browser test
  • public type contract check:types cannot catch: unit test (see Type contracts)