Skip to content

Testing apps

calimero-sdk’s testing feature is the harness: your app built as the real calimero:app component, run on the real wasmtime engine with an in-memory host (crates/sdk/src/testing). A passing test means what it means on a node. The harness plays the node’s executor: it applies a call’s changes only when the call ends Ok, seeds each logged run so tests repeat, and resolves event audiences against the org you give it.

[dependencies]
calimero-sdk = { .. }
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
calimero-sdk = { .., features = ["testing"] }
calimero-app-build = { .. }
serde_json = "1"

calimero-app-build builds the crate into a component (wasm32-wasip2) for the test. The component apps in core share a small helper for that (apps/kv-store/tests/common).

apps/kv-store/tests/harness.rs:

use calimero_sdk::testing::{Failure, Org, TestApp};
use serde_json::json;
#[test]
fn set_get_len_remove() {
let mut t = common::app(Org::new().members(["bob"]));
t.as_("owner").call("set", json!({ "key": "k", "value": "v" })).unwrap();
assert_eq!(t.as_("owner").view("get", json!({ "key": "k" })), Ok(json!("v")));
assert_eq!(t.as_("owner").view("len", json!({})), Ok(json!("1")));
}
  • TestApp::new(component, org, now_ms) creates the app; init runs as the owner.
  • Org::new().admins([..]).members([..]).guests([..]).teams([..]) sets the org the app sees through env::org_role and env::in_team.
  • t.as_(account).call(method, args) / .view(..) run a method as account, with camelCase JSON arguments, the same as a client sends. The result is Result<Value, Failure>: Failure::App { code, fields } for an app error.
  • t.events_for(account) lists the events account received, in order.
  • t.anonymous() and t.system() call as a caller who is not signed in and as the node; t.api_key(..) as an API key.
  • Time and timers: t.set_time(ms), t.advance(ms), t.tick(timer).
  • The outside world: t.mock_http(..) answers outbound HTTP, t.mock_app(..) stands in for another app, t.take_xcalls() / t.deliver(..) inspect and deliver cross-app messages, t.add_file(..) / t.file(..) seed and read files.

A scenario is a JSON list of steps that the Rust harness, the TypeScript harness and the node’s cluster runner all run, so one file tests the app everywhere. Each scenarios/<name>.json runs in the org its <name>.org.json names.

[
{ "as": "bob", "op": "call", "method": "getOrInsert",
"args": { "key": "k", "value": "first" },
"expect": { "ok": "first" },
"events": { "bob": ["Inserted"] } }
]
  • op: call, view, tick (a timer, always as the system), advance (milliseconds), removeMember, or upgrade in a migration scenario (below).
  • as: an account, "@anonymous" or "@system"; "confirmed": true adds the passkey step-up.
  • expect: { "ok": value }, { "error": code, "fields": {..} }, { "trap": .. }, { "confirmRequired": true }, or { "refused": "<API code>" } when the platform refuses before the app runs.
  • "save": "id" stores a result; "$id" later in args or expect is replaced by it. "@bob" is the account bob as the app sees it.
  • events lists, per account, every event name it has received so far.

Run them all from one test (apps/kv-store/tests/scenarios.rs):

use calimero_sdk::testing::run_scenario;
#[test]
fn every_scenario_passes() {
for path in common::scenarios() {
let mut t = common::app(common::org_of(&path));
run_scenario(&mut t, &path).unwrap_or_else(|e| panic!("{}: {e:#}", path.display()));
}
}

Upgrades: test against a shipped release’s state

Section titled “Upgrades: test against a shipped release’s state”

An upgrade switches an app’s code and keeps its state, so the code you ship next must read what every release still in use wrote (Upgrades & state). Test that against the real thing: the signed .mpk you shipped, not today’s code with an old version number. The harness runs that release’s component, lets a scenario write state with it, upgrades to the build under test as a node does (code only, nothing runs) and goes on with the same state.

Lay it out by release, beside scenarios/:

releases/1.0.0.mpk the bundle 1.0.0 shipped, committed as it was published
migrations/1.0.0.json a scenario: steps on 1.0.0, { "op": "upgrade" }, steps on this build
migrations/1.0.0.org.json its org, as for any scenario (optional)
tests/migrations.rs

apps/kv-store/migrations/0.1.0.json:

[
{ "as": "owner", "op": "call", "method": "set", "args": { "key": "a", "value": "1" } },
{ "as": "bob", "op": "call", "method": "set", "args": { "key": "b", "value": "2" } },
{ "op": "upgrade" },
{ "as": "bob", "op": "view", "method": "entries", "args": {}, "expect": { "ok": { "a": "1", "b": "2" } } },
{ "as": "bob", "op": "call", "method": "updateIfExists", "args": { "key": "a", "value": "10" }, "expect": { "ok": true } }
]

apps/kv-store/tests/migrations.rs runs every one of them:

use calimero_sdk::testing::{release_component, run_migration};
#[test]
fn every_release_upgrades_to_this_build() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR"));
for path in common::scenarios_in(&dir.join("migrations")) {
let version = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
let mpk = std::fs::read(dir.join("releases").join(format!("{version}.mpk"))).unwrap();
let old = release_component(&mpk, None).unwrap();
run_migration(&old, common::component(), common::org_of(&path), common::T0, &path)
.unwrap_or_else(|e| panic!("{}: {e:#}", path.display()));
}
}
  • release_component(mpk, service) (feature releases) is the component of service (None for a single-service release) in a signed .mpk, after the signature and digest checks a node makes on install.
  • run_migration(from, to, org, now_ms, path) starts the app on from, runs the scenario, and switches to to at its one { "op": "upgrade" } step; saved variables carry across it. It returns the TestApp for more asserts.
  • t.upgrade(component) does the switch in a test of your own; Refused { 400, "BAD_BUNDLE" } for a component that cannot be prepared.

Turn the feature on beside testing:

[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
calimero-sdk = { .., features = ["testing", "releases"] }

When the new code cannot read the old state, the step that reads it fails and names it:

migrations/0.1.0.json: step 5: {"as":"bob","method":"entries","op":"view",..}:
expected ok {"a":"1","b":"2"}, got Ok(Object {})

Keep a release’s files for as long as an instance may still run it: an instance can skip releases, and every later build must read its state. The step-by-step walk through a release is Ship v2 of your app.

Plain #[test]s may also call the app’s state directly on the host target: env::mock stands in for the host there. Actions, cross-app calls and outbound HTTP answer an error outside a component; use TestApp for those.