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:
- Native tests with
cargo test— test your logic as regular Rust, targeting the host platform. Fast, but doesn't catch Wasm-specific issues. 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.- 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 WasmNative 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 testWasm-Specific Tests with wasm-pack
Install wasm-pack:
cargo install wasm-packWrite 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 --nodeRun in headless Chrome:
wasm-pack test --headless --chromeRun in headless Firefox:
wasm-pack test --headless --firefoxTesting 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 --chromeProduction 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.