Rust + WebAssembly Testing End-to-End

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, fastest

Test 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.toml

Layer 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 math

This 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 --node

Run in real Chrome (catches browser-specific behavior):

wasm-pack test --headless --chrome

Layer 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 APIswasm-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:

  1. cargo test — Rust logic, instant feedback
  2. wasm-pack test --node — Wasm compilation + JS glue, fast
  3. wasm-pack test --headless --chrome — browser-specific behavior
  4. 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.

Read more

Start now free