Testing WASM Components with the Component Model and wit-bindgen
The WebAssembly Component Model is a standard for composing WASM modules with typed interfaces defined in WIT (WebAssembly Interface Types). It replaces ad-hoc ABI conventions with a formal type system — records, variants, options, results — and generates host and guest bindings automatically via wit-bindgen. Testing components means verifying these interfaces: that your component exports match the WIT spec, that imports are correctly fulfilled, and that the type conversions between host and guest are lossless.
Key Takeaways
WIT is the contract, components are the implementation. Write the WIT interface first, generate bindings with wit-bindgen, then test against the interface — not the internal implementation.
wasmtime::component::Linker is different from the module Linker. The component model has its own Linker API. Don't mix wasmtime::Linker (for core modules) with wasmtime::component::Linker (for components).
Generated bindings give you type-safe function calls. Instead of working with raw Val and manual memory management, wit-bindgen generates a Rust struct with typed methods matching your WIT exports.
Test the WIT result<T, E> type as Rust Result<T, E>. The component model's error propagation maps directly to Rust's Result. Test both the happy path and the error variants.
Use wasmtime-wasi to fulfill WASI imports. Most components import WASI interfaces. wasmtime_wasi::add_to_linker_sync adds all standard WASI implementations to your test linker.
What the Component Model Changes
Traditional WASM modules exchange data through linear memory — you pass a pointer and a length, the host reads raw bytes, and everyone manually agrees on how structs are laid out. This works but it's error-prone, not self-describing, and language-specific.
The WebAssembly Component Model adds:
- WIT (WebAssembly Interface Types): A language for describing interfaces — functions, records, variants, enums, options, results — that is independent of any host language.
wit-bindgen: A code generator that takes a WIT file and produces typed host bindings (Rust, Python, JavaScript, Go) and guest implementations.- Canonical ABI: A standardized encoding for all WIT types into WASM linear memory, so every language agrees on the wire format.
Testing components is more reliable than testing raw WASM modules because you're testing against a typed interface contract, not against memory offsets.
Defining a WIT Interface
Start by writing your interface in a .wit file:
// wit/calculator.wit
package example:calculator;
interface operations {
record calculation-result {
value: f64,
operation: string,
inputs: list<f64>,
}
variant math-error {
division-by-zero,
overflow,
invalid-input(string),
}
add: func(a: f64, b: f64) -> f64;
subtract: func(a: f64, b: f64) -> f64;
multiply: func(a: f64, b: f64) -> f64;
divide: func(a: f64, b: f64) -> result<f64, math-error>;
compute-with-history: func(a: f64, b: f64, op: string) -> result<calculation-result, math-error>;
}
world calculator-world {
export operations;
}// wit/formatter.wit
package example:formatter;
interface text {
format-number: func(value: f64, decimals: u8) -> string;
format-list: func(items: list<string>, separator: string) -> string;
truncate: func(text: string, max-length: u32) -> string;
}
world formatter-world {
export text;
}Implementing the Component in Rust
# Cargo.toml for the WASM component
[package]
name = "calculator-component"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.24"
[package.metadata.component]
package = "example:calculator"// src/lib.rs
use wit_bindgen::generate;
generate!({
world: "calculator-world",
path: "wit/calculator.wit",
});
struct Component;
impl exports::example::calculator::operations::Guest for Component {
fn add(a: f64, b: f64) -> f64 {
a + b
}
fn subtract(a: f64, b: f64) -> f64 {
a - b
}
fn multiply(a: f64, b: f64) -> f64 {
a * b
}
fn divide(a: f64, b: f64) -> Result<f64, exports::example::calculator::operations::MathError> {
if b == 0.0 {
Err(exports::example::calculator::operations::MathError::DivisionByZero)
} else {
Ok(a / b)
}
}
fn compute_with_history(
a: f64,
b: f64,
op: String,
) -> Result<
exports::example::calculator::operations::CalculationResult,
exports::example::calculator::operations::MathError,
> {
let value = match op.as_str() {
"add" => a + b,
"subtract" => a - b,
"multiply" => a * b,
"divide" => {
if b == 0.0 {
return Err(exports::example::calculator::operations::MathError::DivisionByZero);
}
a / b
}
other => {
return Err(exports::example::calculator::operations::MathError::InvalidInput(
format!("unknown operation: {}", other),
));
}
};
Ok(exports::example::calculator::operations::CalculationResult {
value,
operation: op,
inputs: vec![a, b],
})
}
}
export!(Component);Build the component:
# Install cargo-component
cargo install cargo-component
<span class="hljs-comment"># Build the component
cargo component build --release
<span class="hljs-comment"># Output: target/wasm32-wasip1/release/calculator_component.wasmWriting Host-Side Tests with Wasmtime
Now write tests that load the component and call it through the generated bindings:
# Cargo.toml for the test crate (separate crate or workspace member)
[package]
name = "calculator-tests"
version = "0.1.0"
edition = "2021"
[dependencies]
wasmtime = { version = "18", features = ["component-model"] }
wasmtime-wasi = "18"
anyhow = "1"
wit-bindgen = "0.24"// tests/component_tests.rs
use wasmtime::{Engine, Config, Store};
use wasmtime::component::{Component, Linker};
use wasmtime_wasi::WasiCtxBuilder;
use anyhow::Result;
// Generate host bindings from the WIT file
wasmtime::component::bindgen!({
path: "wit/calculator.wit",
world: "calculator-world",
});
struct TestState {
wasi: wasmtime_wasi::WasiCtx,
table: wasmtime::ResourceTable,
}
impl wasmtime_wasi::WasiView for TestState {
fn table(&mut self) -> &mut wasmtime::ResourceTable { &mut self.table }
fn ctx(&mut self) -> &mut wasmtime_wasi::WasiCtx { &mut self.wasi }
}
fn create_test_store() -> Result<(Engine, Store<TestState>)> {
let mut config = Config::new();
config.wasm_component_model(true);
let engine = Engine::new(&config)?;
let wasi = WasiCtxBuilder::new()
.inherit_stdio()
.build();
let state = TestState {
wasi,
table: wasmtime::ResourceTable::new(),
};
let store = Store::new(&engine, state);
Ok((engine, store))
}
fn load_calculator(engine: &Engine, store: &mut Store<TestState>) -> Result<CalculatorWorld> {
let component = Component::from_file(
engine,
"target/wasm32-wasip1/release/calculator_component.wasm",
)?;
let mut linker: Linker<TestState> = Linker::new(engine);
wasmtime_wasi::add_to_linker_sync(&mut linker)?;
CalculatorWorld::instantiate(store, &component, &linker)
.map(|(instance, _)| instance)
}
#[test]
fn test_add_positive_numbers() -> Result<()> {
let (engine, mut store) = create_test_store()?;
let calculator = load_calculator(&engine, &mut store)?;
let result = calculator.example_calculator_operations()
.call_add(&mut store, 3.0, 4.0)?;
assert_eq!(result, 7.0);
Ok(())
}
#[test]
fn test_add_negative_numbers() -> Result<()> {
let (engine, mut store) = create_test_store()?;
let calculator = load_calculator(&engine, &mut store)?;
let result = calculator.example_calculator_operations()
.call_add(&mut store, -5.0, 3.0)?;
assert_eq!(result, -2.0);
Ok(())
}
#[test]
fn test_divide_success() -> Result<()> {
let (engine, mut store) = create_test_store()?;
let calculator = load_calculator(&engine, &mut store)?;
let result = calculator.example_calculator_operations()
.call_divide(&mut store, 10.0, 4.0)?;
match result {
Ok(value) => assert_eq!(value, 2.5),
Err(e) => panic!("expected Ok(2.5), got Err({:?})", e),
}
Ok(())
}
#[test]
fn test_divide_by_zero_returns_error_variant() -> Result<()> {
let (engine, mut store) = create_test_store()?;
let calculator = load_calculator(&engine, &mut store)?;
let result = calculator.example_calculator_operations()
.call_divide(&mut store, 1.0, 0.0)?;
match result {
Ok(value) => panic!("expected DivisionByZero error, got Ok({})", value),
Err(example::calculator::operations::MathError::DivisionByZero) => {
// Correct — this is what we expect
}
Err(other) => panic!("expected DivisionByZero, got {:?}", other),
}
Ok(())
}
#[test]
fn test_compute_with_history_valid_operations() -> Result<()> {
let (engine, mut store) = create_test_store()?;
let calculator = load_calculator(&engine, &mut store)?;
let ops = &[
("add", 3.0_f64 + 4.0),
("subtract", 10.0 - 3.0),
("multiply", 6.0 * 7.0),
("divide", 15.0 / 3.0),
];
for (op, expected) in ops {
let a = if *op == "subtract" { 10.0 } else if *op == "multiply" { 6.0 } else { 3.0 };
let b = if *op == "subtract" { 3.0 } else if *op == "multiply" { 7.0 } else if *op == "divide" { 3.0 } else { 4.0 };
let result = calculator.example_calculator_operations()
.call_compute_with_history(&mut store, a, b, op)?;
match result {
Ok(record) => {
assert_eq!(&record.operation, op);
assert_eq!(record.inputs, vec![a, b]);
assert!((record.value - expected).abs() < 1e-10,
"op={}: expected {}, got {}", op, expected, record.value);
}
Err(e) => panic!("op={}: unexpected error {:?}", op, e),
}
}
Ok(())
}
#[test]
fn test_compute_with_history_unknown_operation() -> Result<()> {
let (engine, mut store) = create_test_store()?;
let calculator = load_calculator(&engine, &mut store)?;
let result = calculator.example_calculator_operations()
.call_compute_with_history(&mut store, 1.0, 2.0, "modulo")?;
match result {
Err(example::calculator::operations::MathError::InvalidInput(msg)) => {
assert!(msg.contains("modulo"), "error message should mention 'modulo', got: {}", msg);
}
other => panic!("expected InvalidInput error, got {:?}", other),
}
Ok(())
}Testing Component Composition
One of the component model's killer features is composability — you can wire components together, with one component's exports satisfying another's imports. Test this composition:
// tests/composition_tests.rs
use wasmtime::{Engine, Config, Store};
use wasmtime::component::{Component, Linker, Val};
use anyhow::Result;
#[test]
fn test_composed_pipeline() -> Result<()> {
let mut config = Config::new();
config.wasm_component_model(true);
let engine = Engine::new(&config)?;
// Load two components: calculator produces a number, formatter presents it
// In real projects, use wasm-tools compose to link them
// Here we demonstrate manual composition via the Linker
// This test structure shows the pattern; real composition uses wasm-tools:
// wasm-tools compose -o pipeline.wasm calculator.wasm formatter.wasm
println!("Component composition test placeholder — use wasm-tools compose in practice");
Ok(())
}Compose components with wasm-tools:
cargo install wasm-tools
# Compose two components — formatter consumes calculator's output
wasm-tools compose \
--config composition.yaml \
-o pipeline.wasm \
calculator_component.wasm \
formatter_component.wasmIntegration with cargo nextest
# Install nextest for faster parallel test execution
cargo install cargo-nextest
<span class="hljs-comment"># Run component tests with nextest
cargo nextest run --<span class="hljs-built_in">test component_tests
<span class="hljs-comment"># With timing output
cargo nextest run --<span class="hljs-built_in">test component_tests --no-fail-fast --success-output finalCI Configuration
# .github/workflows/wasm-component-tests.yml
name: WASM Component Tests
on: [push, pull_request]
jobs:
component-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-wasip1
- name: Install cargo-component
run: cargo install cargo-component
- name: Install wasm-tools
run: cargo install wasm-tools
- name: Build component
run: cargo component build --release
- name: Run host-side tests
run: cargo test --test component_tests
- name: Validate WIT interfaces
run: |
wasm-tools validate target/wasm32-wasip1/release/calculator_component.wasm
wasm-tools component wit target/wasm32-wasip1/release/calculator_component.wasmEnd-to-End Testing with HelpMeTest
The component model provides the most rigorous unit-level testing story for WebAssembly — typed interfaces, generated bindings, and Rust's type system all working together to catch bugs at compile time and runtime. But components ultimately run inside larger systems: web applications, server runtimes, plugin hosts.
HelpMeTest tests those systems end-to-end without code. Once your calculator component is embedded in a web app, write a plain-English scenario: "enter 10 and 4, click Divide, expect 2.5 to appear". HelpMeTest runs that against your deployed app on every commit.
Pair component model tests (verifying interface contracts) with HelpMeTest scenarios (verifying real user workflows). Together they give you the full confidence stack — from WIT type safety down to what a real user actually sees in their browser.