Wasmtime and WASI Testing: Server-Side WebAssembly and Interface Testing
Wasmtime is a fast, standards-compliant WebAssembly runtime from the Bytecode Alliance. While wasm-bindgen connects Rust Wasm to browsers, Wasmtime targets server-side use cases: plugin systems, sandboxed computation, edge functions, and language-agnostic module hosting. WASI (WebAssembly System Interface) gives Wasm modules controlled access to OS resources — files, environment variables, clocks, and network sockets.
Testing Wasmtime-hosted modules is different from browser Wasm testing: you embed the runtime programmatically, grant specific WASI capabilities, and assert on module outputs in Rust test code. This guide covers the complete testing approach.
Wasmtime Embedding Architecture
When you embed Wasmtime in your application, the structure is:
Your Rust Application
└── wasmtime::Engine (shared, expensive to create)
└── wasmtime::Store (per-execution state)
└── wasmtime::Instance (compiled module + store)
└── wasmtime::Func (exported functions you can call)For testing, create one Engine per test session and one Store per test case.
Basic Wasmtime Test Setup
# Cargo.toml
[dependencies]
wasmtime = "22.0"
wasmtime-wasi = "22.0"
anyhow = "1.0"
[dev-dependencies]
wasmtime = "22.0"
wasmtime-wasi = "22.0"// tests/wasm_module_tests.rs
use anyhow::Result;
use wasmtime::*;
use std::path::Path;
fn load_wasm_module(engine: &Engine, wasm_path: &str) -> Result<Module> {
Module::from_file(engine, wasm_path)
}
fn compile_wat(engine: &Engine, wat: &str) -> Result<Module> {
Module::new(engine, wat)
}
#[test]
fn test_simple_addition_function() -> Result<()> {
let engine = Engine::default();
// Inline WAT for testing — no external .wasm file needed
let module = compile_wat(&engine, r#"
(module
(func $add (export "add") (param i32 i32) (result i32)
local.get 0
local.get 1
i32.add)
)
"#)?;
let mut store = Store::new(&engine, ());
let instance = Instance::new(&mut store, &module, &[])?;
let add = instance.get_typed_func::<(i32, i32), i32>(&mut store, "add")?;
assert_eq!(add.call(&mut store, (3, 4))?, 7);
assert_eq!(add.call(&mut store, (-1, 1))?, 0);
assert_eq!(add.call(&mut store, (i32::MAX, 0))?, i32::MAX);
Ok(())
}
#[test]
fn test_string_processing_module() -> Result<()> {
let engine = Engine::default();
// Load a real .wasm file built from your Rust source
let module = Module::from_file(&engine, "target/wasm32-wasi/release/my_processor.wasm")?;
let mut store = Store::new(&engine, ());
let instance = Instance::new(&mut store, &module, &[])?;
// Get memory and functions
let memory = instance.get_memory(&mut store, "memory")
.expect("Module must export 'memory'");
let process = instance.get_typed_func::<(i32, i32), i32>(&mut store, "process")?;
// Write input string to Wasm memory
let input = b"hello world";
let input_ptr = 0i32; // Write at offset 0 for testing
memory.write(&mut store, input_ptr as usize, input)?;
let result_len = process.call(&mut store, (input_ptr, input.len() as i32))?;
// Read result from memory
let mut output = vec![0u8; result_len as usize];
memory.read(&store, input_ptr as usize, &mut output)?;
assert_eq!(&output, b"HELLO WORLD");
Ok(())
}WASI Interface Testing
WASI gives Wasm modules access to OS capabilities through a standardized interface. Testing WASI modules requires granting specific capabilities and verifying the module uses them correctly:
// tests/wasi_tests.rs
use anyhow::Result;
use wasmtime::*;
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, preview1::WasiPreview1View};
struct WasiState {
ctx: WasiCtx,
}
impl WasiPreview1View for WasiState {
fn ctx(&mut self) -> &mut WasiCtx {
&mut self.ctx
}
}
fn create_wasi_store_with_stdio(engine: &Engine, stdin_data: &str) -> Store<WasiState> {
let wasi_ctx = WasiCtxBuilder::new()
.stdin(wasmtime_wasi::pipe::ReadPipe::from(stdin_data.as_bytes()))
.stdout(wasmtime_wasi::pipe::WritePipe::new_in_memory())
.stderr(wasmtime_wasi::pipe::WritePipe::new_in_memory())
.build();
Store::new(engine, WasiState { ctx: wasi_ctx })
}
#[test]
fn test_wasi_module_reads_stdin_writes_stdout() -> Result<()> {
let engine = Engine::default();
let module = Module::from_file(&engine, "target/wasm32-wasi/release/csv_processor.wasm")?;
let mut store = create_wasi_store_with_stdio(&engine, "1,2,3,4,5\n");
let mut linker = Linker::new(&engine);
wasmtime_wasi::preview1::add_to_linker_sync(&mut linker, |s: &mut WasiState| s)?;
let instance = linker.instantiate(&mut store, &module)?;
let main = instance.get_typed_func::<(), ()>(&mut store, "_start")?;
main.call(&mut store, ())?;
// Read stdout output
let stdout = store.data().ctx.get_stdout()
.expect("stdout pipe not found");
let output = String::from_utf8(stdout.try_into_inner().unwrap().into_inner())?;
assert!(output.contains("15"), "Expected sum 15, got: {}", output);
Ok(())
}
#[test]
fn test_wasi_module_environment_variables() -> Result<()> {
let engine = Engine::default();
let module = Module::from_file(&engine, "target/wasm32-wasi/release/config_reader.wasm")?;
let wasi_ctx = WasiCtxBuilder::new()
.env("APP_MODE", "test")
.env("LOG_LEVEL", "debug")
.stdout(wasmtime_wasi::pipe::WritePipe::new_in_memory())
.build();
let mut store = Store::new(&engine, WasiState { ctx: wasi_ctx });
let mut linker = Linker::new(&engine);
wasmtime_wasi::preview1::add_to_linker_sync(&mut linker, |s: &mut WasiState| s)?;
let instance = linker.instantiate(&mut store, &module)?;
let main = instance.get_typed_func::<(), ()>(&mut store, "_start")?;
main.call(&mut store, ())?;
let stdout_output = get_stdout_string(&store);
assert!(stdout_output.contains("mode=test"), "Module should read APP_MODE env var");
Ok(())
}
#[test]
fn test_wasi_module_file_access_denied_without_capability() -> Result<()> {
let engine = Engine::default();
let module = Module::from_file(&engine, "target/wasm32-wasi/release/file_reader.wasm")?;
// Create WASI context WITHOUT filesystem access
let wasi_ctx = WasiCtxBuilder::new()
// No .preopened_dir() call — filesystem is sandboxed
.stdout(wasmtime_wasi::pipe::WritePipe::new_in_memory())
.build();
let mut store = Store::new(&engine, WasiState { ctx: wasi_ctx });
let mut linker = Linker::new(&engine);
wasmtime_wasi::preview1::add_to_linker_sync(&mut linker, |s: &mut WasiState| s)?;
let instance = linker.instantiate(&mut store, &module)?;
let main = instance.get_typed_func::<(), ()>(&mut store, "_start")?;
// Module tries to open a file — should fail with WASI error, not panic
let result = main.call(&mut store, ());
// The module should handle the WASI error gracefully
// (not crash the host runtime)
let stdout = get_stdout_string(&store);
assert!(
stdout.contains("permission denied") || stdout.contains("error"),
"Module should report file access failure gracefully"
);
Ok(())
}Testing Wasm Sandboxing
One key use case for Wasmtime is running untrusted code. Test that the sandbox actually works:
#[test]
fn test_wasm_cannot_access_host_memory() -> Result<()> {
let engine = Engine::default();
// A module that tries to access memory outside its bounds
let module = compile_wat(&engine, r#"
(module
(memory 1)
(func $try_oob (export "try_oob") (result i32)
;; Try to load from address 0 — within module memory, safe
i32.const 0
i32.load)
)
"#)?;
let mut store = Store::new(&engine, ());
let instance = Instance::new(&mut store, &module, &[])?;
let try_oob = instance.get_typed_func::<(), i32>(&mut store, "try_oob")?;
// Access within bounds — should succeed
let result = try_oob.call(&mut store, ());
assert!(result.is_ok(), "In-bounds memory access should succeed");
Ok(())
}
#[test]
fn test_wasm_out_of_bounds_access_traps() -> Result<()> {
let engine = Engine::default();
let module = compile_wat(&engine, r#"
(module
(memory 1) ;; 1 page = 64KB
(func $oob (export "oob") (result i32)
;; 65536 = exactly one page — out of bounds
i32.const 65536
i32.load)
)
"#)?;
let mut store = Store::new(&engine, ());
let instance = Instance::new(&mut store, &module, &[])?;
let oob = instance.get_typed_func::<(), i32>(&mut store, "oob")?;
// Out-of-bounds access should trap (not crash the host)
let result = oob.call(&mut store, ());
assert!(result.is_err(), "Out-of-bounds access should trap");
let err = result.unwrap_err();
assert!(
err.to_string().contains("trap") || err.to_string().contains("out of bounds"),
"Error should indicate a memory trap: {}", err
);
Ok(())
}Host Function Testing
Test that host functions (Rust functions called from Wasm) work correctly:
#[test]
fn test_host_function_called_from_wasm() -> Result<()> {
let engine = Engine::default();
let module = compile_wat(&engine, r#"
(module
(import "host" "log" (func $log (param i32 i32)))
(memory 1)
(func $run (export "run")
i32.const 0 ;; ptr to message
i32.const 5 ;; length
call $log)
(data (i32.const 0) "hello")
)
"#)?;
use std::sync::{Arc, Mutex};
let logged_messages: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let logged_messages_clone = logged_messages.clone();
let mut store = Store::new(&engine, ());
let mut linker = Linker::new(&engine);
// Register the host "log" function
linker.func_wrap("host", "log", move |mut caller: Caller<'_, ()>, ptr: i32, len: i32| {
let memory = caller.get_export("memory").unwrap().into_memory().unwrap();
let mut buf = vec![0u8; len as usize];
memory.read(&caller, ptr as usize, &mut buf).unwrap();
let message = String::from_utf8_lossy(&buf).to_string();
logged_messages_clone.lock().unwrap().push(message);
})?;
let instance = linker.instantiate(&mut store, &module)?;
let run = instance.get_typed_func::<(), ()>(&mut store, "run")?;
run.call(&mut store, ())?;
let messages = logged_messages.lock().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "hello");
Ok(())
}CI Integration
# .github/workflows/wasmtime-tests.yml
name: Wasmtime and WASI Tests
on: [push, pull_request]
jobs:
wasmtime-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-wasi
- name: Build WASI modules under test
run: |
cargo build --target wasm32-wasi --release \
--package csv_processor \
--package config_reader \
--package file_reader
- name: Run Wasmtime embedding tests
run: cargo test --test wasm_module_tests --test wasi_testsProduction Monitoring
Wasmtime-based plugin systems can fail when modules are updated. HelpMeTest lets you schedule integration tests that load your production Wasm modules and exercise their APIs, catching incompatibilities between updated modules and the host application before users encounter them.
Conclusion
Wasmtime testing focuses on the host-side embedding: loading modules, granting WASI capabilities, calling exports, and verifying behavior through host-side assertions. Test the sandbox boundary explicitly — verify that modules trap on out-of-bounds access rather than crashing your host. Test WASI capability isolation to confirm that modules only access what you explicitly grant. For plugin systems, test host function registration to catch API contract violations between host and module. These tests run entirely in Rust with cargo test — no browser or separate runtime required.