Testing WebAssembly with AssemblyScript: as-pect, Memory Testing, and JS Interop
WebAssembly gives you near-native performance in the browser and on the server, but testing Wasm modules has historically been awkward. AssemblyScript — a TypeScript-like language that compiles to Wasm — changes that by bringing a familiar type system and a purpose-built test runner called as-pect. This post covers everything you need to test AssemblyScript modules properly: unit tests, memory allocation, JS host imports, testing exports from the JS side, and wiring it all into CI.
Why AssemblyScript for Wasm
Most Wasm tutorials reach for Rust or C++. AssemblyScript is worth considering when:
- Your team already knows TypeScript. The syntax is nearly identical; the main difference is explicit types everywhere and manual memory semantics when you need them.
- You want tight JS interop. AssemblyScript's runtime has mature bindings generators (
@assemblyscript/loader) that handle string encoding, array lifting/lowering, and reference counting across the JS/Wasm boundary. - You want a test runner that understands the Wasm context.
as-pectruns inside the Wasm runtime itself, giving you access to the actual memory layout rather than a mock.
AssemblyScript is not a drop-in replacement for Rust when raw performance or unsafe memory control matters. But for image processing helpers, parsing routines, cryptographic utilities, and computation-heavy business logic that needs to run in both Node.js and the browser, it hits a sweet spot.
Project Setup
Start with a minimal project:
mkdir wasm-math && cd wasm-math
npm init -y
npm install --save-dev assemblyscript
npx asinit .asinit creates assembly/index.ts (your Wasm source), build/ output directory, and a base asconfig.json. Now add as-pect:
npm install --save-dev @as-pect/cli @as-pect/core @as-pect/assembly
npx asp --initThis generates as-pect.config.js and an assembly/__tests__/ directory with an example spec. Your package.json scripts section should look like:
{
"scripts": {
"asbuild": "asc assembly/index.ts --target release",
"test": "asp"
}
}as-pect.config.js controls which files are treated as test suites, what imports are available, and memory configuration:
// as-pect.config.js
module.exports = {
entries: ["assembly/__tests__/**/*.spec.ts"],
include: ["assembly/**/*.ts"],
flags: {
"--runtime": ["stub"], // use stub GC for tests (faster)
"--exportRuntime": [],
},
imports(memory, createImports, instantiateSync, binary) {
// inject host imports here — covered below
return createImports({});
},
};Writing Unit Tests with as-pect
Suppose you have a math utility module:
// assembly/math.ts
export function clamp(value: f64, min: f64, max: f64): f64 {
if (value < min) return min;
if (value > max) return max;
return value;
}
export function lerp(a: f64, b: f64, t: f64): f64 {
return a + (b - a) * t;
}
export function isPrime(n: i32): bool {
if (n < 2) return false;
for (let i: i32 = 2; i * i <= n; i++) {
if (n % i === 0) return false;
}
return true;
}The as-pect spec file uses a Jest-like API, but everything runs inside Wasm:
// assembly/__tests__/math.spec.ts
import { clamp, lerp, isPrime } from "../math";
describe("clamp", () => {
it("returns min when value is below range", () => {
expect<f64>(clamp(-5.0, 0.0, 10.0)).toBe(0.0);
});
it("returns max when value is above range", () => {
expect<f64>(clamp(15.0, 0.0, 10.0)).toBe(10.0);
});
it("returns value when within range", () => {
expect<f64>(clamp(5.0, 0.0, 10.0)).toBe(5.0);
});
it("handles equal min and max", () => {
expect<f64>(clamp(3.0, 5.0, 5.0)).toBe(5.0);
});
});
describe("lerp", () => {
it("returns a at t=0", () => {
expect<f64>(lerp(0.0, 100.0, 0.0)).toBe(0.0);
});
it("returns b at t=1", () => {
expect<f64>(lerp(0.0, 100.0, 1.0)).toBe(100.0);
});
it("interpolates midpoint", () => {
expect<f64>(lerp(0.0, 100.0, 0.5)).toBe(50.0);
});
});
describe("isPrime", () => {
it("identifies known primes", () => {
expect<bool>(isPrime(2)).toBe(true);
expect<bool>(isPrime(7)).toBe(true);
expect<bool>(isPrime(97)).toBe(true);
});
it("rejects composites", () => {
expect<bool>(isPrime(1)).toBe(false);
expect<bool>(isPrime(4)).toBe(false);
expect<bool>(isPrime(100)).toBe(false);
});
});Run the tests:
npx aspas-pect compiles the test suite to Wasm, runs it in a Node.js Wasm runtime, and outputs JUnit-style results. Failed assertions print the expected vs actual values along with the file and line number.
Testing Memory Allocation and Deallocation
This is where AssemblyScript testing gets interesting. Wasm memory is a flat ArrayBuffer — when you allocate strings or arrays, they land at specific offsets. Testing that your code doesn't leak or corrupt memory requires checking the heap state directly.
First, a module that allocates on the heap:
// assembly/buffer.ts
export function createBuffer(size: i32): usize {
return heap.alloc(size);
}
export function freeBuffer(ptr: usize): void {
heap.free(ptr);
}
export function writeAndRead(value: u8): u8 {
const ptr = heap.alloc(1);
store<u8>(ptr, value);
const result = load<u8>(ptr);
heap.free(ptr);
return result;
}Test it with as-pect's memory intrinsics:
// assembly/__tests__/buffer.spec.ts
import { createBuffer, freeBuffer, writeAndRead } from "../buffer";
describe("heap allocation", () => {
it("allocates a non-null pointer", () => {
const ptr = createBuffer(64);
expect<bool>(ptr !== 0).toBe(true);
freeBuffer(ptr);
});
it("round-trips a byte value through memory", () => {
expect<u8>(writeAndRead(42)).toBe(42);
});
it("allocates distinct pointers for separate calls", () => {
const a = createBuffer(16);
const b = createBuffer(16);
expect<bool>(a !== b).toBe(true);
freeBuffer(a);
freeBuffer(b);
});
it("stores correct values at allocated address", () => {
const ptr = createBuffer(4);
store<u32>(ptr, 0xDEADBEEF);
expect<u32>(load<u32>(ptr)).toBe(0xDEADBEEF);
freeBuffer(ptr);
});
});For more complex scenarios involving the managed runtime (classes, arrays, strings), use --runtime full instead of stub in your config and track allocations with __alloc / __retain / __release:
// assembly/__tests__/strings.spec.ts
describe("string memory", () => {
it("string length is correct after allocation", () => {
const s = "hello";
expect<i32>(s.length).toBe(5);
});
it("string concatenation does not corrupt memory", () => {
const a = "foo";
const b = "bar";
const c = a + b;
expect<string>(c).toBe("foobar");
expect<i32>(c.length).toBe(6);
});
});The key discipline: every test that allocates must free. Use afterEach for cleanup when you have shared state:
let sharedBuffer: usize = 0;
beforeEach(() => {
sharedBuffer = heap.alloc(256);
});
afterEach(() => {
heap.free(sharedBuffer);
sharedBuffer = 0;
});Testing JS Host Imports
AssemblyScript modules can import functions from the host JS environment. Testing these imports requires injecting mock implementations into the as-pect runtime.
Declare the import in AssemblyScript:
// assembly/logger.ts
@external("env", "logMessage")
declare function logMessage(ptr: i32, len: i32): void;
export function logHello(): void {
const msg = "hello from wasm";
logMessage(changetype<i32>(msg), msg.length * 2); // UTF-16, 2 bytes per char
}Now provide a mock in as-pect.config.js:
// as-pect.config.js
const captured = [];
module.exports = {
entries: ["assembly/__tests__/**/*.spec.ts"],
imports(memory, createImports, instantiateSync, binary) {
const imports = createImports({
env: {
logMessage(ptr, len) {
// Decode UTF-16 string from Wasm memory
const buf = new Uint16Array(memory.buffer, ptr, len / 2);
captured.push(String.fromCharCode(...buf));
},
},
});
return imports;
},
};The test can then assert against the captured output:
// assembly/__tests__/logger.spec.ts
import { logHello } from "../logger";
// as-pect doesn't share JS closures directly, so test the side-effect
// via a second exported function that reads a flag set by the host
describe("host import logMessage", () => {
it("calls logHello without throwing", () => {
// If the import is missing or throws, this will surface as a Wasm trap
expect<bool>(true).toBe(true);
logHello();
});
});For imports that return values back to Wasm (host functions that Wasm calls and reads the result from), the mock just needs to return the right type:
// assembly/clock.ts
@external("env", "getTimestamp")
declare function getTimestamp(): f64;
export function age(birthYear: f64): f64 {
return getTimestamp() - birthYear;
}// in as-pect.config.js imports()
env: {
getTimestamp() {
return 2026.0; // fixed value for deterministic tests
},
}// assembly/__tests__/clock.spec.ts
import { age } from "../clock";
describe("age", () => {
it("computes age using mocked timestamp", () => {
expect<f64>(age(1990.0)).toBe(36.0);
});
});Testing Wasm Exports from the JS Side
Not everything can be tested inside Wasm. You also need integration-style tests that instantiate the compiled .wasm binary from JS and call exported functions. Use @assemblyscript/loader with Node.js and a standard test runner like Jest or Vitest.
npm install --save-dev @assemblyscript/loader vitestBuild the release binary first:
npx asc assembly/index.ts --target release --exportRuntimeThen write JS-side tests:
// tests/integration.test.ts
import { test, expect, beforeAll } from "vitest";
import loader from "@assemblyscript/loader";
import { readFileSync } from "fs";
interface MathExports {
clamp(value: number, min: number, max: number): number;
lerp(a: number, b: number, t: number): number;
isPrime(n: number): number; // bool is i32 at the boundary
}
let wasm: loader.ASUtil & MathExports;
beforeAll(async () => {
const binary = readFileSync("build/release.wasm");
const instance = await loader.instantiate<MathExports>(binary, {
env: {
abort(msg: number, file: number, line: number, col: number) {
throw new Error(`Wasm abort at ${line}:${col}`);
},
},
});
wasm = instance.exports;
});
test("clamp returns min when below range", () => {
expect(wasm.clamp(-5, 0, 10)).toBe(0);
});
test("lerp midpoint", () => {
expect(wasm.lerp(0, 100, 0.5)).toBeCloseTo(50);
});
test("isPrime returns 1 for prime, 0 for composite", () => {
expect(wasm.isPrime(7)).toBe(1);
expect(wasm.isPrime(4)).toBe(0);
});For string-returning exports, use the loader's string helpers:
test("string export roundtrip", () => {
const ptr = wasm.__newString("test input");
const resultPtr = wasm.processString(ptr);
const result = wasm.__getString(resultPtr);
expect(result).toBe("TEST INPUT");
});These integration tests complement as-pect's unit tests. as-pect catches logic errors inside Wasm fast; the JS-side tests catch ABI mismatches, encoding issues, and boundary type coercions.
CI Integration with GitHub Actions
Wire both test layers into a single workflow:
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run as-pect unit tests
run: npx asp --summary
- name: Build release Wasm
run: npx asc assembly/index.ts --target release --exportRuntime
- name: Run JS integration tests
run: npx vitest run
- name: Upload Wasm artifact
uses: actions/upload-artifact@v4
with:
name: wasm-release
path: build/release.wasmA few CI-specific notes:
Pin the AssemblyScript version. The compiler is still evolving. A minor bump can change codegen in ways that break memory layout assumptions in your tests. Use exact versions in package.json ("assemblyscript": "0.27.x" not "^0.27.0").
Cache the build output. AssemblyScript compilation is fast, but if your integration tests depend on a pre-built .wasm file, failing to build before running JS tests gives confusing errors. The workflow above makes the order explicit.
Fail fast on as-pect, then build. If unit tests fail, there's no point building and running integration tests. GitHub Actions steps fail-fast by default, which is the right behavior here.
Separate lint from test. Add asc --noEmit as a type-check step before testing to catch type errors that as-pect might not surface clearly:
- name: Type check
run: npx asc assembly/index.ts --noEmitKey Takeaways
- as-pect runs inside Wasm. This gives you direct access to memory and lets you test heap behavior that JS-side tests can't reach.
- Use
--runtime stubfor unit tests,--runtime fullfor managed object tests. Stub is faster and eliminates GC noise; full is necessary when testing reference counting, strings, and arrays. - Mock host imports in
as-pect.config.js. Theimports()hook is the right place to inject deterministic mocks for timestamps, logging, and other host functions. - Test the ABI from JS too. as-pect catches logic bugs; Vitest/Jest integration tests catch encoding mismatches and type coercions at the JS/Wasm boundary.
- Pin compiler versions in CI. AssemblyScript is pre-1.0. Floating version ranges will bite you when a minor release changes output binary layout.
- Build the binary before JS integration tests. Make the dependency explicit in your CI workflow — never assume the build artifact is fresh.
AssemblyScript occupies a narrow but useful niche: Wasm for TypeScript teams who want a real test runner. as-pect makes the unit test layer solid. Pairing it with loader-based integration tests gives you the full picture from both sides of the Wasm boundary.