Testing Error Paths Across the Boundary
This page answers one task: the happy path of a WebAssembly module is well tested, but its failures are not — and in production, errors arrive as unhandled rejections, opaque traps, or a module that silently stops working. You want a test suite that triggers every kind of failure the module can produce and checks exactly what JavaScript sees.
Prerequisites
- [ ] A JavaScript test runner that loads the built module (Vitest, Jest, Node’s test runner, or Playwright for browser-only modules).
- [ ] A wrapper layer that translates errors (see the related guides).
- [ ] The ability to build a test variant of the module with fault injection.
The kinds of failure to cover
Failures crossing the boundary fall into a small number of categories, and each needs its own tests because each travels differently:
- Returned errors — a Rust
Result::Error a C error code, converted to a JavaScript exception or result object by the wrapper. - Panics and aborts — Rust panics and C
abort(), which becomeRuntimeError: unreachableand usually leave the instance unusable. - Traps from bugs — out-of-bounds memory access, integer division by zero, invalid indirect calls, stack overflow.
- Resource failures — memory growth refused, allocation failure, tables full.
- Import failures — a JavaScript function the module imports throws or returns something unexpected.
- Boundary misuse — calling after
free(), passing detached buffers, calling before initialisation.
For each, the test asserts three things: the error type and code JavaScript receives, the message or context attached, and whether the instance is still usable afterwards — which decides whether the application can continue or must recreate it.
Step 1 — enumerate the module’s errors
List every error the API can return: the variants of each Rust error enum reachable from exports, every C error code, every documented C++ exception. Turn the list into a table in the test file, so a new variant without a test is visible in review:
const ERROR_CASES = [
{ name: "unsupported version", input: fixtures.v99, code: "UNSUPPORTED_VERSION" },
{ name: "truncated header", input: fixtures.truncated, code: "TRUNCATED" },
{ name: "bad chunk crc", input: fixtures.badCrc, code: "INVALID_CHUNK" },
{ name: "empty input", input: new Uint8Array(0), code: "TRUNCATED" },
];
describe.each(ERROR_CASES)("decode: $name", ({ input, code }) => {
test("throws a coded error", () => {
expect(() => decode(input)).toThrowError(expect.objectContaining({ code }));
});
test("instance still works afterwards", () => {
try { decode(input); } catch {}
expect(decode(fixtures.valid).width).toBe(640);
});
});
Step 2 — trigger panics and traps deliberately
Panics and traps should be rare in production, but the code that handles them — recreating the instance, reporting the error — must work. Expose test-only exports, behind a feature flag, that panic or trap on demand:
#[cfg(feature = "test-hooks")]
#[wasm_bindgen]
pub fn __test_panic() { panic!("deliberate test panic"); }
#[cfg(feature = "test-hooks")]
#[wasm_bindgen]
pub fn __test_oob() -> u8 { unsafe { *(u32::MAX as usize as *const u8) } }
test("a panic is reported and the instance is replaced", async () => {
const before = engine.instanceId;
await expect(engine.call("__test_panic")).rejects.toThrow(/unreachable/);
expect(reporter.last.code).toBe("TRAP");
expect(engine.instanceId).not.toBe(before); // wrapper recreated it
expect(await engine.call("decode", fixtures.valid)).toBeDefined();
});
Build the test variant in CI alongside the release build, never ship it, and assert in a release check that the release module has no __test_ exports.
Step 3 — inject resource failures
Allocation and memory-growth failures need a controlled environment: a small maximum memory, a fault-injecting allocator, or an imported memory with no room to grow. Test that functions designed to handle them return errors and leave the instance usable, and that functions not designed to handle them fail as a trap the wrapper recognises. The techniques are described in testing Wasm modules under memory pressure.
Step 4 — make imports fail
Modules that import JavaScript functions — logging, storage, fetch callbacks — must cope when those throw. Instantiate the module with imports replaced by failing stubs and assert the behaviour:
test("a failing storage import surfaces as STORAGE_ERROR", async () => {
const imports = makeImports({ storage_write: () => { throw new Error("quota exceeded"); } });
const mod = await instantiate(bytes, imports);
expect(() => mod.save(doc)).toThrowError(expect.objectContaining({ code: "STORAGE_ERROR" }));
});
A JavaScript exception thrown from an import unwinds through Wasm frames; if the module holds locks or has half-written state at that point, later calls may misbehave. Tests that call the module again after an import failure reveal that.
Step 5 — test boundary misuse
Generated bindings protect against some misuse, and your wrapper should protect against the rest. Test calling a method on a freed object (wasm-bindgen
throws “null pointer passed to rust”), using a typed-array view after memory growth, calling before init() resolves, and calling re-entrantly from a
callback. Each should produce a clear error rather than corrupt state.
Coverage that keeps up with the API
Error coverage decays as APIs grow. Two cheap guards help. First, generate the list of error codes from the source of truth — the Rust enum or C header —
and assert in a test that every code appears in ERROR_CASES. Second, run code coverage for the Wasm module (LLVM source-based coverage works for Rust
and C targets) and look specifically at error branches: an Err arm never executed in tests is an untested path.
Running error tests in the browser
Some failure behaviour differs between engines and between Node and browsers: trap messages vary in wording, stack depths before overflow differ, and memory limits differ. Assert on error types and your own codes rather than on engine messages, and run a subset of error tests in each browser with Playwright, particularly the ones involving stack overflow and memory limits.
Fixtures for malformed inputs
Returned-error tests need inputs that reliably trigger each error, and hand-crafting malformed binary files is tedious. Generate them from valid fixtures with small, named transformations: truncate at a given offset, flip a byte in the checksum, change the version field, duplicate a chunk, set a length field to its maximum value. Keep the transformations in a helper module so each test reads as “valid file, truncated after the header” rather than as a blob of bytes. When fuzzing or property tests find a new failure, minimise the input and add it as a fixture with a descriptive name, so the suite accumulates real-world failure shapes over time. Store fixtures small — a few hundred bytes where possible — so the suite stays fast and the fixtures stay readable in a hex viewer when a test fails.
Error tests for asynchronous and worker-based APIs
When the module runs in a worker behind a promise-based API, errors travel further: from Wasm to the worker’s JavaScript, through postMessage
serialisation, to the main thread’s promise. Structured cloning preserves Error objects’ name and message in current browsers but drops custom
properties such as code unless you serialise them explicitly. Test error paths end to end through the worker, asserting that codes and context survive
the trip, and that a trap in the worker produces a rejected promise rather than a promise that never settles — the most common failure of worker
wrappers. A test with a timeout that fails when a promise hangs catches that case.
Expected output
The decode suite runs 4 returned-error cases, 2 trap cases, 2 resource-failure cases, 3 import-failure cases and 4 misuse cases; every case asserts the JavaScript-visible error and whether the instance is usable; a test fails when a new error variant is added without a case; and the release build is checked to contain no test hooks.
Gotchas
- Asserting on engine trap messages. Wording differs between engines. Assert types and codes.
- Not checking the instance afterwards. Silent breakage goes unnoticed. Call it again.
- Shipping test hooks. Keep them behind a feature and check release exports.
- Untested import failures. Half-written state survives. Fail imports deliberately.
- Hand-maintained error lists. They drift. Generate them from the source.
Performance note
The full error-path suite runs in 1.9 s in Node and 7.4 s across three browsers in Playwright, about 12% of the module’s total test time.
Frequently Asked Questions
Should panics ever be expected in tests? Only through deliberate hooks; a panic on real input is a bug to fix.
How do I trigger a stack overflow?
A test hook that recurses deeply; assert it surfaces as a RangeError or RuntimeError depending on the engine.
Do I need browser tests for error paths? For engine-dependent behaviour — limits and stack depth — yes; the rest can run in Node.
What about leaks on error paths? Run each error case repeatedly and assert heap size is flat.
Why do my error codes disappear when the module runs in a worker? Structured cloning keeps an Error’s name and message but drops custom properties; serialise codes and context explicitly in the message.
Related
- Adding context to errors from Wasm — what the errors carry.
- Recovering a module after a trap — the recovery being tested.
- Validating inputs before they reach Wasm — checks to test.
- Measuring code coverage for Rust Wasm — finding untested branches.
← Back to Errors & Traps Across the Boundary