wasm-bindgen Testing: JS/Wasm Interop and Headless Browser Execution

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:

  1. The Rust function is compiled to Wasm
  2. wasm-bindgen generates JavaScript/TypeScript glue code
  3. Complex types (strings, Vec, custom structs) are serialized across the boundary
  4. 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.ts

Write 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 files

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

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

Read more