Rust + WebAssembly Testing End-to-End
Rust is the dominant language for production WebAssembly. The Rust/Wasm toolchain — wasm-pack, wasm-bindgen, and web-sys — is mature and well-documented. But testing Rust Wasm projects requires thinking at multiple levels simultaneously: Rust logic, Wasm compilation, JavaScript integration, and browser behavior.
This guide covers the complete testing lifecycle for a Rust WebAssembly project, from cargo test to production monitoring.
The Testing Pyramid for Rust/Wasm
[Playwright E2E Tests]
browser behavior, full app
[wasm-pack test --headless]
runs Rust tests in browser, DOM access
[wasm-pack test --node]
runs compiled Wasm in Node.js, fast
[cargo test]
pure Rust logic, no Wasm compilation, fastestTest at the lowest level that catches the bug. Most logic errors are caught by cargo test. Browser-specific issues need --headless --chrome. User flow issues need Playwright.
Project Structure
my-wasm-project/
├── src/
│ ├── lib.rs # Library code + #[wasm_bindgen] exports
│ ├── math.rs # Pure Rust modules (no Wasm-specific code)
│ └── tests.rs # wasm-bindgen-test tests
├── tests/
│ └── integration.rs # Rust integration tests
├── www/
│ ├── index.html
│ └── index.js # JavaScript host code
├── test/
│ └── playwright/ # E2E tests
└── Cargo.tomlLayer 1: Pure Rust Unit Tests
Separate your pure logic from Wasm-specific code. Pure functions are testable with standard cargo test — no compilation to Wasm required:
// src/math.rs
pub fn fibonacci(n: u64) -> u64 {
match n {
0 => 0,
1 => 1,
_ => {
let mut a = 0u64;
let mut b = 1u64;
for _ in 2..=n {
let temp = a + b;
a = b;
b = temp;
}
b
}
}
}
pub fn is_prime(n: u64) -> bool {
if n < 2 { return false; }
if n == 2 { return true; }
if n % 2 == 0 { return false; }
let sqrt = (n as f64).sqrt() as u64;
!(3..=sqrt).step_by(2).any(|i| n % i == 0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fibonacci() {
assert_eq!(fibonacci(0), 0);
assert_eq!(fibonacci(1), 1);
assert_eq!(fibonacci(10), 55);
assert_eq!(fibonacci(50), 12586269025);
}
#[test]
fn test_is_prime() {
assert!(!is_prime(0));
assert!(!is_prime(1));
assert!(is_prime(2));
assert!(is_prime(97));
assert!(!is_prime(100));
}
#[test]
fn test_fibonacci_no_overflow_for_u64() {
// Fibonacci(93) is the largest value that fits in u64
assert_eq!(fibonacci(93), 12200160415121876738);
}
}Run with:
cargo test
# or just the math module
cargo test mathThis is instant — no Wasm compilation. Iterate fast here.
Layer 2: Wasm-Bindgen Integration Tests
Once pure logic is tested, test the Wasm boundary — the #[wasm_bindgen] exports that JavaScript calls:
// src/lib.rs
use wasm_bindgen::prelude::*;
use crate::math;
#[wasm_bindgen]
pub fn fibonacci(n: u32) -> u32 {
// Note: u32 for JS compatibility (JS numbers are f64)
math::fibonacci(n as u64) as u32
}
#[wasm_bindgen]
pub struct PrimeChecker {
cache: Vec<u64>,
}
#[wasm_bindgen]
impl PrimeChecker {
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
PrimeChecker { cache: Vec::new() }
}
pub fn check(&mut self, n: u64) -> bool {
if self.cache.contains(&n) {
return true; // cached prime
}
let result = math::is_prime(n);
if result {
self.cache.push(n);
}
result
}
pub fn cache_size(&self) -> usize {
self.cache.len()
}
}// src/tests.rs
use wasm_bindgen_test::*;
use crate::{fibonacci, PrimeChecker};
wasm_bindgen_test_configure!(run_in_browser);
#[wasm_bindgen_test]
fn test_fibonacci_export() {
assert_eq!(fibonacci(10), 55);
assert_eq!(fibonacci(0), 0);
}
#[wasm_bindgen_test]
fn test_prime_checker_struct() {
let mut checker = PrimeChecker::new();
assert!(checker.check(97));
assert_eq!(checker.cache_size(), 1);
assert!(!checker.check(100));
assert_eq!(checker.cache_size(), 1); // Not prime, not cached
assert!(checker.check(97)); // Should hit cache
assert_eq!(checker.cache_size(), 1); // Still 1, not double-added
}
#[wasm_bindgen_test]
fn test_prime_checker_multiple_primes() {
let mut checker = PrimeChecker::new();
let primes = [2, 3, 5, 7, 11, 13];
for &p in &primes {
assert!(checker.check(p), "{p} should be prime");
}
assert_eq!(checker.cache_size(), primes.len());
}Run in Node.js (fast, no browser needed):
wasm-pack test --nodeRun in real Chrome (catches browser-specific behavior):
wasm-pack test --headless --chromeLayer 3: JavaScript Integration Tests
Test the JavaScript side — does your glue code correctly initialize, call, and clean up Wasm?
// test/wasm-init.test.js
import { describe, it, expect, beforeAll } from 'vitest';
let wasm;
beforeAll(async () => {
// Dynamic import to handle async Wasm initialization
wasm = await import('../pkg/my_module.js');
await wasm.default();
});
describe('Wasm module initialization', () => {
it('exports are accessible after init', () => {
expect(typeof wasm.fibonacci).toBe('function');
expect(typeof wasm.PrimeChecker).toBe('function');
});
it('fibonacci returns correct result', () => {
expect(wasm.fibonacci(10)).toBe(55);
});
});
describe('PrimeChecker class from JS', () => {
it('can be constructed and used', () => {
const checker = new wasm.PrimeChecker();
expect(checker.check(BigInt(97))).toBe(true);
expect(checker.cache_size()).toBe(1);
// Clean up Rust memory (important for long-running tests)
checker.free();
});
it('handles non-prime numbers', () => {
const checker = new wasm.PrimeChecker();
expect(checker.check(BigInt(100))).toBe(false);
expect(checker.cache_size()).toBe(0);
checker.free();
});
});Note the .free() calls — Rust structs exported to JavaScript have manually managed memory. Call .free() when done to avoid Wasm memory leaks in long-running tests.
Layer 4: Browser E2E Tests with Playwright
For the full end-to-end test — load a page that uses your Wasm module, exercise it through the UI:
// test/playwright/wasm-app.spec.ts
import { test, expect } from '@playwright/test';
test.describe('WebAssembly fibonacci calculator', () => {
test('calculates fibonacci correctly', async ({ page }) => {
await page.goto('/');
// Wait for Wasm to initialize
await page.waitForFunction(() => window.wasmReady === true);
await page.fill('[data-testid="fibonacci-input"]', '10');
await page.click('[data-testid="calculate-btn"]');
await expect(page.locator('[data-testid="result"]')).toHaveText('55');
});
test('handles large numbers without crashing', async ({ page }) => {
await page.goto('/');
await page.waitForFunction(() => window.wasmReady === true);
await page.fill('[data-testid="fibonacci-input"]', '40');
await page.click('[data-testid="calculate-btn"]');
await expect(page.locator('[data-testid="result"]')).toHaveText('102334155');
});
test('shows error for invalid input', async ({ page }) => {
await page.goto('/');
await page.waitForFunction(() => window.wasmReady === true);
await page.fill('[data-testid="fibonacci-input"]', '-1');
await page.click('[data-testid="calculate-btn"]');
await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
});
test('Wasm module loads without console errors', async ({ page }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
await page.goto('/');
await page.waitForFunction(() => window.wasmReady === true);
expect(errors).toHaveLength(0);
});
});The waitForFunction(() => window.wasmReady === true) pattern requires your app to set window.wasmReady = true after Wasm initializes. This is more reliable than arbitrary timeouts:
// www/index.js
import init, { fibonacci } from '../pkg/my_module.js';
async function run() {
await init();
window.wasmReady = true;
document.getElementById('calculate-btn').addEventListener('click', () => {
const n = parseInt(document.getElementById('fibonacci-input').value);
const result = fibonacci(n);
document.getElementById('result').textContent = result.toString();
});
}
run();Testing Wasm in Different Browsers
Wasm support varies across browsers (especially older versions). Test in multiple browsers:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 7'] },
},
],
});Complete CI Pipeline
name: Rust/Wasm Tests
on: [push, pull_request]
jobs:
rust-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Run Rust unit tests
run: cargo test
wasm-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: Test in Node.js
run: wasm-pack test --node
- name: Test in headless Chrome
run: wasm-pack test --headless --chrome
- name: Build for web
run: wasm-pack build --target web
- name: Run JS integration tests
run: npx vitest run test/wasm-init.test.js
e2e-tests:
runs-on: ubuntu-latest
needs: [wasm-tests] # Build must succeed first
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: Build Wasm
run: wasm-pack build --target web
- name: Install npm deps
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Start dev server
run: npm run serve &
- name: Wait for server
run: npx wait-on http://localhost:3000
- name: Run Playwright tests
run: npx playwright test
- name: Upload test results
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/Common Pitfalls
Memory leaks in tests — Rust structs exported to JS must be freed with .free(). In long test suites, leaking memory causes tests to slow down or fail.
BigInt vs Number — Rust u64/i64 values become JavaScript BigInt when exported. This breaks if your test expects a regular number: expect(checker.check(97)) fails because 97 is a Number, but wasm-bindgen expects BigInt(97).
Wasm initialization is async — tests that call Wasm functions before await init() complete will fail with "unreachable" or undefined function errors. Always await initialization.
Node.js vs browser APIs — wasm-pack test --node doesn't have window, document, or fetch. Tests that use web APIs must use --headless --chrome or --headless --firefox.
Summary
Testing Rust WebAssembly end-to-end requires four layers:
cargo test— Rust logic, instant feedbackwasm-pack test --node— Wasm compilation + JS glue, fastwasm-pack test --headless --chrome— browser-specific behavior- Playwright E2E — user-facing functionality
Most bugs are caught by cargo test. Browser-specific issues require the headless tests. Ship all four layers in CI and you'll have high confidence in your Wasm deployments.