Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Part 7: Testing Across the Boundary — When the Compiler Can Only Check One Side at a Time

Part 6 gave us a working plugin system. Discovery, loading, execution, error handling, composition — all wired up and running. But we tested it by hand. We ran the host, looked at the output, and said “looks right.”

That doesn’t scale. When you have ten plugins, each with their own error types and edge cases, you need automated tests. And testing WASM components is different from testing normal Rust code, because you’re testing across a boundary — the plugin compiles to WASM, the host runs it through wasmtime, and the only contract between them is a WIT file.

This part covers three levels of testing. Unit tests catch logic errors in the plugin before it becomes WASM. Integration tests catch boundary mismatches by loading actual components through wasmtime. Contract tests catch interface drift by verifying the WIT file matches what both sides expect. Each level catches different bugs. You need all three.

The Testing Problem

Here’s the thing about component plugins: they have two implementations of the same interface. The plugin implements the WIT interface in Rust that compiles to WASM. The host generates Rust bindings from the same WIT file and calls through wasmtime. If either side gets the interface wrong, you get runtime errors — usually mysterious ones like “export not found” or “type mismatch in func.call.”

Normal Rust code doesn’t have this problem. If you change a function signature, the compiler tells you. But when the plugin and the host compile separately against the same WIT file, the compiler can’t check both sides at once. You need tests that actually load and call the component.

And there’s another problem: the plugin compiles to wasm32-wasip2. You can’t run cargo test on a cdylib (the C dynamic library crate type from Part 1) that targets WASM. The test runner runs on your host architecture (x86_64 or aarch64), not inside a WASM runtime. So you need to split your code: the logic that doesn’t depend on WASM gets tested normally, and the WASM boundary gets tested via the host.

Level 1: Unit Testing the Plugin

The key insight: separate your plugin logic from the Wasm boundary.

Here’s what most tutorials show — the Guest implementation doing everything:

#![allow(unused)]
fn main() {
// plugin/src/lib.rs
wit_bindgen::generate!({
    path: "../wit",
    world: "text-transform",
});
// The generate! macro creates the Guest trait, the export! macro,
// and the binding types. Import from the generated exports module —
// the path matches the WIT package name (example:text-transform):
use exports::example::text_transform::text_transform::{Guest, TransformError};

struct TextTransform;

impl Guest for TextTransform {
    fn transform(input: String) -> Result<String, TransformError> {
        // All the logic in here — hard to test directly
        if input.is_empty() {
            return Err(TransformError::InvalidInput("empty string".to_string()));
        }
        if input.len() > 10_000_000 {
            return Err(TransformError::ProcessingFailed(
                "input exceeds maximum length".to_string(),
            ));
        }
        Ok(input.to_uppercase())
    }
}

export!(TextTransform);
}

You can’t cargo test this. It’s a cdylib targeting wasm32-wasip2. The export! macro and Guest trait are WASM-specific. But the logic — checking for empty input and converting to uppercase — is pure Rust that doesn’t need WASM at all.

The fix: extract the logic into a separate module.

#![allow(unused)]
fn main() {
// plugin/src/lib.rs
mod transform;

wit_bindgen::generate!({
    path: "../wit",
    world: "text-transform",
});
use exports::example::text_transform::text_transform::{Guest, TransformError};

struct TextTransform;

impl Guest for TextTransform {
    fn transform(input: String) -> Result<String, TransformError> {
        transform::transform(input)  // Thin wrapper
    }
}

export!(TextTransform);
}
#![allow(unused)]
fn main() {
// plugin/src/transform.rs
use crate::exports::example::text_transform::text_transform::TransformError;

const MAX_INPUT_LEN: usize = 10_000_000;

pub fn transform(input: String) -> Result<String, TransformError> {
    if input.is_empty() {
        return Err(TransformError::InvalidInput("empty string".to_string()));
    }
    if input.len() > MAX_INPUT_LEN {
        return Err(TransformError::ProcessingFailed(
            format!("input exceeds maximum length of {} bytes", MAX_INPUT_LEN),
        ));
    }
    Ok(input.to_uppercase())
}
}

Now you can test the logic directly:

#![allow(unused)]
fn main() {
// plugin/src/transform.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn uppercase_transform() {
        assert_eq!(transform("hello".into()).unwrap(), "HELLO");
    }

    #[test]
    fn empty_input_is_error() {
        assert!(matches!(transform("".into()), Err(TransformError::InvalidInput(_))));
    }

    #[test]
    fn preserves_non_ascii() {
        assert_eq!(transform("café".into()).unwrap(), "CAFÉ");
    }

    #[test]
    fn whitespace_preserved() {
        assert_eq!(transform("hello world".into()).unwrap(), "HELLO WORLD");
    }
}
}

Run these with cargo test — they compile for your host architecture, not Wasm. They’re fast, they’re normal Rust tests, and they catch logic bugs before you even build the component.

What to Test at This Level

Test happy paths — does transform produce the right output for valid inputs? Test error paths — does it return the right error variant for invalid inputs? Test edge cases — empty strings, very long strings, Unicode, special characters. And test invariants — if transform claims to be idempotent (transform(transform(x)) == transform(x)), test that.

What NOT to Test at This Level

Don’t test Wasm-specific behavior — memory limits, trap handling, import resolution. Don’t test host interaction — how the host calls the function, how errors propagate across the boundary. Don’t test WIT conformance — whether the function signature matches what the WIT file says.

Those are integration tests. We’ll get to them.

Level 2: Integration Testing the Host

Integration tests load an actual .wasm component and call it through wasmtime. This is where you find out if the host and the plugin actually agree on the interface.

The pattern: build a test plugin, load it, call it, check the result.

#![allow(unused)]
fn main() {
// host/tests/integration_test.rs
use anyhow::Result;
use wasmtime::*;
use wasmtime::component::Linker;
use wasmtime_wasi::preview1::WasiP1Ctx;
use wasmtime_wasi::{WasiCtxBuilder, WasiView, WasiCtx, ResourceTable};

// Generate typed bindings from the WIT file.
// This macro must be at module level — it generates types at compile time.
// The exact generated type names depend on your WIT package name
// and interface — check the macro's output if names don't match.
wasmtime::component::bindgen!({ path: "wit/text-transform.wit" });

struct HostState {
    wasi: WasiP1Ctx,
}

// Using the same WasiP1Ctx pattern from Part 6 — the preview1
// compatibility layer bundles WasiCtx and ResourceTable into
// one type. Both patterns work; WasiP1Ctx is one field instead of
// two. The WasiView impl delegates to WasiP1Ctx's methods,
// keeping the linker call simple.
impl WasiView for HostState {
    fn ctx(&mut self) -> &mut WasiCtx { self.wasi.ctx() }
    fn table(&mut self) -> &mut ResourceTable { self.wasi.table() }
}

/// Helper: load a component from the build directory
fn load_component(engine: &Engine, name: &str) -> Result<Component> {
    let path = format!("../target/wasm32-wasip2/release/{}.wasm", name);
    Ok(Component::from_file(engine, &path)?)
}

/// Helper: create a component-mode store with WASI support
fn create_store(engine: &Engine) -> Result<(Store<HostState>, Linker<HostState>)> {
    let mut linker = Linker::new(engine);
    wasmtime_wasi::add_to_linker_sync(&mut linker)?;

    let wasi = WasiCtxBuilder::new().build_p1();
    let state = HostState { wasi };
    let store = Store::new(engine, state);

    Ok((store, linker))
}

#[test]
fn load_and_call_normalizer() -> Result<()> {
    let engine = Engine::default();
    let component = load_component(&engine, "normalizer")?;
    let (mut store, linker) = create_store(&engine)?;

    let instance = linker.instantiate(&mut store, &component)?;
    let bindings = TextTransform::new(&mut store, &instance)?;

    let result = bindings.call_transform(&mut store, "  hello  world  ")?;
    assert_eq!(result?, "hello world");

    Ok(())
}

#[test]
fn load_and_call_validator_invalid_input() -> Result<()> {
    let engine = Engine::default();
    let component = load_component(&engine, "validator")?;
    let (mut store, linker) = create_store(&engine)?;

    let instance = linker.instantiate(&mut store, &component)?;
    let bindings = TextTransform::new(&mut store, &instance)?;

    let result = bindings.call_transform(&mut store, "")?;
    assert!(result.is_err(), "empty string should fail validation");

    Ok(())
}

#[test]
fn load_and_call_word_counter() -> Result<()> {
    let engine = Engine::default();
    let component = load_component(&engine, "word_counter")?;
    let (mut store, linker) = create_store(&engine)?;

    let instance = linker.instantiate(&mut store, &component)?;
    let bindings = TextTransform::new(&mut store, &instance)?;

    let result = bindings.call_transform(&mut store, "hello world")?;
    // Word counter prepends statistics to the input (from Part 6).
    // A targeted assertion checks the important part — the count —
    // without making the test brittle about exact formatting.
    assert!(result?.starts_with("Words: 2"), "word counter should report 2 words");

    Ok(())
}
}

The Build Dependency

Integration tests need compiled .wasm components. This means your test workflow is:

  1. Build the plugins: cd plugin && cargo build --target wasm32-wasip2 --release
  2. Run the host tests: cd host && cargo test

If the plugins aren’t built, the tests fail with “file not found.” This is a common source of CI failures — the test runner can’t build Wasm components on its own.

You can automate this with a build script:

// host/build.rs
use std::process::Command;

fn main() {
    // Build the plugin for Wasm
    let status = Command::new("cargo")
        .args(["build", "--target", "wasm32-wasip2", "--release"])
        .current_dir("../plugin")
        .status()
        .expect("failed to run cargo build for plugin");

    assert!(status.success(), "plugin build failed");

    // Tell Cargo to re-run if the plugin source changes.
    // Note: rerun-if-changed requires a file path, not a directory.
    // Listing individual files ensures rebuilds happen when source changes.
    println!("cargo:rerun-if-changed=../plugin/src/lib.rs");
    println!("cargo:rerun-if-changed=../plugin/src/transform.rs");
    println!("cargo:rerun-if-changed=wit/text-transform.wit");
    // Note: watch the host's WIT copy, not the plugin's. The bindgen!
    // macro reads wit/text-transform.wit — if someone edits that file
    // without touching ../plugin/wit/world.wit, the build script must
    // still trigger a rebuild so the generated bindings stay fresh.
}

Note: Build scripts run before the main crate compiles. This works, but it makes cargo test slower because it rebuilds the plugin every time. In CI, consider building the plugins in a separate step and caching the .wasm files.

What to Test at This Level

Test that the plugin loads without error — if the WIT interface changed and the plugin wasn’t rebuilt, you find out here. Test that happy path calls work — the host can call the plugin and get the right result. Test that error propagation works — the host receives the right error variant from the plugin. Test that multiple plugins load — the host can load and call more than one component. And test store isolation — a trap in one plugin doesn’t affect others.

What NOT to Test at This Level

Don’t test plugin logic — that’s what unit tests are for. Don’t duplicate them. Don’t test WASM internal details — don’t test wasmtime’s implementation. Test your code. Don’t test performance — integration tests with real WASM instantiation are too slow for benchmarks. Use criterion separately.

Level 3: Contract Testing

Contract testing answers a question that neither unit tests nor integration tests can: “does the WIT file actually describe what both sides expect?”

Here’s the scenario that contract tests catch: you update the WIT file to add a new field to a record, rebuild the host (which picks up the new WIT), but forget to rebuild the plugin. The host expects Pair { word: string, count: u32, language: string } but the plugin still produces Pair { word: string, count: u32 }. Integration tests might catch this — or they might not, if the host code doesn’t access the new field in the test.

Contract tests check the WIT file itself: the types, the function signatures, the resource definitions. They ensure that the WIT is consistent and complete.

Checking the WIT Directly

The simplest contract test: parse the WIT file and verify it declares what you expect.

#![allow(unused)]
fn main() {
// tests/contract_test.rs
use anyhow::Result;

/// Verify the WIT file declares the expected interface.
///
/// This catches silent WIT drift — when the WIT file changes
/// without updating the host or plugin code (or vice versa).
#[test]
fn text_transform_wit_has_expected_functions() -> Result<()> {
    let wit_source = std::fs::read_to_string("wit/text-transform.wit")?;

    // The interface must declare a transform function
    assert!(
        wit_source.contains("transform"),
        "WIT must declare 'transform' function"
    );

    // The interface must declare the error type
    assert!(
        wit_source.contains("variant transform-error"),
        "WIT must declare 'transform-error' variant"
    );

    // The error must have the expected cases
    assert!(
        wit_source.contains("invalid-input"),
        "transform-error must have 'invalid-input' case"
    );
    assert!(
        wit_source.contains("processing-failed"),
        "transform-error must have 'processing-failed' case"
    );

    Ok(())
}
}

This is a weak form of contract testing — it checks that the WIT file contains certain strings. It won’t catch type signature changes (like transform changing from func(text: string) -> string to func(text: string) -> result<string, transform-error>). For that, you need to actually parse the WIT.

Parsing the WIT with wit-parser

The wit-parser crate parses WIT files into a structured representation you can assert against:

# Add to host/Cargo.toml [dev-dependencies]
wit-parser = "0.221"  # Matches wasmtime 29; check crates.io for the latest
#![allow(unused)]
fn main() {
// tests/contract_test.rs
//
// Written against wit-parser 0.221 (pulled in by wasmtime 29). The crate is pre-1.0;
// if you're on a different version, the Arena access patterns or
// WorldItem field names may differ, but the structure is the same:
// parse WIT → find the World → walk exports → assert structure.
use anyhow::{Result, anyhow};
use wit_parser::{Resolve, WorldItem};

#[test]
fn text_transform_interface_matches_contract() -> Result<()> {
    let mut resolve = Resolve::new();
    // push_path returns (PackageId, PackageSourceMap). We only need the id.
    let (pkg_id, _) = resolve.push_path("wit/text-transform.wit")?;

    // Navigate from the package to its world.
    // resolve.packages is an Arena<Package> — index with pkg_id.
    // Package.worlds is a HashMap<String, WorldId>.
    let pkg = &resolve.packages[pkg_id];
    let world_id = pkg.worlds.values().next()
        .copied()
        .ok_or_else(|| anyhow!("no world found in WIT package"))?;
    let world = &resolve.worlds[world_id];

    // world.exports maps Names to WorldItem variants:
    //   Function(Function) — a direct function export
    //   Interface { id: InterfaceId, .. } — an interface export
    //   Type { id: TypeId, .. } — a type export

    // Check that the world exports a "transform" function.
    // The function could be a direct world export or inside an interface.
    let has_transform = world.exports.values().any(|item| {
        match item {
            WorldItem::Function(f) => f.name == "transform",
            WorldItem::Interface { id, .. } => {
                // Check if the interface contains a "transform" function
                resolve.interfaces[*id].functions.values()
                    .any(|f| f.name == "transform")
            }
            _ => false,
        }
    });
    assert!(has_transform, "world must export 'transform'");

    // For more specific checks — like verifying a function's parameter
    // types — dig into the Function struct's params and result fields,
    // or into resolve.types using the TypeIds from WorldItem::Type.

    Ok(())
}
}

wit-parser API stability: The wit-parser crate is pre-1.0, so types and methods change between versions. The code above was written against wit-parser 0.221 — the version pulled in by wasmtime 29 (verified via cargo generate-lockfile with wasmtime = "29"). Field names and method signatures may differ between versions, but the structure is the same: Resolve::push_pathresolve.packages[id]pkg.worldsresolve.worlds[id]world.exports. Check the wit-parser docs for your version’s API.

What Contract Tests Catch

Contract tests catch missing exports — the WIT doesn’t declare a function the host expects. They catch type mismatches — the WIT declares result<string, error> but the host code assumes string. They catch breaking changes — a record field was removed or renamed. And they catch stale builds — the WIT changed but the plugin wasn’t rebuilt (caught when integration tests fail because the compiled component doesn’t match the new WIT).

Contract tests are especially valuable in a monorepo where multiple teams edit the WIT file. Without them, a WIT change can silently break consumers who haven’t updated their code yet.

A Test Matrix

Here’s how the three levels map to different failure modes:

Failure ModeUnit TestIntegration TestContract Test
Plugin logic error✅ Caught✅ Caught❌ Missed
Plugin not rebuilt after WIT change❌ Missed✅ Caught⚠️ Partial
Host expects different type than WIT declares❌ Missed✅ Caught✅ Caught
WIT missing expected export❌ Missed❌ Missed✅ Caught
Error variant not handled by host❌ Missed✅ Caught❌ Missed
Resource lifetime issue❌ Missed✅ Caught❌ Missed

Notice the gaps: no single level catches everything. A resource lifetime issue (the store is dropped while the host still holds a resource handle) is only caught by integration tests. A missing WIT export is only caught by contract tests. You need all three.

The ⚠️ on “Plugin not rebuilt after WIT change” deserves an explanation. Contract tests verify that the WIT file matches expectations — but they don’t load the compiled .wasm. If someone updates the WIT and also updates the contract tests, the stale plugin still passes contract tests because contract tests never instantiate it. Integration tests catch this because they load the actual component and wasmtime rejects the mismatch between the new WIT bindings and the old binary. Contract tests are “partial” because they only catch the case where the WIT changed but the test expectations didn’t — a narrower failure mode.

Testing Error Paths

Error paths are the most important thing to test in a plugin system, because they’re the paths that fail in production. Happy paths usually work. Error paths often don’t.

Plugin-Side Error Tests

Every error variant your WIT defines should have at least one test:

#![allow(unused)]
fn main() {
// plugin/src/transform.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_string_returns_invalid_input() {
        let err = transform("".into()).unwrap_err();
        assert!(matches!(err, TransformError::InvalidInput(_)));
    }

    #[test]
    fn too_long_returns_processing_failed() {
        let long_input = "x".repeat(10_000_001);
        let err = transform(long_input.into()).unwrap_err();
        assert!(matches!(err, TransformError::ProcessingFailed(_)));
    }
}
}

Host-Side Error Handling Tests

Test that the host handles each error variant correctly:

#![allow(unused)]
fn main() {
// host/tests/integration_test.rs
#[test]
fn validator_rejects_empty_string() -> Result<()> {
    let engine = Engine::default();
    let component = load_component(&engine, "validator")?;
    let (mut store, linker) = create_store(&engine)?;

    let instance = linker.instantiate(&mut store, &component)?;
    let bindings = TextTransform::new(&mut store, &instance)?;

    let result = bindings.call_transform(&mut store, "")?;
    match result {
        Err(TransformError::InvalidInput(msg)) => {
            assert!(!msg.is_empty(), "error message should be descriptive");
        }
        Err(other) => panic!("wrong error variant: {:?}", other),
        Ok(_) => panic!("empty string should not succeed"),
    }
    Ok(())
}

#[test]
fn host_continues_after_plugin_error() -> Result<()> {
    let mut pipeline = Pipeline::new(&engine)?;

    // Validator rejects empty string
    pipeline.add_plugin("validator")?;

    // But the pipeline should be able to load and run other plugins
    // after the validator fails
    let results = pipeline.run("")?;
    assert!(results.has_errors(), "should have error from validator");
    // The pipeline itself didn't crash
    Ok(())
}
}

Note: TransformError is the error variant we defined in our WIT file in Part 6 (the text-transform interface). The exact type name comes from the bindgen! macro — check the generated code if the name doesn’t match.

Testing Traps

A Wasm trap is the worst-case scenario: the plugin panicked, hit a stack overflow, or divided by zero. The host needs to handle this without crashing.

WASM traps are returned as Err from the wasmtime call — they are not Rust panics. You don’t need catch_unwind. The trap is caught by wasmtime and surfaced as an error result:

#![allow(unused)]
fn main() {
#[test]
fn host_survives_trap() -> Result<()> {
    let engine = Engine::default();
    // Load a plugin that calls unreachable!() (traps on every call)
    let component = load_component(&engine, "trap-plugin")?;
    let (mut store, linker) = create_store(&engine)?;

    let instance = linker.instantiate(&mut store, &component)?;
    let bindings = TextTransform::new(&mut store, &instance)?;

    let result = bindings.call_transform(&mut store, "hello");

    // The trap is returned as Err — the host process is fine
    assert!(result.is_err(), "Wasm trap should return Err, not panic");

    // The host can continue using the store for other plugins
    Ok(())
}
}

Note: Creating a plugin that intentionally traps requires writing raw WASM or using a plugin that calls unreachable!(). In practice, you’d test this with a specially-built test fixture, not a normal plugin. The key assertion is that the host doesn’t panic — the trap is caught by wasmtime and returned as an Err.

Testing the Pipeline

Part 6’s pipeline chains multiple plugins together. Testing it means testing the interaction between plugins — the host passes one plugin’s output to the next plugin’s input. The tests below use a Pipeline struct with methods like add_plugin, run, executed_count (number of plugins that actually ran), has_errors, has_skips, and succeeded_count — the same run_pipeline logic from Part 6, wrapped in a struct. If you’re building along, implement the Pipeline methods yourself, or adapt the tests to call run_pipeline directly.

#![allow(unused)]
fn main() {
#[test]
fn pipeline_chains_plugins_in_order() -> Result<()> {
    let mut pipeline = Pipeline::new(&engine)?;
    pipeline.add_plugin("normalizer")?;  // Trims whitespace
    pipeline.add_plugin("word-counter")?;  // Counts words

    let results = pipeline.run("  hello   world  ")?;

    // The normalizer should have cleaned the whitespace first,
    // then the word counter counts "hello world" (2 words)
    assert_eq!(results.executed_count(), 2);
    assert!(results.all_succeeded());
    Ok(())
}

#[test]
fn pipeline_stops_on_first_error() -> Result<()> {
    let mut pipeline = Pipeline::new(&engine)?;
    pipeline.add_plugin("validator")?;  // Rejects empty
    pipeline.add_plugin("normalizer")?;

    let results = pipeline.run("")?;

    // Validator rejects, normalizer never runs
    assert!(results.has_errors());
    assert_eq!(results.executed_count(), 1, "should stop after first error");
    Ok(())
}

#[test]
fn pipeline_skips_broken_plugin() -> Result<()> {
    let mut pipeline = Pipeline::new(&engine)?;
    pipeline.add_plugin("normalizer")?;
    pipeline.add_plugin("nonexistent")?;  // Not a real plugin
    pipeline.add_plugin("word-counter")?;

    let results = pipeline.run("hello world")?;

    // The broken plugin is skipped, the others still run
    assert!(results.has_skips(), "broken plugin should be skipped");
    assert_eq!(results.succeeded_count(), 2, "other plugins should succeed");
    Ok(())
}
}

A CI Workflow

Here’s a GitHub Actions workflow that runs all three levels:

name: Test Plugin System

on: [push, pull_request]

jobs:
  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: wasm32-wasip2
      - name: Unit test plugin
        run: cd plugin && cargo test
      - name: Unit test host
        run: cd host && cargo test --lib

  integration-test:
    runs-on: ubuntu-latest
    needs: unit-test
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: wasm32-wasip2
      - name: Build plugins
        run: cd plugin && cargo build --target wasm32-wasip2 --release
      - name: Integration tests
        run: cd host && cargo test --test integration_test
        # We run only integration tests here — contract tests have their
        # own job, and unit tests run in the unit-test job. Each job maps to
        # exactly one testing level, with no overlap.

  contract-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - name: Contract tests
        run: cd host && cargo test --test contract_test

The key detail: integration tests run after unit tests and require built plugins. Contract tests run independently — they only need the WIT file, not the compiled Wasm.

The Testing Mental Model

Think of it as three walls of defense:

   Plugin source         WIT file           Host code
        │                    │                   │
        ▼                    ▼                   ▼
   ┌─────────┐        ┌──────────┐       ┌──────────┐
   │  Unit    │        │ Contract │       │   Unit   │
   │  Tests   │        │  Tests   │       │   Tests  │
   └────┬─────┘        └────┬─────┘       └────┬─────┘
        │                    │                   │
        │    compiled Wasm   │  generated code   │
        ▼                    ▼                   ▼
   ┌──────────────────────────────────────────────────┐
   │              Integration Tests                    │
   │     (host + plugin + wasmtime + WIT together)    │
   └──────────────────────────────────────────────────┘

Unit tests are fast and catch logic errors. Contract tests catch interface drift. Integration tests catch everything else — but they’re slow and require built artifacts. The faster a test runs, the more often you’ll run it. Run unit tests on every save. Run integration tests before you merge. Run contract tests when the WIT changes.


Going Further

This is the last part of the tutorial. You now have a complete component-based plugin system: discovery, loading, execution, error handling, composition, a real-world pipeline, and three levels of testing. Here’s where to take it next.

Fuzz testing the boundary. Property-based testing with proptest or arbitrary can generate random inputs and verify invariants: the plugin never crashes the host, error types are always propagated correctly, and round-trip serialization (host → plugin → host) preserves data. Fuzz testing is especially good at finding edge cases in string handling and Unicode.

Performance testing. Integration tests tell you if things work, not how fast they are. Use criterion benchmarks to measure Wasm instantiation time, call overhead, and data transfer costs. The Component Model’s canonical ABI is evolving zero-copy optimizations for buffer transfers — when available in your toolchain version, benchmark with and without them to see the difference.

Plugin sandboxing. WASI’s capability-based security can restrict what plugins access: no filesystem, no network, only the functions the host provides. Configure WASI contexts per-plugin with allowlists. This is the main security advantage of the Component Model over native plugins — use it.

Async components. The Component Model’s async proposal (streams, futures) would let plugins process large inputs without buffering everything in memory. A text transform that processes a 10MB file doesn’t need 10MB of WASM memory — it can stream chunks. As of May 2026, wasmtime’s Component Model async support is still in development — check the Component Model spec and the wasmtime repo for updates.

Publishing and distributing plugins. A production plugin system needs a way to find and install plugins. Warg and other WASM registries let you publish components with version constraints. The host can resolve dependencies and download plugins at startup, instead of requiring them in a local directory. Warg integrates with wasm-pkg-tools — the same .wasm artifacts you’ve been building with cargo build --target wasm32-wasip2 can be published directly.

Multiple languages. The Component Model is language-agnostic. Your host is Rust, but plugins could be Go, Python, or JavaScript — any language with a wit-bindgen generator. Add a Python plugin that uses componentize-py or a Go plugin that uses wit-bindgen-go. The host code doesn’t change at all.


Seven parts ago, we started with a single function that took a string and returned its length. Now you can define typed interfaces, pass structured data, handle errors, manage stateful resources, compose components, build real plugin systems, and test all of it. The Component Model did the heavy lifting — not only by removing unsafe pointer arithmetic, but by giving the type system enough information to check both sides of the boundary at once.

The payoff isn’t the plumbing. It’s what you can build when the plumbing disappears.