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_ES6 build, or a hand-written loader.
  • [ ] Optionally @vitest/browser and 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.

Which layer each kind of test covers Native unit tests cover the algorithm in Rust or C; Vitest tests in Node cover the wrapper, the glue and the module together; browser-mode tests add the real engine, workers and Web APIs; end-to-end tests cover the whole page. cargo test / native unit tests the algorithm itself — fastest, most numerous Vitest in Node wrapper + glue + module: strings, memory, errors at the boundary Vitest browser mode the same tests in a real engine: workers, fetch, Web APIs end-to-end tests the page as users see it — slowest, fewest

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.

The test pipeline for a Wasm package Each test run builds the module in dev mode, runs Vitest in Node for fast boundary tests, runs the same files in Chromium and Firefox through browser mode, and runs a short smoke suite against the release build. wasm-pack --dev fresh pkg/ every run vitest (Node) boundary tests, ~1 s browser mode Chromium + Firefox release smoke optimized build sanity

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 failed or Invalid URL under Node. The glue is trying to fetch a file: URL. Pass the bytes to init, or build with --target nodejs for 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 sure pkg/ 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.

Suite duration by environment The same 120 boundary tests run under Vitest in Node and in browser mode with Chromium and Firefox. Browser startup dominates browser-mode time. seconds for the full suite Node 1.3 s browser mode, Chromium 6.8 s browser mode, Firefox 7.4 s

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.

← Back to Testing & Verifying Wasm Builds