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 (
wast2jsonandspectest-interp), orwasmtimewith itswastsubcommand. - [ ] 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.
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.
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.
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.
Related
- Writing your first WAT module by hand — WAT basics.
- Converting WAT to Wasm with wat2wasm — WABT tools.
- Writing a string function in WAT — a module to test.
- Using tables and call_indirect in WAT — testing traps.
← Back to WebAssembly Text Format (WAT) Basics