Two separate mock surfaces exist, for testing two separate things: module logic against a guest-side mock, and engine correctness against a real compiled component. Read this before writing a runtime test.
- Module business logic is tested in plain Rust, no wasm. A module's
decision logic lives in a host-generic
logic.rs(keeper.rsin a keeper) asfn on_block<H: Host>(host: &H, ...), and its tests drive it againstnexum-sdk-test::MockHost; venue-facing logic addsvidere-test's transport mocks. No wasmtime, no component boundary, no engine crate at all. This is the dominant pattern across every shipped module (twap-monitor, ethflow-watcher, price-alert, balance-tracker); see docs/sdk.md. New module-logic tests belong here. - The engine harness (this page) is reserved for engine, host, and
boundary correctness: supervision (poison, restart, resource traps), dispatch isolation across chains and modules, fault and log capture, the WASI clock override a real guest observes, capability wiring, stream reconnect. These need a real compiled
.wasmcomponent over async mock backends and genuinely cannot be faked in-process. - Do not test module business logic through the wasm harness. If a
test only needs "given input X the module does Y", it belongs in the module's pure-logic file against
MockHost, not in a booted fixture here. A harness test that could be rewritten as a plain-RustMockHosttest without losing coverage is in the wrong place.
nexum/crates/nexum-runtime's test_utils module (gated behind the test-utils cargo feature) ships two layers:
- The bare mock backends -
MockChainProvider,MockStateStore, andMockTypesimplement the engine's component-seam traits with no network and no disk.mock_components/mock_components_frombundle them into aComponentsready forSupervisor::boot. Use these when a test needs to drive the supervisor directly - multi-module scenarios, custom extensions, or checking host-interface wiring the harness below doesn't expose. TestRuntime/TestRuntimeBuilder- a higher-level harness over the same mocks: launch one module through the real publicRuntimeBuilderpath, inject events, and read back logs and store writes, with no supervisor ceremony. This is what most engine-level tests want.
test_utils only compiles under the test-utils feature (it pulls tempfile, needed for manifest staging):
# nexum/crates/nexum-runtime/Cargo.toml
[features]
test-utils = ["dep:tempfile"]
[dev-dependencies]
# Self dev-dependency enabling `test-utils` for this crate's own test, doc,
# and example targets, so `cargo test -p nexum-runtime` sees the mocks
# without every invocation passing `--features test-utils`.
nexum-runtime = { path = ".", features = ["test-utils"] }A crate outside nexum-runtime that wants the harness - an extension crate testing its own backend against a real supervisor, for instance - depends on nexum-runtime with features = ["test-utils"] directly; no self-dependency trick needed there.
Build the module fixture once (cargo build --target wasm32-wasip2 --release -p example, or just build-module), then:
use alloy_rpc_types_eth::Header;
use nexum_runtime::test_utils::TestRuntime;
#[tokio::test]
async fn example_dispatches_a_block_and_logs_it() {
let wasm = "target/wasm32-wasip2/release/example.wasm";
let mut rt = TestRuntime::builder(wasm)
.manifest_inline(
r#"
[module]
name = "example"
[capabilities]
required = ["logging"]
[[subscription]]
kind = "block"
chain_id = 1
"#,
)
.launch()
.await
.expect("launch the example module");
// Inject a block header. Dispatch runs on the spawned event-loop
// task, so inject then await an observable effect - don't assume
// synchronous delivery.
let mut header: Header = Header::default();
header.inner.number = 42;
rt.push_block(header);
// Notification-driven, not a sleep: resolves as soon as the
// dispatched event's log record lands (5s failure backstop).
rt.wait_for_log("example", "block 42 on chain")
.await
.expect("the module logged the block");
rt.shutdown();
rt.wait().await.expect("clean shutdown");
}Beyond the happy path above:
- Chain-log injection mirrors blocks:
rt.push_chain_log(log)wherelog: alloy_rpc_types_eth::Log. - Chain-request programming, for a module that calls
chain::request(e.g. aneth_calloracle read):rt.chain().on_method(ChainMethod::EthCall, r#""0x...""#)beforelaunch, oron_requestfor a full(method, params)match.ChainMethodisnexum_runtime::host::component::ChainMethod. - Error and stream-end injection, to exercise reconnect and fault
paths:
rt.chain().push_block_err(err)delivers a transport error to the open block stream (err: nexum_runtime::host::provider_pool::ProviderError);rt.chain().close_block_stream()simulates the upstream ending the subscription, so the test can assert the event loop's reconnect logic re-opens it. - Store assertions:
rt.store()exposes the sameMockStateStorethe module wrote throughlocal-store- read back what a dispatched event persisted. - Guest time:
rt.clock()is theManualClockinstalled as the module's WASI clock override; advance it to test time-dependent logic without a real sleep.
You want nexum-sdk-test::MockHost instead (plus videre-test's transport mocks for venue-facing logic): no wasm build, no engine crate, runs in milliseconds. See docs/sdk.md and any shipped module's pure-logic test module for the pattern.