Unit Testing Rust Wasm with wasm-bindgen-test
This guide answers one task: run Rust tests compiled to WebAssembly inside a real browser, so the tests exercise the same code path, the same bindings and the same runtime that users will.
Prerequisites
- [ ] A Rust crate with
wasm-bindgen, built forwasm32-unknown-unknown. - [ ]
wasm-pack0.13+, orwasm-bindgen-climatching yourwasm-bindgenversion exactly. - [ ] Chrome or Firefox installed, plus the matching driver if you invoke the CLI directly.
- [ ] Tests that genuinely need a browser; everything else belongs in
cargo test.
Setting it up
Add the test harness as a dev dependency and configure where the tests run. Version alignment between
wasm-bindgen and wasm-bindgen-test is not optional — a mismatch produces errors about undefined
symbols that name neither crate.
[dev-dependencies]
wasm-bindgen-test = "0.3"
[dependencies]
wasm-bindgen = "0.2"
// tests/browser.rs
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser); // without this, tests run in Node
#[wasm_bindgen_test]
fn addition_works() {
assert_eq!(my_crate::add(2, 3), 5);
}
wasm-pack test --headless --chrome
The run_in_browser configuration matters more than it looks. Without it, the harness runs the tests
under Node, which has no window, no document and different behaviour around timers — so a test that
passes there can fail in the browser it was meant to verify.
Async tests and promises
Anything involving fetch, a timer or a promise needs an async test, which the macro supports directly.
use wasm_bindgen_futures::JsFuture;
use web_sys::window;
#[wasm_bindgen_test]
async fn fetches_a_fixture() {
let win = window().expect("no window");
let resp = JsFuture::from(win.fetch_with_str("/fixtures/sample.json"))
.await
.expect("fetch failed");
let resp: web_sys::Response = resp.dyn_into().unwrap();
assert!(resp.ok());
let text = JsFuture::from(resp.text().unwrap()).await.unwrap();
let body = text.as_string().unwrap();
assert!(body.contains("\"count\""));
}
Fixtures need serving from somewhere the harness page can reach. The simplest arrangement is a directory
the test server exposes; failing that, embed the fixture in the binary with include_bytes! and skip the
network entirely, which is faster and removes a source of flakiness.
Testing DOM interaction
Because the tests run in a real browser, they can create elements, dispatch events and assert on the result — which is the main reason to run tests here rather than natively.
#[wasm_bindgen_test]
fn renders_into_a_container() {
let document = web_sys::window().unwrap().document().unwrap();
let container = document.create_element("div").unwrap();
document.body().unwrap().append_child(&container).unwrap();
my_crate::render_summary(&container, &fixture_summary());
assert_eq!(container.query_selector_all("li").unwrap().length(), 3);
container.remove(); // clean up; tests share one document
}
Cleaning up matters: every test in a file runs against the same document, so an element left behind is visible to the next test. A helper that creates a fresh container and removes it on drop keeps this from becoming a source of order-dependent failures.
What fails differently from cargo test
Several things behave differently enough to surprise people who are used to native Rust tests.
Panics still fail the test, but the message arrives through the console and the stack trace is a
WebAssembly one. Installing console_error_panic_hook in a test setup function makes the message
readable, and it is worth doing unconditionally.
std::thread does not exist, so anything spawning a thread fails at runtime rather than compiling
differently. std::time::Instant is unavailable; use performance.now() through web_sys for timing.
Tests do not run in parallel, and they share one page. That makes global state genuinely global across the whole file, which is occasionally convenient and more often the cause of a test that passes alone and fails in the suite.
And output is buffered differently: println! goes to the console rather than the terminal, so
--nocapture behaves unlike its native counterpart. Prefer assertions with messages over printing.
Sharing setup between tests
Because tests share a page, setup and teardown need more care than in a native suite. There is no per-test process to throw away, so anything a test leaves behind persists.
A small guard type handles the common case of DOM cleanup, using Rust’s ordinary drop semantics:
struct Container(web_sys::Element);
impl Container {
fn new() -> Self {
let doc = web_sys::window().unwrap().document().unwrap();
let el = doc.create_element("div").unwrap();
doc.body().unwrap().append_child(&el).unwrap();
Container(el)
}
}
impl Drop for Container {
fn drop(&mut self) { self.0.remove(); }
}
#[wasm_bindgen_test]
fn renders_rows() {
let c = Container::new();
my_crate::render(&c.0, &fixture());
assert_eq!(c.0.children().length(), 3);
} // removed automatically, even on panic
For state that lives inside the module rather than the DOM — a cached instance, a global registry, an arena — expose a reset function from the crate under a test-only feature and call it at the start of each test. Relying on test order to leave the module in a workable state produces a suite that passes locally and fails in CI for reasons nobody can reproduce.
One more piece of setup is worth doing once, in a helper every test calls: installing the panic hook. Without it a failing assertion inside a deeply nested call reports nothing useful, and with it you get the message and the file and line.
Expected output
A passing run reports per-test results from inside the browser:
wasm-pack test --headless --chrome
[INFO]: Checking for the Wasm target...
[INFO]: Compiling to Wasm...
Finished test [unoptimized + debuginfo] target(s) in 4.21s
Running unittests src/lib.rs
running 7 tests
test browser::addition_works ... ok
test browser::renders_into_a_container ... ok
test browser::fetches_a_fixture ... ok
test browser::handles_empty_input ... ok
test browser::exports_are_reachable ... ok
test browser::detects_missing_capability ... ok
test browser::cleans_up_allocations ... ok
test result: ok. 7 passed; 0 failed; 0 ignored
A failure prints the assertion and a stack trace; if the trace is unreadable addresses rather than function names, the panic hook is not installed.
Keeping the suite fast
Browser tests are an order of magnitude slower to start than native ones, and the compile step dominates. Three things keep the loop tolerable.
Keep the browser suite small — ten to twenty tests covering wiring and DOM interaction, with everything
else native. Run cargo test on every save and the browser suite before pushing.
Use --no-default-features or a dedicated test feature to avoid compiling parts of the crate the browser
tests do not exercise, which can halve the build time for a large crate.
And run one browser locally, several in CI. wasm-pack test --headless --chrome is the fast local loop;
adding Firefox and WebKit belongs in the pipeline, where the wall-clock cost is not yours.
Gotchas
- Version mismatch between
wasm-bindgenand its CLI. Produces confusing link errors;wasm-packmanages it for you, which is a reason to use it. - Missing
run_in_browser. Tests run under Node and pass while the browser path is untested. - No panic hook. Failures report
unreachable executedwith no message. - Shared document state between tests. Clean up elements; tests share a page.
std::time::Instantin test code. Panics at runtime; useweb_systiming.- Fixtures fetched from a path the harness does not serve. Embed them, or serve them explicitly.
Performance note
For a crate with 140 native tests and 12 browser tests, cargo test completed in 1.9 s and
wasm-pack test --headless --chrome in 38 s, of which 31 s was compilation. That ratio is why the layer
split matters: putting the other 140 tests in the browser suite would have made the loop unusable while
adding nothing to coverage.
Frequently Asked Questions
Can I debug a browser test interactively?
Yes — drop --headless and the browser opens with the harness page, where you can set breakpoints and
inspect state. With DWARF information and the browser’s extension you can step through Rust source.
Do these tests work in CI?
Yes, with a headless browser installed. Most CI images include Chrome; otherwise install it explicitly and
pass --chrome. The
headless testing guide
covers the pipeline setup.
Can I run only one test?
Yes — wasm-pack test --headless --chrome -- browser::renders_rows filters by name in the same way cargo
does, which makes iterating on a single failing test far quicker than running the whole file.
How do I test code that needs cross-origin isolation? The harness server does not set those headers by default, so threaded code cannot be tested this way without customising it. Testing the single-threaded path in the browser and the threaded path natively is the usual compromise.
Does the harness support test filtering by attribute?
#[ignore] works as it does natively, which is the practical way to keep a slow or environment-dependent
test in the file without running it by default.
What should not be tested this way?
Pure logic, numerical algorithms, parsing and anything else with no browser involvement. Those tests cost
thirty times more here than in cargo test and tell you exactly the same thing, which over a year is a
large amount of waiting for no additional information.
Related
- Running Wasm tests in headless browsers — the CI side.
- Validating binaries with wasm-validate — checking the artifact rather than the behaviour.
- Calling Web APIs from Rust with wasm-bindgen — the bindings these tests exercise.
Used this way — a small, deliberate suite covering what only a browser can verify — the harness is one of
the more valuable tools in the Rust WebAssembly ecosystem. Used as a replacement for cargo test, it is
mostly a way to wait.
← Back to Testing & Verifying Wasm Builds