Testing WASM Modules with Wasmtime: Unit Tests, WASI, and Sandboxing

Testing WASM Modules with Wasmtime: Unit Tests, WASI, and Sandboxing

Wasmtime is a fast, standards-compliant WebAssembly runtime from the Bytecode Alliance. Its Rust crate lets you load, instantiate, and call WASM modules from a Rust host — making it ideal for host-side unit tests that verify WASM module behavior, test WASI system calls in a controlled sandbox, and validate component model interfaces before integration. This guide covers testing with Wasmtime end-to-end.

Key Takeaways

Wasmtime tests run on your native host, not inside a browser. This makes them fast, debuggable with standard Rust tooling, and easy to integrate into cargo test.

Store<T> holds all WASM state. Every host-side test creates a fresh Store to get a clean WASM memory and function table. Never share stores between unrelated tests.

WASI gives your WASM module a sandboxed filesystem, clock, and environment. WasiCtxBuilder controls exactly what the module can access — test with minimal permissions to catch over-privileged code.

Linker wires imports. If your WASM module imports host functions, add them to the Linker before instantiation. This is where you inject test doubles for external dependencies.

The component model separates interface from implementation. Use wit-bindgen to generate typed Rust bindings from WIT interfaces, then test each component in isolation.

What Wasmtime Testing Solves

When you build a WebAssembly module — whether it's a plugin system, a sandboxed computation engine, or a portable business-logic library — you need tests that verify its behavior from the host's perspective:

  • Does the exported function return the right value for these inputs?
  • Does the WASM module stay within its WASI resource limits?
  • What happens when the module tries to access a file path it shouldn't?
  • Does the module handle malformed inputs without corrupting host memory?

wasm-pack test and browser testing answer questions about Rust-to-JavaScript interop. Wasmtime answers questions about your module as a deployable unit, testable from any host language that has a Wasmtime binding.

Project Setup

Add Wasmtime to your test dependencies:

# Cargo.toml
[package]
name = "wasm-host-tests"
version = "0.1.0"
edition = "2021"

[dev-dependencies]
wasmtime = "18"
wasmtime-wasi = "18"
anyhow = "1"
wat = "1"   # parse WebAssembly Text Format in tests

[build-dependencies]
# Optional: build the WASM module as part of cargo build

Basic Module Loading and Function Calls

Start with the simplest possible test — load a WASM module and call an exported function:

// tests/basic_tests.rs
use wasmtime::{Engine, Module, Store, Instance, TypedFunc};
use anyhow::Result;

// Inline WAT (WebAssembly Text Format) for self-contained tests
const ADD_WAT: &str = r#"
    (module
        (func $add (export "add") (param i32 i32) (result i32)
            local.get 0
            local.get 1
            i32.add
        )
        (func $multiply (export "multiply") (param i32 i32) (result i32)
            local.get 0
            local.get 1
            i32.mul
        )
    )
"#;

#[test]
fn test_add_function() -> Result<()> {
    let engine = Engine::default();
    let module = Module::new(&engine, ADD_WAT)?;
    let mut store = Store::new(&engine, ());
    let instance = Instance::new(&mut store, &module, &[])?;

    let add: TypedFunc<(i32, i32), i32> = instance
        .get_typed_func(&mut store, "add")?;

    assert_eq!(add.call(&mut store, (2, 3))?, 5);
    assert_eq!(add.call(&mut store, (-10, 4))?, -6);
    assert_eq!(add.call(&mut store, (0, 0))?, 0);
    Ok(())
}

#[test]
fn test_multiply_function() -> Result<()> {
    let engine = Engine::default();
    let module = Module::new(&engine, ADD_WAT)?;
    let mut store = Store::new(&engine, ());
    let instance = Instance::new(&mut store, &module, &[])?;

    let multiply: TypedFunc<(i32, i32), i32> = instance
        .get_typed_func(&mut store, "multiply")?;

    assert_eq!(multiply.call(&mut store, (6, 7))?, 42);
    assert_eq!(multiply.call(&mut store, (0, 100))?, 0);
    Ok(())
}

Testing Real Compiled WASM

In practice, you load .wasm files compiled from your Rust source:

// tests/compiled_module_tests.rs
use wasmtime::{Engine, Module, Store, Instance, Linker};
use anyhow::Result;
use std::path::Path;

fn load_test_module(engine: &Engine, name: &str) -> Result<Module> {
    // Convention: WASM test artifacts live in tests/fixtures/
    let wasm_path = Path::new("tests/fixtures").join(format!("{}.wasm", name));
    Module::from_file(engine, &wasm_path)
}

#[test]
fn test_string_processing_module() -> Result<()> {
    let engine = Engine::default();
    // Assumes tests/fixtures/string_processor.wasm exists
    // Build it with: cargo build --target wasm32-unknown-unknown --release
    // cp target/wasm32-unknown-unknown/release/string_processor.wasm tests/fixtures/

    let module = Module::new(&engine, &include_bytes!("fixtures/string_processor.wasm")[..])?;
    let mut store = Store::new(&engine, ());
    let instance = Instance::new(&mut store, &module, &[])?;

    // For string passing, you typically work through WASM linear memory
    let memory = instance.get_memory(&mut store, "memory")
        .expect("module should export 'memory'");

    // Allocate and write input string into WASM memory
    let alloc: wasmtime::TypedFunc<i32, i32> = instance
        .get_typed_func(&mut store, "alloc")?;
    let input = b"hello world";
    let ptr = alloc.call(&mut store, input.len() as i32)?;
    memory.write(&mut store, ptr as usize, input)?;

    // Call string length function
    let str_len: wasmtime::TypedFunc<(i32, i32), i32> = instance
        .get_typed_func(&mut store, "string_length")?;
    let len = str_len.call(&mut store, (ptr, input.len() as i32))?;
    assert_eq!(len, 11);

    Ok(())
}

WASI: Testing Sandboxed System Calls

WASI (WebAssembly System Interface) is the standard API for WASM modules to interact with the outside world — files, environment variables, clocks, random numbers. Wasmtime's wasmtime-wasi crate lets you control every WASI capability in tests:

// tests/wasi_tests.rs
use wasmtime::{Engine, Module, Store, Linker};
use wasmtime_wasi::{WasiCtxBuilder, preview1};
use anyhow::Result;
use std::io::Cursor;

struct HostState {
    wasi: wasmtime_wasi::WasiCtx,
    table: wasmtime::ResourceTable,
}

#[test]
fn test_wasi_module_reads_env_var() -> Result<()> {
    let engine = Engine::default();

    // Build a WASI context with specific environment variables
    let wasi = WasiCtxBuilder::new()
        .env("APP_MODE", "test")
        .env("LOG_LEVEL", "debug")
        .build();

    let mut store = Store::new(&engine, wasi);

    // Load a WASM module compiled with wasm32-wasip1 target
    // This would be a module that reads env vars via WASI
    let module = Module::new(&engine, include_bytes!("fixtures/env_reader.wasm"))?;

    let mut linker: Linker<wasmtime_wasi::WasiCtx> = Linker::new(&engine);
    wasmtime_wasi::preview1::add_to_linker_sync(&mut linker, |s| s)?;

    let instance = linker.instantiate(&mut store, &module)?;
    let run: wasmtime::TypedFunc<(), ()> = instance.get_typed_func(&mut store, "_start")?;

    // The module reads APP_MODE and writes it to stdout
    // We'd capture stdout via InMemoryOutput (see below)
    run.call(&mut store, ())?;
    Ok(())
}

#[test]
fn test_wasi_filesystem_isolation() -> Result<()> {
    let engine = Engine::default();

    // Only expose a specific temp directory — no access to the rest of the filesystem
    let temp_dir = tempfile::tempdir()?;
    let wasi = WasiCtxBuilder::new()
        .preopened_dir(
            wasmtime_wasi::Dir::open_ambient_dir(&temp_dir.path(), wasmtime_wasi::ambient_authority())?,
            "/data",
        )?
        .build();

    let mut store = Store::new(&engine, wasi);
    // The module can only read/write within /data — accessing /etc/passwd will fail
    // This test verifies the sandbox boundary holds

    Ok(())
}

#[test]
fn test_wasi_stdout_capture() -> Result<()> {
    use wasmtime_wasi::pipe::MemoryOutputPipe;

    let engine = Engine::default();
    let stdout = MemoryOutputPipe::new(4096);

    let wasi = WasiCtxBuilder::new()
        .stdout(stdout.clone())
        .build();

    let mut store = Store::new(&engine, wasi);
    let module = Module::new(&engine, include_bytes!("fixtures/hello_wasi.wasm"))?;

    let mut linker: Linker<wasmtime_wasi::WasiCtx> = Linker::new(&engine);
    wasmtime_wasi::preview1::add_to_linker_sync(&mut linker, |s| s)?;

    let instance = linker.instantiate(&mut store, &module)?;
    let start: wasmtime::TypedFunc<(), ()> = instance.get_typed_func(&mut store, "_start")?;
    start.call(&mut store, ())?;

    let output = stdout.contents();
    let output_str = std::str::from_utf8(&output)?;
    assert!(output_str.contains("Hello, WASI!"));
    Ok(())
}

Injecting Host Functions (Mocking Imports)

If your WASM module imports host functions, use Linker to provide test implementations:

// tests/linker_tests.rs
use wasmtime::{Engine, Module, Store, Linker, Caller};
use anyhow::Result;
use std::sync::{Arc, Mutex};

const MODULE_WITH_IMPORTS: &str = r#"
    (module
        (import "env" "log_message" (func $log (param i32 i32)))
        (import "env" "get_timestamp" (func $timestamp (result i64)))
        (memory (export "memory") 1)
        (data (i32.const 0) "test message")

        (func (export "do_work") (result i64)
            ;; Log a message
            i32.const 0    ;; ptr to "test message"
            i32.const 12   ;; length
            call $log
            ;; Return the current timestamp
            call $timestamp
        )
    )
"#;

#[test]
fn test_module_with_mocked_imports() -> Result<()> {
    let engine = Engine::default();
    let module = Module::new(&engine, MODULE_WITH_IMPORTS)?;

    // Collect log calls for assertion
    let log_calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
    let log_calls_clone = log_calls.clone();

    let mut linker: Linker<()> = Linker::new(&engine);

    // Mock the log_message import
    linker.func_wrap("env", "log_message", move |mut caller: Caller<'_, ()>, ptr: i32, len: i32| {
        let memory = caller.get_export("memory")
            .and_then(|e| e.into_memory())
            .unwrap();
        let mut buf = vec![0u8; len as usize];
        memory.read(&caller, ptr as usize, &mut buf).unwrap();
        let msg = String::from_utf8_lossy(&buf).to_string();
        log_calls_clone.lock().unwrap().push(msg);
    })?;

    // Mock the get_timestamp import — return a fixed value for deterministic tests
    linker.func_wrap("env", "get_timestamp", || -> i64 {
        1_700_000_000_000 // fixed timestamp for reproducible tests
    })?;

    let mut store = Store::new(&engine, ());
    let instance = linker.instantiate(&mut store, &module)?;

    let do_work: wasmtime::TypedFunc<(), i64> = instance
        .get_typed_func(&mut store, "do_work")?;

    let timestamp = do_work.call(&mut store, ())?;

    // Assert the mock timestamp was returned
    assert_eq!(timestamp, 1_700_000_000_000);

    // Assert the log was called with the right message
    let calls = log_calls.lock().unwrap();
    assert_eq!(calls.len(), 1);
    assert_eq!(calls[0], "test message");

    Ok(())
}

Store Configuration and Resource Limits

One of Wasmtime's most useful testing features is the ability to configure resource limits — you can test that your WASM module behaves correctly under memory pressure:

use wasmtime::{Engine, Config, Module, Store, StoreLimits, StoreLimitsBuilder};
use anyhow::Result;

#[test]
fn test_module_respects_memory_limit() -> Result<()> {
    let engine = Engine::default();

    // Build a WASM module that tries to allocate 256MB of memory
    let hungry_module = r#"
        (module
            (memory (export "memory") 1 4096)  ;; 1 page initial, max 4096 pages (256MB)
            (func (export "grow_memory") (result i32)
                i32.const 100   ;; try to grow by 100 pages (6.4MB)
                memory.grow
            )
        )
    "#;

    let module = Module::new(&engine, hungry_module)?;

    // Create a store with a 2MB memory limit
    let limits = StoreLimitsBuilder::new()
        .memory_size(2 * 1024 * 1024) // 2MB
        .build();

    let mut store = Store::new(&engine, limits);
    store.limiter(|state| state as &mut dyn wasmtime::ResourceLimiter);

    let instance = Instance::new(&mut store, &module, &[])?;
    let grow: wasmtime::TypedFunc<(), i32> = instance.get_typed_func(&mut store, "grow_memory")?;

    // memory.grow should return -1 (failed) when over the limit
    let result = grow.call(&mut store, ())?;
    assert_eq!(result, -1, "Memory growth should fail when exceeding store limit");
    Ok(())
}

Testing Fuel Consumption

Wasmtime supports "fuel" — a way to limit computation and prevent infinite loops. This is invaluable for testing plugin systems:

use wasmtime::{Engine, Config, Module, Store};
use anyhow::Result;

#[test]
fn test_computation_bounded_by_fuel() -> Result<()> {
    let mut config = Config::new();
    config.consume_fuel(true);
    let engine = Engine::new(&config)?;

    let infinite_loop = r#"
        (module
            (func (export "run_forever")
                loop
                    br 0
                end
            )
        )
    "#;

    let module = Module::new(&engine, infinite_loop)?;
    let mut store = Store::new(&engine, ());
    store.set_fuel(10_000)?; // Only allow 10,000 instructions

    let instance = Instance::new(&mut store, &module, &[])?;
    let run: wasmtime::TypedFunc<(), ()> = instance.get_typed_func(&mut store, "run_forever")?;

    // The call should fail with a fuel exhaustion trap, not hang forever
    let result = run.call(&mut store, ());
    assert!(result.is_err(), "Expected fuel exhaustion error");
    assert!(result.unwrap_err().to_string().contains("fuel"),
        "Error should mention fuel exhaustion");
    Ok(())
}

End-to-End Testing with HelpMeTest

Wasmtime tests give you precise host-side control — you can verify WASM module behavior, WASI sandbox boundaries, and resource limits in milliseconds as part of cargo test. But production WASM deployments live inside applications: browser apps, server-side runtimes, plugin hosts.

HelpMeTest covers the layer that Wasmtime tests can't: the full application experience. You write scenarios in plain English — "upload a file and verify the WASM processor returns the correct output", "trigger the computation and confirm the UI updates" — and HelpMeTest runs them against your deployed app continuously.

Use Wasmtime unit tests to validate every WASM module export and WASI interaction. Use HelpMeTest to confirm that your application correctly wires those modules into a working product that users can actually rely on.

Read more

ScyllaDB Testing Guide: Cassandra Driver Compatibility, Shard-per-Core Testing & Performance Regression

ScyllaDB Testing Guide: Cassandra Driver Compatibility, Shard-per-Core Testing & Performance Regression

ScyllaDB delivers Cassandra-compatible APIs with a rewritten Seastar-based engine that achieves dramatically higher throughput. Testing ScyllaDB applications requires validating both Cassandra compatibility and ScyllaDB-specific behaviors like shard-per-core data distribution. This guide covers both angles. ScyllaDB Testing Landscape ScyllaDB is a drop-in replacement for Cassandra at the API level—which means

By HelpMeTest