Three.js From Zero · Article s8-06
S8-06 Testing 3D Apps
Season 8 · Article 06
Testing 3D Applications
Unit-test pure functions. Snapshot-test visual output. E2E-test user flows with Playwright. Catch WebGL regressions automatically.
1. Three layers of tests
| Type | Tool | What |
|---|---|---|
| Unit | Vitest/Jest | Math, ECS systems, state logic |
| Snapshot | Playwright | Render → screenshot → diff golden image |
| E2E | Playwright | Full user flow: load, click, verify |
2. Unit-testable code
// PURE: testable
export function lerpVector(a, b, t) {
return { x: a.x + (b.x-a.x)*t, y: a.y + (b.y-a.y)*t };
}
test('lerp midpoint', () => expect(lerpVector({x:0,y:0},{x:10,y:0},0.5)).toEqual({x:5,y:0}));
// IMPURE: hard to test
export function moveObject() {
const obj = scene.getObjectByName('player');
obj.position.x += velocity * dt;
}
Separate math/logic from Three.js objects. Test the math in isolation.
3. Snapshot/visual regression
// playwright.config.ts
test('chair renders correctly', async ({ page }) => {
await page.goto('/viewer.html?model=chair');
await page.waitForFunction(() => window.__ready);
await expect(page.locator('canvas')).toHaveScreenshot('chair.png', {
maxDiffPixels: 100,
threshold: 0.1,
});
});
First run: saves golden image. Subsequent runs: compare pixel-by-pixel. Fails on unexpected visual change.
4. Signal "ready" for deterministic capture
// In app
loader.load(url, g => {
scene.add(g.scene);
window.__ready = true; // test hook
});
// In test
await page.waitForFunction(() => window.__ready);
// And wait for enough frames for AA/animation to settle
await page.evaluate(() => new Promise(r => {
let n = 0;
function step() { if (++n > 10) r(); else requestAnimationFrame(step); }
requestAnimationFrame(step);
}));
5. Testing WebGL in CI
Headless Chrome via Playwright has WebGL. Use Docker with --use-gl=swiftshader flag for deterministic software rendering.
Alternatively: WebGL-on-Node via headless-gl (CPU-based, slow, deterministic).
6. ECS system tests
import { createWorld, addEntity, addComponent } from 'bitecs';
import { Position, Velocity } from '../components';
import { MovementSystem } from '../systems';
test('movement moves by velocity * dt', () => {
const w = createWorld();
const e = addEntity(w);
addComponent(w, Position, e); addComponent(w, Velocity, e);
Position.x[e] = 0;
Velocity.x[e] = 5;
MovementSystem(w, 0.5);
expect(Position.x[e]).toBe(2.5);
});
7. Performance regression
test('fps stays above 55 with 10k instances', async ({ page }) => {
await page.goto('/stress.html?n=10000');
const fps = await page.evaluate(() => window.measureFps());
expect(fps).toBeGreaterThan(55);
});
Catches perf cliffs in code review.
8. Flake fighters
- Disable animations during snapshot tests.
- Wait for
__readyflag after load. - Wait N rAF cycles after ready.
- Use software rendering in CI (swiftshader).
- Allow tiny pixel diff threshold (TAA jitter).
9. Playwright setup
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: { baseURL: 'http://localhost:5173' },
projects: [{
name: 'chrome', use: {
launchOptions: { args: ['--use-gl=swiftshader', '--disable-gpu-vsync'] }
}
}]
});
10. Takeaways
- Split code: pure math (unit-testable) vs scene mutations.
- Snapshot tests catch visual regressions automatically.
- Use
__readyflag + wait N rAFs for deterministic captures. - Headless Chrome + swiftshader for deterministic CI.
- Perf tests catch regressions in PR review.
Testing is a CI-side workflow. No interactive demo here — see the Playwright config and test examples above.