Testing Rust WebAssembly with wasm-pack: wasm-bindgen-test in Browser and Node.js
wasm-pack is the standard toolchain for building and testing Rust code compiled to WebAssembly. Its wasm-pack test command runs your Rust tests inside a real browser (headless Chrome, Firefox, or Safari) or under Node.js, giving you confidence that your WASM module behaves correctly in the environments where it will actually run. This guide covers the full testing workflow from setup to async tests and web API mocks.
Key Takeaways
#[wasm_bindgen_test] is the test macro, not #[test]. The standard #[test] attribute does not work in a browser WASM context. wasm-bindgen-test provides the browser-aware equivalent.
Run wasm-pack test --headless --chrome for browser tests. This spins up a headless Chrome instance and runs your tests there — the only way to test actual DOM, Web APIs, and browser globals.
Use --node when you don't need a browser. Node.js mode is faster, simpler to set up in CI, and sufficient for pure logic tests that don't touch browser-specific APIs.
Async tests work with #[wasm_bindgen_test] directly. Return JsFuture or use wasm-bindgen-futures::JsFuture — no separate async test harness needed.
Mock Web APIs by injecting JS before your Rust code runs. Use js_sys::eval() or web-sys window methods to set up test fixtures the same way you would in JavaScript.
Why Testing WASM in Rust Is Different
When you compile Rust to WebAssembly, you're not producing a standalone binary — you're producing a module that will run inside a JavaScript host environment. That changes what "testing" means:
- Standard
cargo testruns on your host machine, not inside a WebAssembly runtime. Tests pass on x86_64 but may fail or behave differently in WASM because of integer overflow semantics, missing POSIX APIs, or JavaScript interop bugs. - Web APIs (DOM, fetch, Canvas, WebGL) don't exist outside a browser. You can't test code that calls
document.getElementById()with plaincargo test. - Memory layout and alignment differ between native and WASM targets. Off-by-one pointer bugs that hide on x86 can surface in WASM.
wasm-pack test solves this by compiling your test binary to WASM and running it inside the actual host environment — browser or Node.js — where your module will ship.
Setting Up wasm-pack
Install wasm-pack if you haven't already:
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
<span class="hljs-comment"># or with cargo:
cargo install wasm-packCreate a new WASM library project:
cargo new --lib my-wasm-lib
cd my-wasm-libYour Cargo.toml needs the WASM-specific dependencies:
[package]
name = "my-wasm-lib"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "HtmlElement", "console"] }
[dev-dependencies]
wasm-bindgen-test = "0.3"
[profile.release]
opt-level = "s"The "rlib" crate type alongside "cdylib" is important — it lets you run the same code with both cargo test (for host-side logic) and wasm-pack test (for WASM target tests).
Your First wasm-bindgen-test
Here's a simple Rust library with a function and a test:
// src/lib.rs
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
#[wasm_bindgen]
pub fn fibonacci(n: u32) -> u32 {
match n {
0 => 0,
1 => 1,
_ => fibonacci(n - 1) + fibonacci(n - 2),
}
}Now write your tests using the wasm_bindgen_test macro:
// tests/wasm_tests.rs (or inline in src/lib.rs under #[cfg(test)])
use wasm_bindgen_test::*;
use my_wasm_lib::{add, greet, fibonacci};
// This tells wasm-pack to run tests in a browser context
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
fn test_add_positive_numbers() {
assert_eq!(add(2, 3), 5);
}
#[wasm_bindgen_test]
fn test_add_negative_numbers() {
assert_eq!(add(-5, 3), -2);
}
#[wasm_bindgen_test]
fn test_add_overflow_behavior() {
// Test that WASM handles i32 overflow the same as we expect
let result = add(i32::MAX, 1);
assert_eq!(result, i32::MIN); // wrapping on overflow
}
#[wasm_bindgen_test]
fn test_greet_basic() {
assert_eq!(greet("World"), "Hello, World!");
}
#[wasm_bindgen_test]
fn test_greet_empty_string() {
assert_eq!(greet(""), "Hello, !");
}
#[wasm_bindgen_test]
fn test_fibonacci_base_cases() {
assert_eq!(fibonacci(0), 0);
assert_eq!(fibonacci(1), 1);
}
#[wasm_bindgen_test]
fn test_fibonacci_sequence() {
let expected = [0, 1, 1, 2, 3, 5, 8, 13, 21, 34];
for (n, &expected_val) in expected.iter().enumerate() {
assert_eq!(fibonacci(n as u32), expected_val, "fibonacci({}) failed", n);
}
}Running Tests in Headless Chrome
# Headless Chrome (requires Chrome to be installed)
wasm-pack <span class="hljs-built_in">test --headless --chrome
<span class="hljs-comment"># Headless Firefox
wasm-pack <span class="hljs-built_in">test --headless --firefox
<span class="hljs-comment"># Non-headless (opens real browser window — useful for debugging)
wasm-pack <span class="hljs-built_in">test --chromeSample output:
[INFO]: Checking for the Wasm target...
[INFO]: Compiling to Wasm...
Compiling my-wasm-lib v0.1.0
Finished test profile [unoptimized + debuginfo] target(s) in 3.42s
[INFO]: Installing wasm-bindgen-cli...
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: Running tests in Chrome...
running 7 tests
test wasm_tests::test_add_positive_numbers ... ok
test wasm_tests::test_add_negative_numbers ... ok
test wasm_tests::test_add_overflow_behavior ... ok
test wasm_tests::test_greet_basic ... ok
test wasm_tests::test_greet_empty_string ... ok
test wasm_tests::test_fibonacci_base_cases ... ok
test wasm_tests::test_fibonacci_sequence ... ok
test result: ok. 7 passed; 0 failed; 0 ignoredRunning Tests in Node.js
If your code doesn't use browser-specific APIs, Node.js mode is faster and CI-friendly:
// For Node.js tests, use this configuration instead:
// wasm_bindgen_test_configure!(run_in_node_experimental);
// Or simply omit the configure macro — Node.js is the defaultwasm-pack test --nodeNode.js mode doesn't need Chrome or a display server, making it ideal for CI pipelines where installing a browser is inconvenient.
Testing DOM Interactions
When your WASM code manipulates the DOM, you need to run in a browser context and interact with real DOM elements:
use wasm_bindgen::prelude::*;
use web_sys::{Document, HtmlElement, window};
#[wasm_bindgen]
pub fn create_greeting_element(document: &Document, name: &str) -> HtmlElement {
let el = document.create_element("div")
.unwrap()
.dyn_into::<HtmlElement>()
.unwrap();
el.set_text_content(Some(&format!("Hello, {}!", name)));
el.set_class_name("greeting");
el
}
#[wasm_bindgen]
pub fn count_elements_by_class(document: &Document, class_name: &str) -> u32 {
document
.get_elements_by_class_name(class_name)
.length()
}// tests/dom_tests.rs
use wasm_bindgen_test::*;
use web_sys::{window, Document};
use my_wasm_lib::{create_greeting_element, count_elements_by_class};
wasm_bindgen_test_configure!(run_in_browser);
fn get_document() -> Document {
window().unwrap().document().unwrap()
}
#[wasm_bindgen_test]
fn test_create_greeting_element_content() {
let document = get_document();
let el = create_greeting_element(&document, "Alice");
assert_eq!(el.text_content(), Some("Hello, Alice!".to_string()));
}
#[wasm_bindgen_test]
fn test_create_greeting_element_class() {
let document = get_document();
let el = create_greeting_element(&document, "Bob");
assert_eq!(el.class_name(), "greeting");
}
#[wasm_bindgen_test]
fn test_count_elements_by_class() {
let document = get_document();
let body = document.body().unwrap();
// Set up DOM fixture
for _ in 0..3 {
let div = document.create_element("div").unwrap();
div.set_class_name("test-item");
body.append_child(&div).unwrap();
}
let count = count_elements_by_class(&document, "test-item");
assert!(count >= 3, "Expected at least 3 .test-item elements, got {}", count);
}Async Tests with JsFuture
Many real-world WASM modules work with Promises — fetch calls, timers, IndexedDB. wasm-bindgen-test supports async tests natively:
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, RequestMode, Response};
#[wasm_bindgen]
pub async fn fetch_json(url: &str) -> Result<JsValue, JsValue> {
let mut opts = RequestInit::new();
opts.method("GET");
opts.mode(RequestMode::Cors);
let request = Request::new_with_str_and_init(url, &opts)?;
let window = web_sys::window().unwrap();
let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?;
let resp: Response = resp_value.dyn_into()?;
let json = JsFuture::from(resp.json()?).await?;
Ok(json)
}// tests/async_tests.rs
use wasm_bindgen::prelude::*;
use wasm_bindgen_test::*;
use wasm_bindgen_futures::JsFuture;
use js_sys::Promise;
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
async fn test_promise_resolves() {
// Test that a simple Promise resolves correctly
let promise = Promise::resolve(&JsValue::from_str("hello"));
let result = JsFuture::from(promise).await.unwrap();
assert_eq!(result, JsValue::from_str("hello"));
}
#[wasm_bindgen_test]
async fn test_timer_fires() {
use web_sys::window;
use js_sys::Promise;
// Create a 10ms delay using setTimeout wrapped in a Promise
let promise = Promise::new(&mut |resolve, _reject| {
window()
.unwrap()
.set_timeout_with_callback_and_timeout_and_arguments_0(
&resolve,
10,
)
.unwrap();
});
JsFuture::from(promise).await.unwrap();
// If we reach here, the timer fired successfully
}
#[wasm_bindgen_test]
async fn test_fetch_mock() {
// In real tests you'd mock fetch at the JS level
// This demonstrates async test structure
let window = web_sys::window().unwrap();
assert!(window.location().href().is_ok());
}Mocking Web APIs
The cleanest way to mock browser APIs in wasm-bindgen tests is to inject JavaScript stubs using js_sys::eval() before your Rust code runs:
use wasm_bindgen_test::*;
use js_sys::eval;
use wasm_bindgen::JsValue;
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
fn test_with_mocked_local_storage() {
// Inject a localStorage mock
eval(r#"
window.__testStorage = {};
window.localStorage = {
getItem: (key) => window.__testStorage[key] || null,
setItem: (key, val) => { window.__testStorage[key] = val; },
removeItem: (key) => { delete window.__testStorage[key]; },
clear: () => { window.__testStorage = {}; }
};
"#).unwrap();
// Now test your Rust code that uses localStorage
// my_wasm_lib::save_preference("theme", "dark");
// let pref = my_wasm_lib::load_preference("theme");
// assert_eq!(pref, Some("dark".to_string()));
}
#[wasm_bindgen_test]
fn test_with_mocked_fetch() {
// Mock fetch to return a fixed response
eval(r#"
window.fetch = async (url) => {
return {
ok: true,
status: 200,
json: async () => ({ message: "mocked", url }),
};
};
"#).unwrap();
// Now your async Rust fetch calls will hit the mock
}CI Integration
Add a GitHub Actions step to run your WASM tests:
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test-wasm:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Install wasm-pack
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- name: Run WASM tests in Node.js (fast, no browser needed)
run: wasm-pack test --node
- name: Run WASM tests in headless Chrome
uses: browser-actions/setup-chrome@latest
- run: wasm-pack test --headless --chromeFor unit tests that don't need a browser, prefer --node in CI for speed. Reserve --headless --chrome for tests that genuinely touch browser APIs.
End-to-End Testing with HelpMeTest
Unit tests with wasm-pack verify your Rust logic and browser-API interactions at the module level. But once your WASM module is embedded in a real web application, you need end-to-end tests that drive the full user experience — loading the page, triggering WASM-powered features, and asserting on visible results.
HelpMeTest lets you write those E2E tests in plain English without code. You describe what a user does ("click the Calculate button, check that the result shows 42"), HelpMeTest runs that against your deployed app, and reports pass/fail. When a wasm-pack test catches a Rust logic bug and an HelpMeTest scenario catches a UI integration regression, you have full-stack confidence in your WebAssembly application.
Run wasm-pack test in your CI pipeline for fast Rust-level feedback, and schedule HelpMeTest monitors to continuously verify your deployed WASM app behaves correctly for real users.