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

TypeToolWhat
UnitVitest/JestMath, ECS systems, state logic
SnapshotPlaywrightRender → screenshot → diff golden image
E2EPlaywrightFull 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 __ready flag 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 __ready flag + 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.