WebAssembly Testing Guide: cargo test, wasm-pack, and Browser Runners

WebAssembly Testing Guide: cargo test, wasm-pack, and Browser Runners

WebAssembly is no longer a browser novelty. Wasm modules power plugin systems, edge compute functions, serverless runtimes, and embedded logic in languages from Rust to C++ to Go. And like any production code, Wasm modules need tests — but testing Wasm is different from testing native code.

The core challenge: Wasm runs in a sandboxed environment with a different memory model, no OS access, and potentially different behavior from native. Code that passes cargo test on your laptop may behave differently when compiled to Wasm. This guide covers the complete testing stack for Rust WebAssembly projects.

The Wasm Testing Stack

For Rust WebAssembly projects, you have three complementary testing approaches:

  1. Native tests with cargo test — test your logic as regular Rust, targeting the host platform. Fast, but doesn't catch Wasm-specific issues.
  2. wasm-pack test — compile your code to Wasm and run tests inside a JS environment (Node.js or a headless browser). Slower but catches real Wasm behavior.
  3. Browser runner tests — run tests in real browsers (Chrome, Firefox, Safari) using wasm-pack test --chrome --firefox. Most realistic, slowest.

Use all three: native tests for fast iteration, wasm-pack for pre-push validation, browser tests in CI for release confidence.

Setting Up a Rust Wasm Project

# Cargo.toml
[package]
name = "my-wasm-lib"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]  # Both are required

[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"

[dev-dependencies]
wasm-bindgen-test = "0.3"

[profile.release]
opt-level = "s"  # Optimize for size in Wasm

Native Tests with cargo test

Test your pure logic as regular Rust first. Keep Wasm-specific code behind feature flags or wrapper modules:

// src/lib.rs
use wasm_bindgen::prelude::*;

/// Parse a CSV row and return the fields.
/// This is pure logic — no Wasm-specific APIs — so it's fully testable natively.
pub fn parse_csv_row(row: &str) -> Vec<String> {
    let mut fields = Vec::new();
    let mut current = String::new();
    let mut in_quotes = false;
    
    for ch in row.chars() {
        match ch {
            '"' => in_quotes = !in_quotes,
            ',' if !in_quotes => {
                fields.push(current.trim().to_string());
                current = String::new();
            }
            _ => current.push(ch),
        }
    }
    fields.push(current.trim().to_string());
    fields
}

/// Compute statistics over a numeric dataset.
pub fn compute_stats(values: &[f64]) -> Option<(f64, f64, f64)> {
    if values.is_empty() {
        return None;
    }
    let mean = values.iter().sum::<f64>() / values.len() as f64;
    let variance = values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / values.len() as f64;
    let std_dev = variance.sqrt();
    let min = values.iter().cloned().fold(f64::INFINITY, f64::min);
    Some((mean, std_dev, min))
}

#[wasm_bindgen]
pub fn parse_and_sum(csv_row: &str) -> f64 {
    parse_csv_row(csv_row)
        .iter()
        .filter_map(|s| s.parse::<f64>().ok())
        .sum()
}

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_parse_csv_simple() {
        let result = parse_csv_row("apple,banana,cherry");
        assert_eq!(result, vec!["apple", "banana", "cherry"]);
    }
    
    #[test]
    fn test_parse_csv_with_quotes() {
        let result = parse_csv_row(r#""hello, world",foo,bar"#);
        assert_eq!(result[0], "hello, world");
        assert_eq!(result.len(), 3);
    }
    
    #[test]
    fn test_parse_csv_empty_fields() {
        let result = parse_csv_row("a,,c");
        assert_eq!(result, vec!["a", "", "c"]);
    }
    
    #[test]
    fn test_compute_stats_empty_returns_none() {
        assert!(compute_stats(&[]).is_none());
    }
    
    #[test]
    fn test_compute_stats_single_value() {
        let (mean, std_dev, min) = compute_stats(&[5.0]).unwrap();
        assert!((mean - 5.0).abs() < 1e-10);
        assert!((std_dev - 0.0).abs() < 1e-10);
        assert!((min - 5.0).abs() < 1e-10);
    }
    
    #[test]
    fn test_compute_stats_known_values() {
        let (mean, std_dev, _) = compute_stats(&[2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]).unwrap();
        assert!((mean - 5.0).abs() < 1e-10);
        assert!((std_dev - 2.0).abs() < 1e-10);
    }
}

Run natively:

cargo test

Wasm-Specific Tests with wasm-pack

Install wasm-pack:

cargo install wasm-pack

Write tests that require the Wasm environment or test JS interop:

// src/wasm_tests.rs
use wasm_bindgen_test::*;
use wasm_bindgen::prelude::*;
use js_sys::Array;

// Run these tests in a browser environment
wasm_bindgen_test_configure!(run_in_browser);

// Or run in Node.js (no browser needed):
// wasm_bindgen_test_configure!(run_in_node);

#[wasm_bindgen_test]
fn test_parse_and_sum_in_wasm() {
    // This runs the actual Wasm binary
    let result = super::parse_and_sum("1.5,2.5,3.0");
    assert!((result - 7.0).abs() < 1e-10);
}

#[wasm_bindgen_test]
fn test_wasm_returns_js_array() {
    use super::get_parsed_fields; // A function that returns JsValue
    
    let result = get_parsed_fields("a,b,c");
    // Verify JS interop works correctly
    assert!(result.is_truthy());
}

#[wasm_bindgen_test]
fn test_empty_string_does_not_panic() {
    // Wasm panics terminate the entire module — this is critical to test
    let result = super::parse_and_sum("");
    assert_eq!(result, 0.0);
}

#[wasm_bindgen_test]
fn test_large_input_does_not_oom() {
    // Test memory behavior with large inputs in the Wasm sandbox
    let large_row = (0..10_000)
        .map(|i| i.to_string())
        .collect::<Vec<_>>()
        .join(",");
    
    let result = super::parse_and_sum(&large_row);
    let expected: f64 = (0..10_000_f64).sum();
    assert!((result - expected).abs() < 1.0); // Allow float rounding
}

Run in Node.js (no browser needed for CI):

wasm-pack test --node

Run in headless Chrome:

wasm-pack test --headless --chrome

Run in headless Firefox:

wasm-pack test --headless --firefox

Testing Wasm Panics

Wasm panics are different from native panics. In a native Rust program, a panic unwinds the stack and prints a message. In Wasm, a panic (by default) throws a JavaScript exception and terminates the module instance. You must test panic behavior explicitly:

#[wasm_bindgen]
pub fn divide(a: f64, b: f64) -> f64 {
    if b == 0.0 {
        // Don't panic in Wasm — return a sentinel or use Result instead
        return f64::NAN;
    }
    a / b
}

#[wasm_bindgen_test]
fn test_divide_by_zero_returns_nan() {
    let result = divide(10.0, 0.0);
    assert!(result.is_nan(), "Division by zero should return NaN, not panic");
}

#[wasm_bindgen_test]
fn test_divide_normal() {
    let result = divide(10.0, 4.0);
    assert!((result - 2.5).abs() < 1e-10);
}

Testing console.log Output

Wasm modules often use console.log for debugging. Test that logging calls are correct:

use web_sys::console;

#[wasm_bindgen]
pub fn process_with_logging(data: &str) -> String {
    console::log_1(&format!("Processing: {}", data).into());
    data.to_uppercase()
}
#[wasm_bindgen_test]
fn test_process_with_logging() {
    // The log output goes to browser console or Node.js stdout
    // Focus on the return value — console output is for debugging only
    let result = super::process_with_logging("hello");
    assert_eq!(result, "HELLO");
}

CI Pipeline

# .github/workflows/wasm-tests.yml
name: WebAssembly Tests

on: [push, pull_request]

jobs:
  native-tests:
    name: Native cargo test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - run: cargo test --lib

  wasm-node-tests:
    name: wasm-pack test (Node.js)
    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
      - run: wasm-pack test --node

  wasm-browser-tests:
    name: wasm-pack test (Headless Chrome)
    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: Install Chrome
        run: |
          wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
          echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee /etc/apt/sources.list.d/google-chrome.list
          sudo apt-get update && sudo apt-get install -y google-chrome-stable
      - run: wasm-pack test --headless --chrome

Production Monitoring

Wasm modules deployed to production can fail in ways that tests don't catch: browser compatibility regressions, CDN-served stale binaries, or instantiation failures in older environments. HelpMeTest lets you schedule real-browser tests that load your Wasm module and exercise its API, catching regressions before users report them.

Conclusion

Wasm testing requires three layers: native cargo test for fast logic validation, wasm-pack test --node for environment correctness without browser overhead, and headless browser tests for the most realistic validation. The most critical Wasm-specific tests are panic safety (Wasm panics kill the module instance), memory behavior with large inputs, and JS interop correctness. Build all three layers into CI and you'll catch Wasm-specific regressions before they ship.

Read more