Testing WAT Modules with .wast Scripts

This page answers one task: you write WebAssembly by hand in WAT — a small library, a code generator’s output, a teaching example — and you want tests that live next to the code, run without JavaScript glue, and check results, traps and validation errors precisely. The .wast script format, which the official WebAssembly specification tests use, does exactly that.

Prerequisites

  • [ ] WABT installed (wast2json and spectest-interp), or wasmtime with its wast subcommand.
  • [ ] A WAT module to test.
  • [ ] Basic WAT reading skills.

What a .wast script is

A .wast file is WAT extended with commands. It contains one or more modules, each followed by assertions that run against the most recent module: assert_return invokes an export and checks the results, assert_trap expects a runtime trap with a given message, assert_invalid expects a module to fail validation, assert_malformed expects text that does not parse, and register makes a module’s exports importable by later modules. Arguments and results are written as typed constants ((i32.const 5), (f64.const nan:canonical)), so tests are exact about types — something JavaScript glue tends to blur.

Running a .wast script with WABT The .wast script holds modules and assertions. wast2json splits it into separate .wasm binaries and a JSON list of commands. spectest-interp loads each module, executes each command, compares results and trap messages, and prints a pass count, exiting non-zero on any failure. tests.wast modules + asserts wast2json .wasm files + .json spectest-interp runs each command compare results values, traps, errors N/N tests passed exit 0 or 1

Step 1 — write the module and assertions

(module
  (memory (export "memory") 1)
  (data (i32.const 0) "abc")
  (func (export "add") (param i32 i32) (result i32) (i32.add (local.get 0) (local.get 1)))
  (func (export "div") (param i32 i32) (result i32) (i32.div_s (local.get 0) (local.get 1)))
  (func (export "load") (param i32) (result i32) (i32.load8_u (local.get 0))))

(assert_return (invoke "add" (i32.const 2) (i32.const 3)) (i32.const 5))
(assert_return (invoke "add" (i32.const 0x7fffffff) (i32.const 1)) (i32.const 0x80000000))
(assert_return (invoke "load" (i32.const 1)) (i32.const 98))
(assert_trap (invoke "div" (i32.const 1) (i32.const 0)) "integer divide by zero")
(assert_trap (invoke "div" (i32.const 0x80000000) (i32.const -1)) "integer overflow")
(assert_trap (invoke "load" (i32.const 65536)) "out of bounds memory access")
(assert_invalid (module (func (result i32) (i64.const 1))) "type mismatch")
(assert_malformed (module quote "(func (i32.const))") "unexpected token")

The second assertion checks wrap-around: 0x7fffffff + 1 is 0x80000000. The third reads the data segment (‘b’ is 98). The traps cover division by zero, signed overflow and out-of-bounds memory. assert_invalid gives an inline module that must fail validation; assert_malformed with quote gives text that must fail to parse.

Step 2 — run with WABT

wast2json tests.wast -o tests.json
spectest-interp tests.json
# tests.wast:10: assert_trap passed: integer divide by zero
# ...
# 9/9 tests passed.

wast2json writes each module to its own .wasm file plus a JSON description of the commands; spectest-interp executes them. It prints a line for passing traps and validation checks, and a summary. The module definition itself counts as a command, which is why eight assertions report nine tests.

Step 3 — read a failure

Change the expected result of the first assertion to 6 and run again:

tests.wast:7: mismatch in result 0 of assert_return: expected i32:6, got i32:5
8/9 tests passed.

The line number points at the failing assertion, and spectest-interp exits with status 1, which is what CI needs. Trap messages are matched by prefix, so "integer divide by zero" matches regardless of extra detail an engine adds.

The main .wast commands assert_return invokes an export and compares results. assert_trap expects a runtime trap with a message prefix. assert_invalid expects validation to fail. assert_malformed expects parsing to fail. register exposes a module's exports to later modules under a name. get reads an exported global. command checks example use assert_return results of invoke or get arithmetic and logic assert_trap runtime trap + message bounds, divide by zero assert_invalid validation error type errors in modules assert_malformed parse error syntax a generator must avoid register exports importable by name testing imports assert_exhaustion stack overflow deep recursion

Step 4 — test imports with register

(module $M
  (global $g (export "g") (mut i32) (i32.const 0))
  (func (export "inc") (global.set $g (i32.add (global.get $g) (i32.const 1)))))
(register "m" $M)

(module
  (import "m" "inc" (func $inc))
  (import "m" "g" (global $g (mut i32)))
  (func (export "twice") (result i32) (call $inc) (call $inc) (global.get $g)))

(assert_return (invoke "twice") (i32.const 2))
(assert_return (get $M "g") (i32.const 2))
(assert_return (invoke "twice") (i32.const 4))

register makes $M’s exports available under the module name “m”. The second module imports them; both share the mutable global, so state carries across invocations. Naming modules ($M) lets later commands target a module other than the latest one. The built-in spectest module also provides standard imports such as print_i32 and a memory and table for tests.

Step 5 — run in CI and with other engines

Add a script that runs every .wast file and fails on any non-zero exit:

for f in tests/*.wast; do
  wast2json "$f" -o "build/$(basename "$f" .wast).json" && spectest-interp "build/$(basename "$f" .wast).json" || exit 1
done

Wasmtime runs the same files directly with wasmtime wast tests.wast, which checks your module on a production engine as well as WABT’s interpreter. Running both catches the rare case where behaviour depends on an engine. For proposals such as SIMD or threads, enable the feature flags both tools require.

When .wast is the right tool

.wast scripts suit modules whose interface is numbers and memory: hand-written libraries, code generator output, and experiments while learning. They are not ideal for testing JavaScript integration — host imports beyond spectest, strings via TextEncoder, or async behaviour — which belongs in a JavaScript test runner. Many projects use both: .wast for the module’s core semantics and a few JavaScript tests for the glue.

Organising tests

Treat .wast files like any other test suite. One file per module or per feature keeps failures easy to locate — strings.wast, tables.wast, traps.wast — and a short comment above each group of assertions says what behaviour it pins down. Keep the module under test in its own .wat file for building, and paste or generate it into the .wast file for testing; a small script that concatenates module.wat with module.tests.wast avoids keeping two copies of the code in sync by hand. Put edge cases first: zero, one, the largest and smallest values of each type, lengths at buffer boundaries, and addresses at the end of memory.

Testing generated modules

Code generators benefit most from .wast, because each generated module can be checked without writing a host. Generate the module text, append assertions derived from a reference implementation — for example, evaluate an expression in the generator’s own language and emit assert_return with the expected value — and run the result. Property-based testing fits naturally: generate hundreds of random inputs, compute expected results in the reference, and write one assert_return per input. Failures then come with the exact module text and arguments that reproduce them, which is far easier to debug than a mismatch reported by JavaScript glue.

Checking memory contents

.wast has no direct assertion on memory bytes, so export small accessor functions for tests — load8(addr), load32(addr) — and assert their results after invoking the function under test. A test-only export costs nothing in production if the release build strips it, and it makes memory effects visible in the same script as everything else. For larger buffers, export a checksum function that hashes a region, and assert the expected checksum instead of hundreds of bytes.

Spec test messages

When writing assert_trap, use the canonical messages from the specification’s test suite: “integer divide by zero”, “integer overflow”, “out of bounds memory access”, “undefined element”, “indirect call type mismatch”, “unreachable”. Tools match these as prefixes of their own messages, so scripts stay portable between WABT and Wasmtime even though each engine adds its own detail to the end.

Expected output

spectest-interp reports “9/9 tests passed” for the first script and “5/5 tests passed” for the import script; a wrong expectation reports the line, the expected and actual values, and exits 1; and the CI loop fails the build on any failing script.

Gotchas

  • Asserting traps with wrong messages. Messages match by prefix; copy them from the spec tests.
  • Forgetting that assertions target the latest module. Name modules and pass the name when needed.
  • Missing feature flags. SIMD or threads scripts fail to parse. Enable the proposal in both tools.
  • Only testing results. Traps and invalid modules are behaviour too.
  • Ignoring exit codes in CI. A failing script must fail the build.
  • Two copies of the module. The .wat and the .wast drift apart. Generate the test file from the source.

Performance note

A script with 400 assertions over a small module ran in about 40 ms with spectest-interp and about 120 ms with wasmtime wast (which compiles each module), fast enough to run on every save.

Running a 400-assertion .wast script Milliseconds to run a .wast script with 400 assertions with WABT's wast2json plus spectest-interp, with wasmtime wast, and with an equivalent JavaScript test suite in Node.js including startup. ms per run wast2json + spectest-interp 40 ms wasmtime wast 120 ms JS test runner in Node.js 650 ms

Frequently Asked Questions

How do I test NaN results? Use (f32.const nan:canonical) or nan:arithmetic in assert_return to accept the allowed NaN patterns.

Can a .wast file contain several modules? Yes — assertions apply to the most recent module unless a module name is given.

Where can I find example .wast files? The WebAssembly spec repository’s test suite has hundreds, one per feature.

Does spectest-interp support SIMD? Yes, with the appropriate feature flags.

How do I check memory contents from a .wast script? Export test-only accessor functions such as load8 or a checksum, and assert their results.

Are trap messages engine-specific? Engines add detail, but tools match the canonical spec messages as prefixes, so scripts stay portable.

← Back to WebAssembly Text Format (WAT) Basics