wasm-bindgen Testing: JS/Wasm Interop and Headless Browser Execution
wasm-bindgen is the bridge between Rust WebAssembly and JavaScript. It generates the glue code that lets JS call Rust functions, pass complex types across the boundary, and use JavaScript APIs from Rust. That bridge is a rich source of bugs: type mismatches, ownership errors at the boundary, and behavior differences between the Wasm sandbox and native environments.
This guide focuses specifically on testing the interop layer: the #[wasm_bindgen] boundary, the generated TypeScript types, and the execution of those tests in headless browsers.
Why Interop Testing Is Different
When you expose a Rust function to JavaScript via wasm-bindgen, several things happen:
- The Rust function is compiled to Wasm
wasm-bindgengenerates JavaScript/TypeScript glue code- Complex types (strings,
Vec, custom structs) are serialized across the boundary - Ownership and memory management follows Wasm rules, not JS garbage collection
Each of these steps can introduce bugs that aren't visible in native Rust tests. Interop tests must run in a real Wasm/JS environment.
The wasm-bindgen-test Macro
The #[wasm_bindgen_test] attribute marks tests that should run in Wasm. These compile to Wasm and execute inside a JS runtime:
// src/lib.rs
use wasm_bindgen::prelude::*;
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
/// A struct exposed to JavaScript
#[wasm_bindgen]
pub struct DataProcessor {
data: Vec<f64>,
label: String,
}
#[wasm_bindgen]
impl DataProcessor {
#[wasm_bindgen(constructor)]
pub fn new(label: &str) -> DataProcessor {
DataProcessor {
data: Vec::new(),
label: label.to_string(),
}
}
pub fn add_value(&mut self, value: f64) {
self.data.push(value);
}
pub fn mean(&self) -> Option<f64> {
if self.data.is_empty() {
None
} else {
Some(self.data.iter().sum::<f64>() / self.data.len() as f64)
}
}
pub fn to_json(&self) -> String {
format!(
r#"{{"label":"{}","count":{},"mean":{}}}"#,
self.label,
self.data.len(),
self.mean().unwrap_or(0.0)
)
}
#[wasm_bindgen(getter)]
pub fn label(&self) -> String {
self.label.clone()
}
}
/// Exported free function
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
/// Function that accepts a JS array
#[wasm_bindgen]
pub fn sum_array(values: &js_sys::Float64Array) -> f64 {
let vec: Vec<f64> = values.to_vec();
vec.iter().sum()
}Testing Struct Construction and Methods
#[cfg(test)]
mod wasm_tests {
use super::*;
use wasm_bindgen_test::*;
#[wasm_bindgen_test]
fn test_data_processor_construction() {
let processor = DataProcessor::new("test-processor");
assert_eq!(processor.label(), "test-processor");
}
#[wasm_bindgen_test]
fn test_add_values_and_compute_mean() {
let mut processor = DataProcessor::new("stats");
processor.add_value(10.0);
processor.add_value(20.0);
processor.add_value(30.0);
let mean = processor.mean().expect("Mean should exist for non-empty data");
assert!((mean - 20.0).abs() < 1e-10, "Mean should be 20.0, got {}", mean);
}
#[wasm_bindgen_test]
fn test_empty_processor_returns_none_mean() {
let processor = DataProcessor::new("empty");
assert!(processor.mean().is_none(), "Empty processor should return None");
}
#[wasm_bindgen_test]
fn test_to_json_output_format() {
let mut processor = DataProcessor::new("my-dataset");
processor.add_value(5.0);
processor.add_value(15.0);
let json = processor.to_json();
// Verify JSON structure
assert!(json.contains(r#""label":"my-dataset""#));
assert!(json.contains(r#""count":2"#));
assert!(json.contains(r#""mean":10"#));
}
#[wasm_bindgen_test]
fn test_greet_returns_formatted_string() {
let result = greet("World");
assert_eq!(result, "Hello, World!");
}
#[wasm_bindgen_test]
fn test_greet_with_empty_string() {
let result = greet("");
assert_eq!(result, "Hello, !");
}
#[wasm_bindgen_test]
fn test_greet_with_unicode() {
let result = greet("Рust");
assert_eq!(result, "Hello, Рust!");
}
}Testing JS Array Interop
Passing JS typed arrays across the boundary requires careful testing:
#[cfg(test)]
mod array_tests {
use super::*;
use wasm_bindgen_test::*;
use js_sys::Float64Array;
#[wasm_bindgen_test]
fn test_sum_array_from_js() {
let arr = Float64Array::new_with_length(4);
arr.set_index(0, 1.0);
arr.set_index(1, 2.0);
arr.set_index(2, 3.0);
arr.set_index(3, 4.0);
let result = sum_array(&arr);
assert!((result - 10.0).abs() < 1e-10);
}
#[wasm_bindgen_test]
fn test_sum_empty_array() {
let arr = Float64Array::new_with_length(0);
let result = sum_array(&arr);
assert_eq!(result, 0.0);
}
#[wasm_bindgen_test]
fn test_sum_array_with_negative_values() {
let arr = Float64Array::new_with_length(3);
arr.set_index(0, -5.0);
arr.set_index(1, 10.0);
arr.set_index(2, -3.0);
let result = sum_array(&arr);
assert!((result - 2.0).abs() < 1e-10);
}
}Testing Promises and Async Interop
Async Rust functions compile to JS Promises in Wasm:
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::Request;
#[wasm_bindgen]
pub async fn fetch_data(url: &str) -> Result<JsValue, JsValue> {
let window = web_sys::window().unwrap();
let response = JsFuture::from(window.fetch_with_str(url)).await?;
let response: web_sys::Response = response.dyn_into()?;
let json = JsFuture::from(response.json()?).await?;
Ok(json)
}#[cfg(test)]
mod async_tests {
use super::*;
use wasm_bindgen_test::*;
use wasm_bindgen_futures::JsFuture;
#[wasm_bindgen_test]
async fn test_fetch_returns_json() {
// Use a real URL in browser tests, or a mock server
// For CI, use a local mock server accessible from the test browser
let result = fetch_data("https://httpbin.org/json").await;
assert!(result.is_ok(), "fetch_data should succeed for valid URL");
}
#[wasm_bindgen_test]
async fn test_fetch_handles_invalid_url() {
let result = fetch_data("not-a-url").await;
assert!(result.is_err(), "fetch_data should fail for invalid URL");
}
}Testing TypeScript Type Generation
wasm-bindgen generates TypeScript .d.ts files. Test that the generated types match your expectations:
# Build and generate types
wasm-pack build --target web
<span class="hljs-comment"># Check generated types exist
<span class="hljs-built_in">ls pkg/my_wasm_lib.d.tsWrite a type assertion test in TypeScript to validate the generated API surface:
// tests/type-check.ts
// This file is only for TypeScript type checking — ts-node or tsc --noEmit
import init, { DataProcessor, greet, sum_array } from "../pkg/my_wasm_lib";
async function typeCheck() {
await init();
// DataProcessor should be constructible with a string
const processor: DataProcessor = new DataProcessor("test");
// Methods should have correct signatures
processor.add_value(1.5);
const mean: number | undefined = processor.mean();
const label: string = processor.label;
const json: string = processor.to_json();
// Free functions
const greeting: string = greet("World");
const sum: number = sum_array(new Float64Array([1, 2, 3]));
console.log("Type check passed:", { mean, label, json, greeting, sum });
}
typeCheck().catch(console.error);// tsconfig.json (for type checking)
{
"compilerOptions": {
"strict": true,
"target": "ES2020",
"moduleResolution": "node",
"esModuleInterop": true,
"noEmit": true
},
"include": ["tests/*.ts", "pkg/*.d.ts"]
}Add to CI:
npm install typescript ts-node --save-dev
npx tsc --noEmit # Type check without emitting filesTesting Error Handling Across the Boundary
Errors thrown in Rust Wasm propagate as JS exceptions. Test that they're catchable:
#[wasm_bindgen]
pub fn parse_positive_int(s: &str) -> Result<u32, JsValue> {
let n: i64 = s.trim().parse()
.map_err(|e| JsValue::from_str(&format!("Parse error: {}", e)))?;
if n < 0 {
return Err(JsValue::from_str("Value must be positive"));
}
Ok(n as u32)
}#[wasm_bindgen_test]
fn test_parse_positive_int_valid() {
let result = parse_positive_int("42").expect("Should parse valid input");
assert_eq!(result, 42u32);
}
#[wasm_bindgen_test]
fn test_parse_positive_int_invalid_string() {
let result = parse_positive_int("abc");
assert!(result.is_err());
let err = result.unwrap_err();
let err_str = err.as_string().unwrap_or_default();
assert!(err_str.contains("Parse error"));
}
#[wasm_bindgen_test]
fn test_parse_positive_int_negative_value() {
let result = parse_positive_int("-5");
assert!(result.is_err());
let err_str = result.unwrap_err().as_string().unwrap_or_default();
assert!(err_str.contains("positive"));
}Running Tests in Multiple Browsers
# Headless Chrome
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"># Both (for CI)
wasm-pack <span class="hljs-built_in">test --headless --chrome --firefox# .github/workflows/wasm-bindgen-tests.yml
name: wasm-bindgen Tests
on: [push, pull_request]
jobs:
interop-tests:
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 and Firefox
run: |
sudo apt-get update
sudo apt-get install -y google-chrome-stable firefox
- name: Run interop tests in Chrome
run: wasm-pack test --headless --chrome
- name: Run interop tests in Firefox
run: wasm-pack test --headless --firefox
- name: Build and check TypeScript types
run: |
wasm-pack build --target web
npm install
npx tsc --noEmitMonitoring Wasm in Production
Wasm modules deployed to web applications can regress due to browser updates or bundler changes. HelpMeTest provides scheduled browser tests that load your Wasm module in real browser environments and validate its API — giving you early warning when a browser update breaks your wasm-bindgen interop.
Conclusion
wasm-bindgen interop testing requires running in a real JS environment. The #[wasm_bindgen_test] macro and wasm-pack test make this practical. Test struct construction and method behavior, typed array passing, async Promise behavior, error propagation, and the generated TypeScript types. Run in multiple browsers in CI — Chrome and Firefox have different WebAssembly implementations and catch different bugs. The TypeScript type check catches API surface regressions that unit tests miss.