12. Tests and CI: headless rendering, compared screenshots, and their limits
Sections
Reading settings
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
Code: the component tests in tests/fretboard.test.tsx, the pixel comparison in scripts/l12-pixels.ts, the probe runner in scripts/probe.mjs, and the whole check in check.sh, run by .github/workflows/threejs-examples.yml.
Four layers
Section titled “Four layers”A 3D page mixes code that any test runner can check with code that needs a GPU, and a GPU is what CI machines usually lack. This course tests in four layers, from the cheapest to the least reliable:
| Layer | What runs | Needs | In .NET terms | In this course |
|---|---|---|---|---|
| Pure logic | math, notes, physics | Node.js | xUnit on a class library | fretAt, noteName, rays in l05-raycaster.ts, Rapier in l10-rapier.ts |
| Components | the scene graph a component builds, its props and events | Node.js, no GPU | a view model test; bUnit for Blazor | Vitest with @react-three/test-renderer |
| Probed pages | the real renderer, in a real browser | a browser, a GPU or a software rasterizer | UI automation with FlaUI or Appium | probe.mjs: renderer.info, colors, hashes, pointer events |
| Pixels | what the screen shows | the same GPU and driver, every time | screenshot comparison, snapshot tests with Verify | l12-pixels.ts, and the colors read in lessons 1 to 7 |
The higher layers catch more and break more easily. The course compares the lower three everywhere, and pixels only where it can explain what differs.
Components without a GPU
Section titled “Components without a GPU”@react-three/test-renderer 9.1.1 runs R3F’s reconciler in Node.js: create(<Fretboard />) builds the three.js scene graph from the component, with real Mesh and BufferGeometry objects, and nothing is drawn. Vitest 5.0.1 runs the tests, with Vite’s transform, so the .tsx files need no separate build.
The five tests check what lesson 9 measured in the browser (fretboard.test.tsx): the neck, 12 frets and 6 strings; one geometry shared by the frets, or 12 when they are inline; the markers computed once for the same array, and on every render for a new one; the note under the pointer; and 8 geometries disposed on unmount. The pointer test shows the limit of the layer: no ray is cast, the test supplies the intersection point R3F would have computed (lines 60-69):
test('reports the note under the pointer', async () => { const picks: Pick[] = []; const renderer = await ReactThreeTestRenderer.create(<Fretboard onPick={(pick) => picks.push(pick)} />); const group = renderer.scene.find((node) => node.props.name === 'fretboard'); // No raycaster runs here: the test supplies the intersection point that R3F would have computed await renderer.fireEvent(group, 'onPointerMove', { point: pressPoint(3, 5), stopPropagation: () => {} }); expect(picks).toEqual([{ fret: 5, string: 3, note: 'C4' }]); expect(renderer.scene.findAll((node) => node.props.name === 'hover')).toHaveLength(1); await renderer.unmount();});$ npx vitest run tests --reporter=verbose
RUN v5.0.1 .
(node:PID) [THREE_CJS_DEPRECATED] DeprecationWarning: `require("three")` is deprecated and will be removed.Replace `const THREE = require("three")` with `import * as THREE from "three"`.(Use `node --trace-deprecation ...` to show where the warning was created)stderr | tests/fretboard.test.tsx > Fretboard > has a neck, 12 frets and 6 stringsTHREE.Clock: This module has been deprecated. Please use THREE.Timer instead.... ✓ tests/fretboard.test.tsx > Fretboard > has a neck, 12 frets and 6 strings ✓ tests/fretboard.test.tsx > Fretboard > shares one geometry between the frets, unless they are written inline ✓ tests/fretboard.test.tsx > Fretboard > computes the markers once for the same array, and on every render for a new one ✓ tests/fretboard.test.tsx > Fretboard > reports the note under the pointer ✓ tests/fretboard.test.tsx > Fretboard > disposes the geometries it created on unmount, including the shared one
Test Files 1 passed (1) Tests 5 passed (5) Start at TIME Duration NThree things in that output:
IS_REACT_ACT_ENVIRONMENT. The first version of the file printed “The current testing environment is not configured to support act(…)” 23 times, and its dispose test had to wait for R3F, which disposes removed objects at idle priority unless it runs underact. React reads a global flag to know it is in a test; the file now sets it (lines 10-15), the warnings are gone, and R3F disposes right away, so the test counts the calls without waiting.- Two copies of three.js. The deprecation warning comes from
@react-three/test-renderer’s CommonJS build, which callsrequire('three')on its line 6 (found with--trace-deprecation): Node.js loadsthree.cjsnext to the ES module build the tests and the component import. The scene’s objects were instances of the ES module classes, checked withinstanceofin a throwaway test, so the second copy costs load time without splitting the classes here. THREE.Clockagain, from R3F’s store, as in lesson 9.
check.sh runs vitest run tests: without the folder, Vitest also picked up a throwaway test left under out/, the course’s ignored output folder, and failed.
Probed pages
Section titled “Probed pages”Every lesson page reports what the renderer did, and probe.mjs prints it, as lesson 1 describes: counts from renderer.info, colors read from the screenshot, hashes, and what the page saw after real pointer events. check.sh normalizes the output and compares it with expected/. The counts are the backbone of this course’s CI: most of them are the same on the author’s GPU and on a software rasterizer, where colors and times are not.
What the three runners render with, from the logs since lesson 1:
| Runner | WebGPURenderer |
WebGLRenderer and forceWebGL |
chromium-headless-shell |
|---|---|---|---|
ubuntu-24.04 |
WebGL 2 fallback, SwiftShader | SwiftShader | SwiftShader |
windows-2025-vs2026 |
WebGL 2 fallback, SwiftShader | SwiftShader | SwiftShader |
macos-26-arm64 |
WebGPU, Apple’s paravirtualized GPU | ANGLE on Metal | SwiftShader |
| The author’s machine | WebGPU, NVIDIA RTX 5080 | ANGLE on Direct3D 11 | SwiftShader |
So two of three runners never run the WebGPU backend. When an output depends on the backend, check.sh compares it with expected/<name>.webgl.txt when the page ran on WebGL 2 from the start (lines 54-59), a file generated with chromium-headless-shell on the author’s machine, whose SwiftShader has matched the runners’ so far. “From the start” matters for lesson 11’s fallback page: it starts on WebGPU and ends on WebGL 2, and on a GPU it must be compared with the WebGPU output. The WebGPU output itself is checked on the author’s machine and on macOS only.
Screenshots, pixel by pixel
Section titled “Screenshots, pixel by pixel”pixelmatch 7.2.0 compares two images, counts the pixels whose color distance exceeds a threshold, in a perceptual color space (YIQ), and can leave out the pixels it detects as anti-aliasing. l12-pixels.ts prints that count, and before it the stricter one: pixels whose bytes differ at all.
The same page, lesson 1’s cube, probed twice on the same machine:
$ node scripts/l12-pixels.ts out/shots/l01_probe.png out/shots/l12_probe_again.pngl01_probe.png and l12_probe_again.png, 800x450: pixels not byte-identical: 0 (0.00 %) pixels over pixelmatch's threshold 0.1, anti-aliasing excluded: 0 (0.00 %)check.sh compares this output on every OS: a page rendered twice by the same GPU and driver gives the same pixels. Across backends, it only prints the counts, since they depend on the machine. On the author’s machine:
l01_probe.png and l01_probe_webgl.png, 800x450: pixels not byte-identical: 294 (0.08 %) pixels over pixelmatch's threshold 0.1, anti-aliasing excluded: 0 (0.00 %) l01_probe.png and l01_probe_headless_shell.png, 800x450: pixels not byte-identical: 10330 (2.87 %) pixels over pixelmatch's threshold 0.1, anti-aliasing excluded: 0 (0.00 %)WebGPU against ANGLE’s WebGL 2 on the same NVIDIA GPU: 294 pixels differ. Against SwiftShader: 10,330, almost 3 % of the image. None of them exceed pixelmatch’s default threshold once anti-aliasing is left out: the differences are rounding on the faces, one or two levels per channel, and edges rasterized differently.
![]()
Open full screen. Try it: exercise 3 is about lesson 3’s tone mapping switched from ACES to AgX: on lesson 3’s live page, the panel’s tone mapping makes that switch. check.sh also probes the five live demos, and builds them again to compare with the copy the site publishes: diff -r must print nothing.
A byte-exact comparison fails between any two of the three renderers; a thresholded one passes, and would also pass a real change of a few levels, like lesson 3’s ACES color, 127 on the author’s GPU and 128 in CI. The course reads specific colors at known positions with a tolerance of 2 per channel (compare.mjs): a check of one number, which a reader can relate to a lesson. Full-image comparisons are right where the renderer is fixed: the same GPU, driver and browser, like a visual regression job on one dedicated machine, with the reference images regenerated there.
Six ways a test went wrong in this course
Section titled “Six ways a test went wrong in this course”Each of these made a check fail or hang before it was fixed, in lessons 1 to 13:
- A promise replaced during a wait. Lesson 1’s first probe hung: the page replaced
window.probeafterpage.evaluatehad started to wait for the old promise. The pages now resolve one promise created once. - Work queued behind the result. Lesson 8’s pages with 800,000 triangles reported in time, then Playwright’s screenshot waited more than 30 seconds behind the frames still queued in SwiftShader. Those probes time fewer frames, and wait up to 240 seconds.
- A render loop that stops. Lesson 11’s page waited for XR frames that never came, since each one threw, and held the machine’s shared GPU lock for ten minutes until it was stopped by hand. The page now catches render errors and gives up after 5 seconds, and
probe.mjsexits on its own after twice its timeout plus 30 seconds (lines 19-24). - React batching on a slow renderer. Lesson 9’s page counted 11 renders on the author’s GPU and 6 in
chromium-headless-shell: when frames are slow, several state updates arrive before React renders, and it batches them.flushSynccommits each one. - Coalesced pointer events. After a 5-step mouse move, the renders counted differed between machines: the browser merges
pointermoveevents when frames are slow. The page no longer reports render counts after pointer actions, and no longer moves the pointer before unmounting. - Output that belongs to the OS. Chromium on Windows warns “The powerPreference option is currently ignored” whenever R3F passes
powerPreference; Linux and macOS don’t. WebGL errors carry a context address,[.WebGL-0x2afc00409400], different on every run.check.shremoves both from the compared output, and prints how many WebGL errors there were.
The common thread: a 3D test waits on frames, and frames depend on the machine. Wait for a condition, not a duration; bound every wait; drive state changes synchronously; compare what doesn’t depend on timing.
In GuitarAlchemist/ga
Section titled “In GuitarAlchemist/ga”GA’s React components have 22 Playwright spec files under tests/: 15 in the dashboard suite, under tests/dashboard/, and 7 others, 6 of them on 3D pages (the seventh tests the tab converter).
- Waiting for a duration.
fretboard.spec.tswaits for the canvas, then 3 seconds “for WebGPU initialization” (lines 22-27), and callswaitForTimeout25 times. On a slow runner, 3 seconds may not be enough; on the author’s machine, it’s 3 seconds lost per test. A page that sets a flag, or adata-attribute, when its first frame is rendered gives the test something to wait for. - Assertions that can’t fail, and one that can’t pass.
bsp-doom-explorer.spec.tstakes a screenshot of the canvas and assertsexpect(screenshot).toBeTruthy()(lines 14-20): aBufferis always truthy, even for a black image. Later, two screenshots 2 seconds apart must differ (lines 76-87), which a blinking cursor or a timer would satisfy.floor0-navigation.spec.tschecks that the canvas isn’t black by callinggetContext('2d')on it (lines 280-298); a canvas that already has a WebGL or WebGPU context returnsnullfor a 2D one, the function returnstrue, “all black”, andexpect(isBlack).toBe(false)fails whatever the scene draws. Reading the canvas works withtoDataURLfrom the same frame (withpreserveDrawingBuffer), or with the page’s screenshot, asprobe.mjsdoes. - Results committed, not run. 13 files under
ReactComponents/ga-react-components/test-results/are in the repository, including a.last-run.jsonwhose status isfailed, with 13 failed tests. The CI workflow that runs Playwright,playwright-dashboard.yml, runs onlytests/dashboard/, against the live site, and none of the 3D specs. - The runners’ GPU, known. GA’s
deploy-banner.ymlexplains why it stopped capturing its banner in CI: “GitHub runners have no GPU and SwiftShader renders WebGPU compute as black frames” (lines 64-69). That matches this course’s table: on Linux, WebGPU falls back to WebGL 2, and a compute shader has no fallback at all.
GA experiment
Section titled “GA experiment”Lesson 14 applies this lesson’s rule to timings: its CI compares only renderer.info counters, on three OSes, and never a time. How its measurements were made explains the rest: 240 timed frames without requestAnimationFrame or vsync, and GPU times from WebGPU timestamp queries.
Key takeaways
Section titled “Key takeaways”- Test 3D code in layers: pure logic and components without a GPU everywhere, probed pages through counts, pixels only on a fixed machine.
@react-three/test-rendererbuilds the real scene graph in Node.js; setIS_REACT_ACT_ENVIRONMENTin the test file.renderer.info’s counts are the same on a GPU and on SwiftShader; colors and times are not.- Two of GitHub’s three runners never run WebGPU: know what each renders with, and keep separate expected outputs per backend.
- Byte-exact screenshots fail across renderers; a thresholded comparison hides small real changes. Read specific values instead, with a stated tolerance.
- Never wait for a duration or for frames without a bound: wait for a condition, with a timeout, and drive state changes synchronously.
Exercises
Section titled “Exercises”- Write a component test for lesson 13’s port that checks that 10 renders with a new
positionsarray of the same content build the markers once. What does the test renderer not let you check about the port’s strings? floor0-navigation.spec.tswants to know whether a WebGL canvas is black. Write the check two ways: from Playwright’s screenshot, and from inside the page.- The course’s pixel comparison passes with 10,330 pixels that aren’t byte-identical. Design a check that would catch lesson 3’s tone mapping switched from ACES to AgX on any runner, without a byte-exact reference.
Solution 1
test('builds the markers once for the same positions', async () => { const positions = () => C_MAJOR.map((p) => ({ ...p })); const renderer = await ReactThreeTestRenderer.create(<Fretboard3D positions={positions()} />); for (let i = 0; i < 10; i++) await renderer.update(<Fretboard3D positions={positions()} />); expect(portStats.markerBuilds).toBe(1); await renderer.unmount();});The strings move in their vertex shader, in TSL: the test renderer builds the InstancedMesh and its node material, but no shader is compiled or run, so the vibration can’t be checked there. The port’s useLayoutEffect also writes instance matrices, which the test can read with getMatrixAt. A sketch, not run: Fretboard3D imports three/webgpu and draws a canvas texture, which needs a document (to verify under Vitest’s jsdom or happy-dom environment).
Solution 2
From Playwright, which captures what the page composited:
const png = PNG.sync.read(await page.locator('canvas').screenshot());let lit = 0;for (let i = 0; i < png.data.length; i += 4) if (png.data[i] > 10 || png.data[i + 1] > 10 || png.data[i + 2] > 10) lit++;expect(lit).toBeGreaterThan(1000);From the page: draw the 3D canvas onto a 2D canvas, ctx2d.drawImage(glCanvas, 0, 0), then getImageData. For a WebGL context, that reads the drawing buffer only in the same task as the frame, unless the context was created with preserveDrawingBuffer: true; otherwise the image is cleared, and black. The screenshot needs neither, which is why probe.mjs reads colors from it. Not run against GA (to verify).
Solution 3
Read what the tone mapping changes, not the whole image: lesson 3’s probe already reads patches of known linear values. Choose the patches where the operators differ: at linear 0.18, ACES gives 127 and AgX 128, too close to tell apart across GPUs; at linear 1, ACES gives 227 and AgX 202, and at 0.05, 50 and 70 (lesson 3’s table). Those differences are tens of levels, far above the 2 levels that separate GPUs, so a check of those patches, each with a tolerance of 2, catches the switch everywhere. For a whole-image check, compare against a reference made on the same runner type, and set pixelmatch’s threshold between the runner’s own noise (0 differing pixels for the same page twice) and the smallest change you care about, measured, not guessed.
Sources
Section titled “Sources”- @react-three/test-renderer; Vitest; React:
actandIS_REACT_ACT_ENVIRONMENT,flushSync - Playwright: screenshots,
page.mouse - pixelmatch
- MDN:
HTMLCanvasElement.getContext,preserveDrawingBuffer, pointer events coalescing - SwiftShader; GitHub: runner images