Testing Wasm Modules with Vitest
This page answers one task: write fast, ordinary JavaScript tests for code that calls into a WebAssembly module — the module’s exports and the wrapper around them — using Vitest, and run those tests both in Node and in a browser.
Prerequisites
- [ ] Node 20+ and Vitest 2+ (
npm install -D vitest). - [ ] A built module with JavaScript glue: wasm-pack output, an Emscripten
EXPORT_ES6build, or a hand-written loader. - [ ] Optionally
@vitest/browserand Playwright for browser mode.
What to test from the JavaScript side
Rust and C code is best tested in its own language — cargo test and native unit tests are faster and closer to the
code. But a WebAssembly module is also a component with a JavaScript interface, and that interface has its own bugs:
strings that are encoded incorrectly at the boundary, memory that is not freed, a wrapper that forgets to await
initialisation, views into linear memory that go stale after growth, error values that arrive as numbers instead of
exceptions. None of those show up in cargo test, because none of them exist until JavaScript is involved.
Vitest is a good fit for this layer. It runs ES modules natively, supports top-level await, executes test files in
parallel workers, and has a browser mode that runs the same tests in a real browser engine. Most importantly, it
tests the module exactly the way your application uses it: through the generated glue and your own wrapper.
Step 1 — load the module once per test file
Initialising a module takes time — compiling a few hundred kilobytes is milliseconds, but doing it in every test adds
up. Load it once per file at the top level; Vitest supports top-level await in test files:
// geometry.test.js
import { describe, it, expect } from "vitest";
import { readFile } from "node:fs/promises";
import init, { polygon_area, Path } from "../pkg/geometry.js";
// wasm-pack --target web: pass the bytes, since there is no fetch of file URLs in older Node
await init({ module_or_path: await readFile(new URL("../pkg/geometry_bg.wasm", import.meta.url)) });
describe("polygon_area", () => {
it("computes the area of a unit square", () => {
const pts = new Float64Array([0, 0, 1, 0, 1, 1, 0, 1]);
expect(polygon_area(pts)).toBeCloseTo(1.0, 12);
});
it("is zero for fewer than three points", () => {
expect(polygon_area(new Float64Array([0, 0, 1, 1]))).toBe(0);
});
});
Passing the bytes to init sidesteps the question of how the glue locates its binary under Node. For a
--target nodejs build, which loads synchronously from disk, a plain import is enough. Each test file runs in its
own worker with its own module instance, so files cannot interfere with each other’s memory.
Step 2 — test the boundary, not the algorithm again
The tests worth writing at this layer are the ones that only fail because of the boundary. Strings with non-ASCII characters, empty and very large inputs, values that must round-trip unchanged, and errors:
describe("boundary behaviour", () => {
it("round-trips non-ASCII names", () => {
const p = new Path("Zürich → 東京 🚆");
expect(p.name()).toBe("Zürich → 東京 🚆");
p.free();
});
it("throws a JS Error, not a number, on invalid input", () => {
expect(() => new Path("")).toThrowError(/empty name/);
});
it("handles a 10 MB input without corrupting the result", () => {
const pts = new Float64Array(1_250_000).map((_, i) => (i % 2 ? Math.sin(i) : Math.cos(i)));
expect(Number.isFinite(polygon_area(pts))).toBe(true);
});
});
The large-input test is the one that catches stale memory views: allocating a 10 MB copy forces linear memory to
grow, and glue or wrapper code that cached a Uint8Array over the old buffer reads garbage afterwards. That class of bug
is explained in why memory.grow invalidates pointers.
Step 3 — check for leaks between tests
Exported Rust structs live in Wasm memory until free() is called. Tests are a convenient place to verify the wrapper
frees what it allocates. Measure linear memory before and after a batch of operations:
import { memory } from "../pkg/geometry_bg.wasm"; // or read it from the init() result
it("does not leak across 10,000 Path constructions", () => {
const before = memory.buffer.byteLength;
for (let i = 0; i < 10_000; i++) {
const p = new Path(`path-${i}`);
p.free();
}
expect(memory.buffer.byteLength).toBe(before);
});
Memory size only grows in whole 64 KiB pages and never shrinks, so this test catches leaks large enough to trigger growth — which, at 10,000 iterations, any real leak is. For finer-grained accounting, export an allocation counter from the module, as in counting allocations with a wrapping allocator.
Step 4 — run the same tests in a browser
Node and browsers run the same WebAssembly engine family in Chrome’s case (V8), but not the same environment. Workers,
fetch, crypto.getRandomValues, OffscreenCanvas and cross-origin isolation exist only in browsers. Vitest’s
browser mode runs the test files in a real browser driven by Playwright:
// vitest.config.js
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
browser: {
enabled: true,
provider: "playwright",
instances: [{ browser: "chromium" }, { browser: "firefox" }],
headless: true,
},
},
});
In browser mode, replace the readFile call with the glue’s default loading — await init() fetches the binary relative
to the module — since there is no file system. A common pattern is a tiny helper that picks the right loader:
// test/load.js
export async function loadGeometry() {
const mod = await import("../pkg/geometry.js");
if (typeof window === "undefined") {
const { readFile } = await import("node:fs/promises");
await mod.default({ module_or_path: await readFile(new URL("../pkg/geometry_bg.wasm", import.meta.url)) });
} else {
await mod.default();
}
return mod;
}
Running in Firefox as well as Chromium is the cheapest way to catch engine differences — in SIMD support, in proposals that one engine ships earlier, or in error message formats your tests match against.
Step 5 — wire it into the build
Tests should run against a freshly built module, never a stale pkg/. Make the test script build first:
{
"scripts": {
"build:wasm": "wasm-pack build --dev --target web",
"test": "npm run build:wasm && vitest run",
"test:browser": "npm run build:wasm && vitest run --browser"
}
}
Use the dev build for tests: it keeps debug assertions and names, so a failure inside the module produces a readable stack trace. Run a smaller smoke suite against the release build too, because optimizations occasionally change behaviour — float results that differ in the last bit, or code that relied on an overflow check.
Expected output
✓ test/geometry.test.js (6 tests) 41ms
✓ test/path.test.js (4 tests) 118ms
✓ test/memory.test.js (2 tests) 302ms
Test Files 3 passed (3)
Tests 12 passed (12)
Duration 1.24s (transform 31ms, setup 0ms, collect 210ms, tests 461ms)
In browser mode the same summary appears once per browser instance, with the browser name next to each file.
Gotchas
TypeError: fetch failedorInvalid URLunder Node. The glue is trying to fetch afile:URL. Pass the bytes toinit, or build with--target nodejsfor Node-only tests.- Tests pass alone and fail together. Tests share one module instance per file, and one test left state behind — a global inside the module, or an object never freed. Reset state explicitly or split the file.
- Browser mode cannot find the
.wasm. The test server serves files relative to the project root; make surepkg/is inside it and not excluded by a.gitignore-driven filter. - Snapshot tests of float output fail across browsers. Compare with a tolerance, as covered in snapshot testing Wasm output.
Performance note
The Node suite of 120 boundary tests ran in 1.3 s, of which module compilation was 28 ms per file. The same suite in browser mode took 6.8 s per browser, almost all of it browser startup and page loading. That ratio is the reason to keep most tests in Node and run browser mode on a smaller set, or only in CI.
Frequently Asked Questions
Should I use Vitest or wasm-bindgen-test?
Both, for different layers. wasm-bindgen-test runs Rust tests inside Wasm and is best for testing Rust code that uses
web-sys; see unit testing Rust Wasm with wasm-bindgen-test.
Vitest tests what JavaScript callers experience.
Does Jest work instead?
Jest’s ES module and top-level await support has improved but still needs configuration. Vitest handles both natively,
which is why it is less friction for Wasm packages.
Can I test a module that uses threads?
In browser mode, if the test server sends cross-origin isolation headers. In Node, SharedArrayBuffer is always
available, so threaded modules can be tested there more easily.
Can tests call internal Rust functions that are not exported?
Not from JavaScript — only exports are visible. Either test them in cargo test, or export a small test-only API behind
a cargo feature that the test build enables and the release build does not.
How do I measure coverage of the Wasm code? Vitest’s coverage covers JavaScript only. For the module, see measuring code coverage for Rust Wasm.
Related
- Running Wasm tests in headless browsers — the Rust-side browser test runner.
- Designing a promise-based API around a Wasm module — the wrapper these tests exercise.
- Detecting forgotten free() calls in wasm-bindgen — leak hunting in more depth.
- Loading Wasm in Node.js with ES modules — how the Node side loads modules.
← Back to Testing & Verifying Wasm Builds