Testing Rust WebAssembly with wasm-pack: Browser, Node.js, and CI
Rust compiled to WebAssembly introduces a new class of testing challenge: your code runs in a browser or Node.js context, not on the OS. Standard cargo test doesn't run wasm tests. wasm-pack test does.
This guide covers how to write testable Rust wasm code, use wasm-pack test with Node.js and headless browsers, and wire it into CI.
What wasm-pack test Does
wasm-pack test compiles your Rust code to WebAssembly, then runs the test binary in:
- Node.js — fastest, no browser required
- Headless Chrome — for browser-specific APIs
- Headless Firefox — for cross-browser coverage
- Chrome/Firefox — interactive, for development
Setup
Install wasm-pack:
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | shInstall wasm32 target:
rustup target add wasm32-unknown-unknownConfigure Cargo.toml:
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
[dev-dependencies]
wasm-bindgen-test = "0.3"Writing wasm Tests
The key difference from standard Rust tests: use #[wasm_bindgen_test] instead of #[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) -> u64 {
match n {
0 => 0,
1 => 1,
_ => {
let mut a = 0u64;
let mut b = 1u64;
for _ in 2..=n {
let c = a + b;
a = b;
b = c;
}
b
}
}
}// tests/wasm_tests.rs
use wasm_bindgen_test::*;
// Run tests in Node.js (default)
wasm_bindgen_test_configure!(run_in_node_experimental);
// Or run in browser:
// wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
fn test_add() {
assert_eq!(myapp::add(2, 3), 5);
assert_eq!(myapp::add(-1, 1), 0);
assert_eq!(myapp::add(0, 0), 0);
}
#[wasm_bindgen_test]
fn test_greet() {
assert_eq!(myapp::greet("Alice"), "Hello, Alice!");
assert_eq!(myapp::greet("World"), "Hello, World!");
}
#[wasm_bindgen_test]
fn test_fibonacci() {
assert_eq!(myapp::fibonacci(0), 0);
assert_eq!(myapp::fibonacci(1), 1);
assert_eq!(myapp::fibonacci(10), 55);
assert_eq!(myapp::fibonacci(20), 6765);
}Running Tests
# Run in Node.js (fast, no browser)
wasm-pack test --node
# Run in headless Chrome
wasm-pack test --headless --chrome
# Run in headless Firefox
wasm-pack test --headless --firefox
# Run in both browsers
wasm-pack test --headless --chrome --firefox
# Run a specific test
wasm-pack test --node -- test_fibonacciTesting Browser-Specific APIs
Some tests require a browser context (DOM, Web APIs):
// tests/browser_tests.rs
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
fn test_creates_dom_element() {
use web_sys::{window, HtmlElement};
let window = window().expect("no window");
let document = window.document().expect("no document");
let div = document.create_element("div").unwrap();
div.set_text_content(Some("Hello from Rust!"));
assert_eq!(div.text_content().unwrap(), "Hello from Rust!");
}
#[wasm_bindgen_test]
async fn test_fetch_api() {
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, Response};
// This test only runs in browser context
let opts = RequestInit::new();
let request = Request::new_with_str_and_init("/api/health", &opts).unwrap();
let window = web_sys::window().unwrap();
let response: Response = JsFuture::from(window.fetch_with_request(&request))
.await
.unwrap()
.dyn_into()
.unwrap();
assert_eq!(response.status(), 200);
}Testing Async Wasm Code
// For Node.js
wasm_bindgen_test_configure!(run_in_node_experimental);
#[wasm_bindgen_test]
async fn test_async_computation() {
use wasm_bindgen_futures::JsFuture;
use js_sys::Promise;
let promise = Promise::resolve(&JsValue::from(42));
let result = JsFuture::from(promise).await.unwrap();
assert_eq!(result.as_f64(), Some(42.0));
}
#[wasm_bindgen_test]
async fn test_our_async_function() {
let result = myapp::fetch_and_process("test-data").await;
assert!(result.is_ok());
}Testing JavaScript Interop
Test the boundary between Rust and JavaScript:
// src/lib.rs
use wasm_bindgen::prelude::*;
use js_sys::Array;
#[wasm_bindgen]
pub fn sum_array(arr: &Array) -> f64 {
let mut total = 0.0;
for i in 0..arr.length() {
if let Some(val) = arr.get(i).as_f64() {
total += val;
}
}
total
}// tests/wasm_tests.rs
#[wasm_bindgen_test]
fn test_sum_js_array() {
use js_sys::Array;
let arr = Array::new();
arr.push(&JsValue::from(1.0));
arr.push(&JsValue::from(2.0));
arr.push(&JsValue::from(3.0));
assert_eq!(myapp::sum_array(&arr), 6.0);
}
#[wasm_bindgen_test]
fn test_sum_empty_array() {
let arr = Array::new();
assert_eq!(myapp::sum_array(&arr), 0.0);
}
#[wasm_bindgen_test]
fn test_sum_array_with_non_numbers() {
let arr = Array::new();
arr.push(&JsValue::from(1.0));
arr.push(&JsValue::from_str("not a number")); // Should be skipped
arr.push(&JsValue::from(3.0));
assert_eq!(myapp::sum_array(&arr), 4.0);
}Separating Native and Wasm Tests
Use #[cfg(target_arch = "wasm32")] to conditionally compile tests:
// src/math.rs
pub fn multiply(a: f64, b: f64) -> f64 {
a * b
}
// Native tests
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod tests {
use super::*;
#[test]
fn test_multiply_native() {
assert_eq!(multiply(3.0, 4.0), 12.0);
}
}
// Wasm tests
#[cfg(test)]
#[cfg(target_arch = "wasm32")]
mod wasm_tests {
use super::*;
use wasm_bindgen_test::*;
#[wasm_bindgen_test]
fn test_multiply_wasm() {
assert_eq!(multiply(3.0, 4.0), 12.0);
}
}Run each separately:
cargo test # Native tests
wasm-pack test --node # Wasm testsCI Integration
# .github/workflows/wasm-tests.yml
name: WebAssembly Tests
on: [push, pull_request]
jobs:
test-node:
name: wasm-pack test (Node.js)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
target: wasm32-unknown-unknown
- name: Install wasm-pack
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- uses: actions/setup-node@v4
with: { node-version: "20" }
- name: Run wasm tests in Node.js
run: wasm-pack test --node
test-browser:
name: wasm-pack test (headless Chrome)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
target: wasm32-unknown-unknown
- name: Install wasm-pack
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
- name: Install Chrome
run: |
wget -q https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
sudo apt install ./google-chrome-stable_current_amd64.deb
- name: Run browser tests
run: wasm-pack test --headless --chromeDebugging wasm Test Failures
When a wasm test fails, you get a JavaScript stack trace, not a Rust one. Enable better error messages:
# .cargo/config.toml
[target.wasm32-unknown-unknown]
rustflags = ["-C", "debuginfo=2"]Add console_error_panic_hook for better panic messages:
[dependencies]
console_error_panic_hook = { version = "0.1", optional = true }
[features]
default = ["console_error_panic_hook"]// src/lib.rs
pub fn init_panic_hook() {
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
}Conclusion
wasm-pack test fills the gap between cargo test (native) and browser behavior. Use Node.js tests for fast iteration on pure logic, and headless browser tests for DOM and Web API interactions. The #[cfg(target_arch = "wasm32")] pattern lets you share test logic between native and wasm environments. Add both modes to CI and you'll catch the class of bugs that only appear in the wasm runtime.