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 for wasm32-unknown-unknown.
  • [ ] wasm-pack 0.13+, or wasm-bindgen-cli matching your wasm-bindgen version 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.

What happens when you run the suite The test crate is compiled to WebAssembly, wasm-bindgen generates glue and a harness page, a local server serves them, and a headless browser runs the tests and reports results back to the terminal. cargo build test crate → .wasm wasm-bindgen glue + harness page local server serves with the right type headless browser runs them results back to the terminal Every stage can fail for its own reasons, which is why a suite that will not start is usually a toolchain problem rather than a test problem.

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.

Two runners, different rules Native tests run in parallel in separate processes with a real thread API and ordinary timing. Browser tests run sequentially in one shared page with no threads and browser timing APIs. cargo test parallel, isolated processes threads, Instant, filesystem readable panics and traces milliseconds per test wasm-bindgen-test sequential, one shared page no threads, browser timing only real DOM, real bindings seconds for the whole suite Use the right runner for each test rather than forcing everything into one: the browser suite should be small and the native suite large.

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.

Three test targets, three reaches A plain unit test runs natively and cannot touch web APIs. A wasm-bindgen test runs in Node or a browser. Only the browser configuration reaches the DOM and workers. #[test] native fastest; pure logic only, no web APIs at all #[wasm_bindgen_test] runs in Node; the module is real, the host is not wasm_bindgen_test_configure browser run: DOM, workers, real engine limits Keep the bulk of the suite native — it runs in milliseconds and needs no browser on the machine. Reserve the browser configuration for the handful of tests that genuinely need a page.

Gotchas

  • Version mismatch between wasm-bindgen and its CLI. Produces confusing link errors; wasm-pack manages 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 executed with no message.
  • Shared document state between tests. Clean up elements; tests share a page.
  • std::time::Instant in test code. Panics at runtime; use web_sys timing.
  • 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.

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