Testing Rust WebAssembly with wasm-pack: Browser, Node.js, and CI

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 | sh

Install wasm32 target:

rustup target add wasm32-unknown-unknown

Configure 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_fibonacci

Testing 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 tests

CI 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 --chrome

Debugging 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.

Read more

Start now free