Faster Native GPU Testing for Vitest and Jest without a Browser
I built Vitest and Jest environments that give your tests real, GPU-backed WebGL and WebGPU contexts directly in Node.js. They use the same ANGLE and Dawn implementations as Chrome, so you get the same pixels without the overhead of launching a browser.
Ben Houston • • 7 min read
Testing graphics code has always been painful. If your code touches WebGL or WebGPU, the usual answer is to launch a headless Chrome with Puppeteer or Playwright, load a page, render, grab a screenshot, and ship the result back to your test runner. It works, but it is slow, it is heavy to install in CI, and it pulls your rendering tests out of the normal unit-test workflow everyone else on the team uses.

The alternative has usually been mocks. A mocked WebGL context lets your tests run, but it does not render anything, so it cannot tell you whether your shader compiles, whether your buffers are laid out correctly, or whether the image on screen is right.
So I built two sets of test environments that give you the real thing inside plain Node.js:
- vitest-gpu:
vitest-environment-webgl-node,vitest-environment-webgpu-node, andvitest-screenshotfor image baselines. - jest-gpu:
jest-environment-webgl-nodeandjest-environment-webgpu-node.
Switch the test environment, and document.createElement('canvas').getContext('webgl2') or navigator.gpu.requestAdapter() just work. Three.js, Babylon.js, and plain WebGL and WebGPU code run unchanged.
The Inspiration: Three.js Screenshots without Chrome#
This started with a pull request to Three.js from Renaud Rohlinger. Three.js generates screenshots of its examples in CI to catch rendering regressions, and historically every one of those screenshots went through Chrome.
Renaud's PR moves most of them out of the browser. Roughly three quarters of the examples now render directly in Node.js using native GPU libraries: his @onirenaud/node-webgl for WebGL and the webgpu package for WebGPU. Only the examples that genuinely need a browser, for things like DOM layout, 2D canvas, or media playback, still use Chrome.
The result was a big speed-up. The slowest CI shard went from about 15 minutes to under 6 minutes, roughly 2.6× faster, largely because there is no browser to download, launch, and drive for each example.
Three.js does not use Vitest or Jest, so Renaud wired this up by hand for its own test harness. I wanted anyone to be able to use the same approach with a one-line config change. That is what these packages are: the same native GPU approach, packaged as standard test environments for the two most popular JavaScript test runners.
Same Implementations as Chrome#
The important detail is that this is not an emulation or a reimplementation of WebGL or WebGPU. The underlying libraries are the ones Chrome itself uses:
- WebGL runs on ANGLE, Chrome's WebGL implementation, via
@onirenaud/node-webgl. - WebGPU runs on Dawn, Chrome's WebGPU implementation, via the
webgpupackage.
Because the rendering stack is the same, your tests exercise the same shader compilers and the same GPU backends that your users' browsers do. A screenshot rendered in a test matches what Chrome would render, without paying for the rest of the browser.
Render and Snapshot with Vitest#
Install the environment, the screenshot matcher, and whatever library you render with:
pnpm add -D vitest vitest-environment-webgpu-node vitest-screenshot three
Point Vitest at the environment:
// vitest.config.ts import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'webgpu-node' } });
Then write a normal test. This one renders a Three.js cube with the WebGPU renderer and compares it against an image baseline:
// cube.test.ts import * as THREE from 'three/webgpu'; import { expect, it } from 'vitest'; import { createCanvas } from 'vitest-environment-webgpu-node'; import { extendMatchers } from 'vitest-screenshot'; extendMatchers(); it('renders a cube', async () => { const canvas = createCanvas(256, 256); const renderer = new THREE.WebGPURenderer({ canvas: canvas.asElement() }); await renderer.init(); renderer.setSize(256, 256, false); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100); camera.position.z = 3; const cube = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshNormalMaterial()); cube.rotation.set(0.4, 0.6, 0); scene.add(cube); await renderer.renderAsync(scene, camera); await expect(canvas).toMatchScreenshot('cube.png'); renderer.dispose(); });
The first local run writes __screenshots__/cube.png next to the test. Commit it, and later runs diff against it. Baselines follow Vitest's normal snapshot rules: missing baselines are created locally but fail in CI, and vitest -u updates them. When a comparison fails, you get the actual image and a diff image written next to the baseline, so you can see exactly what changed.
For WebGL, use environment: 'webgl-node' and create the canvas the way you would in a browser:
const canvas = document.createElement('canvas'); const renderer = new THREE.WebGLRenderer({ canvas });
Render with Jest#
The Jest packages have the same shape. Set the environment in your Jest config:
// jest.config.mjs export default { testEnvironment: 'jest-environment-webgpu-node', };
Then render and read the pixels back:
import * as THREE from 'three/webgpu'; import { expect, it } from '@jest/globals'; import { createCanvas } from 'jest-environment-webgpu-node'; it('renders a cube', async () => { const canvas = createCanvas(256, 256); const renderer = new THREE.WebGPURenderer({ canvas: canvas.asElement() }); await renderer.init(); // ...build the scene as above... await renderer.renderAsync(scene, camera); const { width, height, data } = await canvas.readPixels(); const center = (Math.floor(height / 2) * width + Math.floor(width / 2)) * 4; expect(Array.from(data.subarray(center, center + 3))).not.toEqual([0, 0, 0]); renderer.dispose(); });
The Jest packages do not ship their own screenshot matcher. Instead, encode the readback to PNG and pair it with jest-image-snapshot, which Jest users likely already know. Both packages ship dual ESM and CommonJS builds; the jest-gpu README has a complete TypeScript and ESM setup you can copy.
Not Just Screenshots#
Screenshots are the obvious use, but a real GPU context in a unit test is useful for much more:
- Compute shaders. Request a WebGPU device, dispatch a compute pass, map the output buffer, and assert on the numbers. This is a great way to test GPU-side math without any rendering at all.
- Shader compilation. Catch GLSL and WGSL errors in CI instead of in a user's console.
- Resource handling. Test texture uploads, buffer layouts, and readbacks against a real driver.
The vitest-gpu demo and jest-gpu demo include device checks, uploads, readbacks, triangles, and rendering with Three.js, Babylon.js, Babylon Lite, and Vercel's vgpu.
On macOS and Windows, prebuilt binaries are used. On Linux CI, install Mesa and the tests run with software rendering, so you do not need a GPU on your CI runners.
Early Reaction#
The response so far has confirmed that I am not the only one who has felt this pain. As Shawn put it:
This is really something that's needed so badly. I've abandoned so many tests in the past because I didn't want to install complex dependencies.
That is the goal: GPU tests that are as easy to write, and as fast to run, as any other unit test.
Conclusion#
Renaud showed that most GPU rendering tests do not need a browser at all, and that dropping it makes them much faster. vitest-gpu and jest-gpu make that approach available to anyone with a single config change, using the same ANGLE and Dawn implementations Chrome ships, so you get the same results.
# Vitest pnpm add -D vitest-environment-webgl-node vitest-environment-webgpu-node vitest-screenshot # Jest pnpm add -D jest-environment-webgl-node jest-environment-webgpu-node
Both projects are on GitHub under the MIT license: vitest-gpu and jest-gpu. Their development was sponsored by Land of Assets. Issues and pull requests are welcome.