End-to-End Testing Browser WASM Apps with Playwright and Jest
WebAssembly modules in browsers are instantiated via JavaScript and expose their functionality through JS exports. End-to-end and integration tests for WASM web apps use the same tools as regular web tests — Playwright for browser automation and Jest for unit-level JS/WASM integration — but with a few WASM-specific techniques: waiting for async module initialization, testing through the JS wrapper API, inspecting linear memory, and handling WASM traps as test failures.
Key Takeaways
Always wait for WASM initialization before testing. WebAssembly.instantiate is async. Your WASM module exposes a ready promise or an initialization function — wait for it before calling any exports.
Test through the JS API, not raw WASM exports. Most WASM apps expose a JS wrapper module that handles memory management and type conversion. Test that wrapper — it's what users interact with.
Memory inspection is a superpower. In tests, you can read the raw bytes of WASM linear memory to verify invariants that the JS API doesn't expose — buffer contents, internal state, memory layout.
WASM traps bubble up as JavaScript errors. An out-of-bounds memory access or integer overflow trap becomes a JS RuntimeError. Write tests that expect these errors for invalid inputs.
Playwright's evaluate() runs arbitrary JS in the browser context. Use it to call WASM exports, inspect memory, and return results to your test — this is the bridge between Playwright automation and WASM internals.
The Browser WASM Testing Stack
Browser WASM apps typically have this structure:
User → HTML/CSS UI
↓
JavaScript (event handlers, DOM manipulation)
↓
JS Wrapper Module (type conversion, memory management)
↓
WASM Module (compiled Rust/C/Go business logic)
↓
WASM Linear MemoryTesting happens at multiple levels:
- Playwright tests the full stack from UI to WASM output
- Jest tests the JS wrapper integration without a full browser (using jsdom or a headless browser)
- Raw WASM tests (wasm-pack, Wasmtime) test the WASM module in isolation
This guide focuses on Playwright and Jest — the layers closest to real user interactions.
Setting Up the Test Environment
npm init -y
npm install --save-dev \
playwright \
@playwright/test \
jest \
jest-environment-jsdom \
@jest/globals
npx playwright install chromiumA typical WASM web app has this layout:
my-wasm-app/
├── public/
│ ├── index.html
│ ├── calculator.wasm # compiled WASM module
│ └── calculator.js # JS wrapper (generated or hand-written)
├── src/
│ └── lib.rs # Rust source
├── tests/
│ ├── playwright/
│ │ └── calculator.spec.ts
│ └── jest/
│ └── calculator-wasm.test.js
└── package.jsonThe JS wrapper module:
// public/calculator.js
let instance = null;
let memory = null;
const wasmImports = {
env: {
// Host functions the WASM module can call back
log_error: (ptr, len) => {
const bytes = new Uint8Array(memory.buffer, ptr, len);
const msg = new TextDecoder().decode(bytes);
console.error('[WASM Error]', msg);
},
},
};
export async function initCalculator(wasmUrl = '/calculator.wasm') {
const response = await fetch(wasmUrl);
const { instance: inst } = await WebAssembly.instantiateStreaming(
response,
wasmImports
);
instance = inst;
memory = inst.exports.memory;
return calculator;
}
function writeString(str) {
const encoder = new TextEncoder();
const bytes = encoder.encode(str);
const ptr = instance.exports.alloc(bytes.length);
new Uint8Array(memory.buffer, ptr, bytes.length).set(bytes);
return { ptr, len: bytes.length };
}
function readString(ptr, len) {
const bytes = new Uint8Array(memory.buffer, ptr, len);
return new TextDecoder().decode(bytes);
}
export const calculator = {
add: (a, b) => instance.exports.add(a, b),
subtract: (a, b) => instance.exports.subtract(a, b),
multiply: (a, b) => instance.exports.multiply(a, b),
divide: (a, b) => {
if (b === 0) throw new Error('Division by zero');
return instance.exports.divide(a, b);
},
formatResult: (value, decimals) => {
const { ptr, len } = writeString(value.toString());
const resultPtr = instance.exports.format_number(ptr, len, decimals);
const resultLen = instance.exports.last_string_len();
return readString(resultPtr, resultLen);
},
memorySize: () => memory.buffer.byteLength,
rawMemory: () => memory,
};Playwright Integration Tests
// tests/playwright/calculator.spec.ts
import { test, expect, Page } from '@playwright/test';
// Helper to initialize WASM in the browser context and get a handle
async function getCalculator(page: Page) {
return page.evaluate(async () => {
// @ts-ignore - calculator module is loaded in the page
const mod = await import('/calculator.js');
const calc = await mod.initCalculator('/calculator.wasm');
// Store on window for subsequent evaluate() calls
(window as any).__calc = calc;
return true;
});
}
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:3000');
// Wait for the WASM module to finish loading
await page.waitForFunction(() => (window as any).__wasmReady === true, {
timeout: 5000,
});
});
test('add two numbers via UI', async ({ page }) => {
// Fill inputs and trigger calculation through the UI
await page.fill('[data-testid="input-a"]', '15');
await page.fill('[data-testid="input-b"]', '27');
await page.click('[data-testid="btn-add"]');
// Assert the result appears correctly
const result = await page.textContent('[data-testid="result"]');
expect(result).toBe('42');
});
test('divide shows decimal result', async ({ page }) => {
await page.fill('[data-testid="input-a"]', '10');
await page.fill('[data-testid="input-b"]', '4');
await page.click('[data-testid="btn-divide"]');
const result = await page.textContent('[data-testid="result"]');
expect(result).toBe('2.5');
});
test('divide by zero shows error message', async ({ page }) => {
await page.fill('[data-testid="input-a"]', '5');
await page.fill('[data-testid="input-b"]', '0');
await page.click('[data-testid="btn-divide"]');
// The UI should show an error, not crash
const errorMsg = await page.textContent('[data-testid="error"]');
expect(errorMsg).toContain('zero');
// The result area should not show garbage
const result = await page.textContent('[data-testid="result"]');
expect(result).toBe('');
});
test('call WASM exports directly via evaluate', async ({ page }) => {
// Test WASM functions without going through the UI
const addResult = await page.evaluate(() => {
return (window as any).__calc.add(100, 200);
});
expect(addResult).toBe(300);
});
test('WASM memory remains stable after many operations', async ({ page }) => {
const initialMemory = await page.evaluate(() => {
return (window as any).__calc.memorySize();
});
// Run 10,000 operations
await page.evaluate(async () => {
const calc = (window as any).__calc;
for (let i = 0; i < 10_000; i++) {
calc.add(i, i + 1);
}
});
const finalMemory = await page.evaluate(() => {
return (window as any).__calc.memorySize();
});
// Memory should not grow unboundedly from simple arithmetic
// Allow up to 1MB growth (1 WASM page = 64KB)
const growth = finalMemory - initialMemory;
expect(growth).toBeLessThan(1024 * 1024);
});
test('inspect WASM linear memory directly', async ({ page }) => {
// Write a string into WASM memory and verify the bytes are correct
const result = await page.evaluate(() => {
const calc = (window as any).__calc;
const mem = calc.rawMemory();
// Read the first 16 bytes of memory (known layout from our module)
const view = new Uint8Array(mem.buffer, 0, 16);
return Array.from(view);
});
// The first few bytes of WASM linear memory in our module are zeros (reserved)
expect(result[0]).toBe(0);
expect(result.length).toBe(16);
});
test('WASM module handles concurrent calls', async ({ page }) => {
const results = await page.evaluate(async () => {
const calc = (window as any).__calc;
// Fire multiple async operations simultaneously
const promises = Array.from({ length: 100 }, (_, i) =>
Promise.resolve(calc.add(i, i * 2))
);
return Promise.all(promises);
});
// Verify all 100 results are correct
expect(results).toHaveLength(100);
results.forEach((result: number, i: number) => {
expect(result).toBe(i + i * 2);
});
});Jest Unit Tests for WASM/JS Integration
Jest lets you test the JS wrapper in isolation using a Node.js WASI environment:
// tests/jest/calculator-wasm.test.js
const { JSDOM } = require('jsdom');
const fs = require('fs');
const path = require('path');
// Set up jsdom for fetch and DOM
const dom = new JSDOM('<!DOCTYPE html>', {
pretendToBeVisual: true,
resources: 'usable',
});
global.window = dom.window;
global.fetch = async (url) => {
const filePath = path.join(__dirname, '../../public', url.replace(/^\//, ''));
const buffer = fs.readFileSync(filePath);
return {
ok: true,
arrayBuffer: async () => buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
};
};
global.WebAssembly = require('webassembly'); // requires 'webassembly' npm package
// Load the JS wrapper
let calculator;
beforeAll(async () => {
// Polyfill WebAssembly.instantiateStreaming for Node.js
if (!WebAssembly.instantiateStreaming) {
WebAssembly.instantiateStreaming = async (fetchResult, imports) => {
const buffer = await fetchResult.arrayBuffer();
return WebAssembly.instantiate(buffer, imports);
};
}
const { initCalculator } = require('../../public/calculator.js');
calculator = await initCalculator('./public/calculator.wasm');
}, 10_000);
describe('Calculator WASM exports', () => {
test('add returns correct sum', () => {
expect(calculator.add(2, 3)).toBe(5);
expect(calculator.add(-5, 3)).toBe(-2);
expect(calculator.add(0, 0)).toBe(0);
});
test('subtract returns correct difference', () => {
expect(calculator.subtract(10, 4)).toBe(6);
expect(calculator.subtract(0, 5)).toBe(-5);
});
test('multiply returns correct product', () => {
expect(calculator.multiply(6, 7)).toBe(42);
expect(calculator.multiply(-3, 4)).toBe(-12);
});
test('divide returns correct quotient', () => {
expect(calculator.divide(10, 4)).toBe(2.5);
expect(calculator.divide(1, 3)).toBeCloseTo(0.333, 3);
});
test('divide throws for zero divisor', () => {
expect(() => calculator.divide(5, 0)).toThrow('Division by zero');
});
});
describe('WASM memory behavior', () => {
test('memory is accessible after many calls', () => {
for (let i = 0; i < 1000; i++) {
calculator.add(i, i);
}
// Memory object should still be valid
expect(calculator.memorySize()).toBeGreaterThan(0);
});
test('raw memory buffer has expected size', () => {
const mem = calculator.rawMemory();
// WASM modules start with at least 1 page (64KB)
expect(mem.buffer.byteLength).toBeGreaterThanOrEqual(65536);
});
});
describe('WASM error handling', () => {
test('WASM trap from bad pointer becomes JS error', () => {
// Calling with a pointer that's out of bounds should trap
// The WASM module should handle this gracefully via JS
// This depends on your module's error handling strategy
expect(() => {
// An intentionally bad call — test that errors are catchable
calculator.formatResult(NaN, 2);
}).toThrow();
});
});Playwright Configuration for WASM Testing
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/playwright',
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
webServer: {
command: 'npx serve public -p 3000',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 10_000,
},
use: {
baseURL: 'http://localhost:3000',
// Allow reading large WASM files
launchOptions: {
args: ['--enable-features=SharedArrayBuffer'],
},
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
});CI Pipeline
# .github/workflows/wasm-browser-tests.yml
name: Browser WASM Tests
on: [push, pull_request]
jobs:
build-wasm:
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: Build WASM module
run: wasm-pack build --target web --release
- name: Upload WASM artifact
uses: actions/upload-artifact@v4
with:
name: wasm-module
path: pkg/
jest-tests:
needs: build-wasm
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Download WASM artifact
uses: actions/download-artifact@v4
with:
name: wasm-module
path: public/
- run: npm ci
- run: npm test -- --testPathPattern=jest
playwright-tests:
needs: build-wasm
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Download WASM artifact
uses: actions/download-artifact@v4
with:
name: wasm-module
path: public/
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Run Playwright tests
run: npx playwright test
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/End-to-End Testing with HelpMeTest
Playwright tests give you fine-grained control over browser WASM testing — you can inspect memory, call exports directly, and simulate edge cases programmatically. But writing and maintaining Playwright tests takes engineering time: selectors break when the UI changes, async timing needs careful handling, and test infrastructure requires setup and maintenance.
HelpMeTest complements your Playwright suite with zero-code scenario testing. Describe what a user does — "open the image editor, apply the WASM filter, download the result, verify the file is non-empty" — and HelpMeTest handles the browser automation, retry logic, and failure reporting automatically. No test code to maintain when your UI changes.
Use Playwright for WASM-specific technical tests (memory inspection, export validation, error handling). Use HelpMeTest for user-journey tests that need to stay green as your app evolves. Together they give you complete coverage of your browser WebAssembly application — from raw memory bytes to real user workflows.