For AI agents: Documentation index at /llms.txt

Skip to content

Testing Strategies

Testing canisters on ICP deserves particular attention for two reasons. First, canister upgrades are irreversible in practice: once a buggy upgrade runs pre_upgrade, your stable memory may be corrupted before you can roll back. Second, cycles cost real money: a performance regression that doubles your instruction count doubles your operating cost. Catching these problems in tests before deployment avoids both classes of harm.

Effective canister testing uses three layers, from fastest to slowest:

  1. Unit tests: Pure Rust or Motoko tests with mocked IC dependencies. Milliseconds per test, no WASM compilation, run in parallel. Cover 90%+ of your business logic here.
  2. PocketIC integration tests: Deploy your canister WASM into a lightweight in-process IC replica. Seconds per test, but test actual IC behavior: canister calls, upgrade hooks, stable memory, multi-canister interactions, and time-based logic.
  3. Deployed testing: Test against a real network (local or mainnet) via the CLI or scripts. Slowest, but validates deployment configuration, cycles top-up, and inter-canister call routing.

Most projects need all three layers. The key insight is to push as much logic as possible into unit tests, then use PocketIC integration tests to verify that the IC-specific scaffolding (stable memory encoding, upgrade hooks, inter-canister calls) behaves correctly end-to-end.

The challenge with testing Rust canisters is that ic_cdk functions like ic_cdk::caller(), ic_cdk::api::time(), and inter-canister calls are not available outside the IC execution environment. The solution is dependency injection: abstract all non-deterministic IC operations behind traits, then inject mocks in tests.

Define a trait for each external dependency:

pub trait StorageApi: Send + Sync {
fn get_count(&self) -> u64;
fn increment(&self) -> u64;
}

Collect dependencies in a central struct:

use std::sync::Arc;
pub struct CanisterApi {
pub storage: Arc<dyn StorageApi>,
// add more dependencies here (governance, time, etc.)
}
impl CanisterApi {
pub fn new(storage: Arc<dyn StorageApi>) -> Self {
Self { storage }
}
}

In production, initialize with real implementations. In tests, inject mocks:

thread_local! {
pub static CANISTER_API: RefCell<CanisterApi> = RefCell::new({
let storage = Arc::new(StableMemoryStorage);
CanisterApi::new(storage)
});
}

With this structure, unit tests run entirely in pure Rust. No WASM, no PocketIC, no network:

#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
struct TestStorage {
count: std::cell::Cell<u64>,
}
impl StorageApi for TestStorage {
fn get_count(&self) -> u64 { self.count.get() }
fn increment(&self) -> u64 {
let n = self.count.get() + 1;
self.count.set(n);
n
}
}
#[test]
fn test_increment() {
let storage = Arc::new(TestStorage { count: std::cell::Cell::new(0) });
let api = CanisterApi::new(storage);
assert_eq!(api.storage.get_count(), 0);
assert_eq!(api.storage.increment(), 1);
assert_eq!(api.storage.increment(), 2);
}
}

Run unit tests with:

Terminal window
cargo test --lib

For a complete working example that shows mocking inter-canister calls, stable memory, and async endpoints, see the unit_testable_rust_canister example.

Motoko unit tests use the mops package manager’s test runner. Install the mops CLI, add a test dependency, and run mops test.

A typical test file using the test package from mops:

import { test; suite; expect } "mo:test";
import Counter "Counter";
suite("Counter", func() {
test("increments correctly", func() {
let c = Counter.Counter(0);
c.increment();
expect.nat(c.get()).equal(1);
});
});
Terminal window
mops test

PocketIC is a lightweight, in-process IC replica designed for testing. It supports Rust and JavaScript/TypeScript. Use PocketIC to test anything that requires actual IC execution: upgrade hooks, stable memory encoding, query vs. update semantics, and multi-canister call graphs.

Add pocket-ic to your dev dependencies:

Cargo.toml
[dev-dependencies]
pocket-ic = "9.0.2"
candid = "0.10"

A basic integration test deploys your canister WASM and calls it:

use pocket_ic::{PocketIc, PocketIcBuilder};
use candid::{encode_one, decode_one, Principal};
#[test]
fn test_counter_roundtrip() {
let pic = PocketIcBuilder::new()
.with_application_subnet()
.build();
// Create and fund the canister
let canister_id = pic.create_canister();
pic.add_cycles(canister_id, 2_000_000_000_000);
// Install the compiled WASM
let wasm = std::fs::read("target/wasm32-unknown-unknown/release/backend.wasm")
.expect("build first: cargo build --target wasm32-unknown-unknown --release");
pic.install_canister(canister_id, wasm, vec![], None);
// Make an update call
let result = pic.update_call(
canister_id,
Principal::anonymous(),
"increment_count",
encode_one(()).unwrap(),
).expect("update call failed");
// Decode and assert
let count: u64 = decode_one(&result).unwrap();
assert_eq!(count, 1);
}

Run integration tests with:

Terminal window
# Build the WASM first
cargo build --target wasm32-unknown-unknown --release
# Run integration tests (in tests/ directory, not --lib)
cargo test

For advanced PocketIC usage: multi-subnet topologies, time travel, NNS subnet setup, and JavaScript/TypeScript testing with Pic JS: see PocketIC.

ICP canisters run inside a deterministic virtual machine where every instruction is counted. Each update call is limited to 40 billion instructions. canbench measures your canister’s instruction count, heap memory, and stable memory usage: and detects regressions by comparing against saved baselines.

Install canbench:

Terminal window
cargo install canbench

Add an optional dependency to your Cargo.toml:

Cargo.toml
[dependencies]
canbench-rs = { version = "0.1.1", optional = true }

Create a canbench.yml pointing to your compiled WASM:

canbench.yml
build_cmd:
cargo build --release --target wasm32-unknown-unknown --features canbench-rs
wasm_path:
./target/wasm32-unknown-unknown/release/<YOUR_CANISTER>.wasm

Annotate benchmark functions with #[bench] inside a canbench-rs feature gate:

#[cfg(feature = "canbench-rs")]
mod benches {
use super::*;
use canbench_rs::bench;
#[bench]
fn fibonacci_20() {
println!("{:?}", fibonacci(20));
}
}
Terminal window
canbench

Sample output:

---------------------------------------------------
Benchmark: fibonacci_20 (new)
total:
instructions: 2301 (new)
heap_increase: 0 pages (new)
stable_memory_increase: 0 pages (new)
---------------------------------------------------
Executed 1 of 1 benchmarks.

Run canbench a second time after saving results: it compares against the baseline and reports regressions. Commit the canbench_results.yml file to your repository so CI can catch regressions automatically.

For full crate documentation, see canbench-rs on docs.rs.

This section covers the “Deployed testing” tier of the testing pyramid: running tests against a full local network rather than an in-process PocketIC replica. icp-cli supports Docker-based test networks for this purpose, which is useful when you need to test deployment configuration, CLI workflows, asset canister behavior, or anything that requires real network I/O.

Add a Docker-based network to icp.yaml:

icp.yaml
networks:
- name: docker-test
mode: managed
image: ghcr.io/dfinity/icp-cli-network-launcher
port-mapping:
- "8001:4943"
rm-on-exit: true
environments:
- name: test
network: docker-test
canisters: [backend]
Terminal window
# Start the containerized network
icp network start docker-test
# Deploy to the test environment
icp deploy -e test
# Run your test scripts against http://localhost:8001
# ...
# Stop and clean up
icp network stop docker-test

The rm-on-exit: true flag removes the Docker container when the network stops, keeping CI environments clean.

For CI/CD pipelines, use port-mapping: ["0:4943"] to let Docker assign an available port, then read the actual port with:

Terminal window
icp network status docker-test --json

For the full containerized network configuration reference: including environment variables, volume mounts, and custom images: see the icp-cli containerized networks guide.

ScenarioRecommended approach
Business logic, pure functionsUnit tests (Rust #[test] or Motoko mops test)
Upgrade hooks, stable memory encodingPocketIC integration tests
Inter-canister callsUnit tests (mocked) + PocketIC for end-to-end
Performance regression detectioncanbench benchmarks in CI
Deployment config, asset canisterContainerized network tests
Candid interface compatibilitycandid_parser::utils::service_equal in unit tests