Testing your app
A Calimero app is ordinary Rust that the runtime compiles to WASM. That means
you can test its logic the same way you test any Rust crate: with #[cfg(test)]
modules and cargo test. The SDK ships an in-process harness, TestHost, that
stands in for the node — it gives your #[app::state] type a clean in-memory
store, captures events and logs, and lets you drive call / view methods and
assert on what comes back.
Why test in-process
Section titled “Why test in-process”Spinning up a node, installing a context, and poking it over an API works, but
it is slow and awkward for the inner loop. TestHost runs your state type as
plain Rust on the host architecture:
- Fast — millisecond-level tests, no
wasm32build and no containers. - Deterministic — state lives in a mock store reset at the top of every test; the harness reports fixed well-known executor / context ids and a seeded PRNG, so runs are reproducible.
- No node needed — no
merod, no networking, no context setup. It is a unit test, so it slots straight intocargo testand CI.
Use it for the bulk of your logic coverage, then reach for a real node only for the things the harness deliberately does not model (see Limitations).
TestHost lives in calimero_sdk::testing and drives CRDT state through a
native mock store. That mock is gated behind the testing feature of
calimero-storage, so you enable it as a dev-dependency — it never ships in
your cdylib.
-
Add the feature-gated dev-dependency to your app’s
Cargo.toml:[dev-dependencies]# Enables the native mock host so `TestHost` can drive CRDT state under `cargo test`.calimero-storage = { workspace = true, features = ["testing"] } -
Add a test module at the bottom of
src/lib.rsand import the harness:#[cfg(test)]mod tests {use calimero_sdk::testing::TestHost;use super::*;// tests go here} -
Run them:
Terminal window cargo test -p your-app-name
Writing a test
Section titled “Writing a test”The pattern is always the same:
-
Construct a host with
TestHost::new, passing your#[app::init]constructor. The builder runs against a freshly-reset store, exactly like the real init path. -
Drive the state:
call(|s| ...)runs a&mut selfmethod and commits the result;view(|s| ...)runs a&selfmethod without committing. Each returns whatever the closure returns (typically the method’sapp::Result). -
Assert on the returned value, on follow-up
views of the state, and on capturedevents()/logs().
Here is a real, trimmed test from the kv-store example app:
#[cfg(test)]mod tests { use calimero_sdk::testing::TestHost;
use super::*;
#[test] fn set_get_len_remove() { let mut app = TestHost::new(KvStore::init);
app.call(|s| s.set("k".into(), "v".into())).unwrap(); assert_eq!(app.view(|s| s.get("k")).unwrap(), Some("v".to_owned())); assert_eq!(app.view(|s| s.len()).unwrap(), 1);
assert_eq!(app.call(|s| s.remove("k")).unwrap(), Some("v".to_owned())); assert_eq!(app.view(|s| s.get("k")).unwrap(), None); assert_eq!(app.view(|s| s.len()).unwrap(), 0); }
#[test] fn set_emits_event() { let mut app = TestHost::new(KvStore::init);
app.call(|s| s.set("k".into(), "v".into())).unwrap(); assert_eq!(app.events().len(), 1); }}State isolation
Section titled “State isolation”All mock state — storage, events, logs, identity — lives in thread-locals that
TestHost::new resets on construction. Construct a fresh TestHost at the top
of each #[test]. Only one may be live per thread at a time; new panics if
another is still alive rather than silently resetting it out from under you, so
let each one drop (go out of scope) before building the next.
Identity-dependent paths
Section titled “Identity-dependent paths”Until you say otherwise, the harness reports fixed well-known executor / context ids that satisfy owner checks. The relevant methods:
| Method | What it does |
|---|---|
set_executor(id) | Override the executor id reported to app logic for later call / view. |
set_context(id) | Override the context id reported to app logic. |
call_as(id, |s| ...) | Run one mutation as id, driving both the SDK identity and CRDT authorship, then restore. |
executor_id() / context_id() | Read the current identities. |
Inspecting events and logs
Section titled “Inspecting events and logs”Events emitted via app::emit! and lines from app::log! are captured, not
dispatched. Read them with events() / logs(), or drain-and-clear with
take_events() / take_logs(). A captured event exposes its kind, raw data
bytes, and — if emitted with a handler — the handler name:
#[test]fn set_emits_event_with_handler() { let mut app = TestHost::new(KvStore::init);
app.call(|s| s.set("k".into(), "v".into())).unwrap();
let events = app.events(); assert_eq!(events.len(), 1); assert_eq!(events[0].handler.as_deref(), Some("insert_handler"));}Limitations
Section titled “Limitations”The harness covers normal call / view / event / log / identity flows. It is
not a node, and it does not model everything one does. The most important
gap to internalize:
This is exactly how the kv-store-with-handlers example tests it — emit-and-assert
is one test, invoke-the-handler is another:
#[test]fn handler_records_invocation() { let mut app = TestHost::new(KvStore::init);
// The harness captures events but does not auto-dispatch their // handlers, so we invoke the handler method directly to exercise // its bookkeeping in isolation. app.call(|s| s.insert_handler("k", "v")).unwrap();
assert_eq!(app.view(|s| s.get_handler_execution_count()).unwrap(), 1); assert!(app .logs() .iter() .any(|line| line.contains("insert_handler")));}Other host operations have no in-process equivalent and panic if invoked under the harness — test these on a real node instead:
- Cross-context calls (
env::xcall). - Networked blob announce / fetch.
env::ed25519_verify.
#[app::migrate] and read_raw are supported — TestHost::migrate runs a
migration in-process, and helpers like assert_migrate_converges /
assert_absorb_replay_converges assert a migration lands on the same Merkle root
across nodes.
Convergence testing
Section titled “Convergence testing”If your app uses CRDTs (most do), calimero-storage’s testing feature also
exposes converge_app, a multi-replica property harness. It drives N in-memory
replicas under their own executor identities, applies your ops in per-replica
randomized order, gossips the resulting deltas in randomized order, and asserts
every replica ends on the same Merkle root hash — a one-liner “prove my state
converges”. Put these in tests/converge.rs:
use kv_store::KvStore;
use calimero_storage::testing::converge_app;
#[test]fn distinct_keys_all_survive() { converge_app(KvStore::init) .replicas(3) .ops(|s| { let _ = s.set("a".into(), "1".into()); }) .ops(|s| { let _ = s.set("b".into(), "2".into()); }) .ops(|s| { let _ = s.set("c".into(), "3".into()); }) .invariant("all three keys present", |s| s.len().unwrap_or(0) == 3) .assert_all_replicas_equal();}In-process vs end-to-end
Section titled “In-process vs end-to-end”TestHost proves your logic is correct in isolation. It does not prove your
app works when a real node compiles it to WASM, installs it into a context,
routes events through the node’s loop, dispatches cross-context calls, and
synchronizes state across peers over the network. Those are end-to-end concerns:
- Build your app and walk through installing and calling it on a node in the tutorial.
- Run and operate a real node — installing contexts, calling methods, observing sync — under Operate.
Use both: in-process tests for tight, exhaustive logic coverage; a real node for the integration surface the harness deliberately leaves out.