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.
The testing pyramid
Section titled “The testing pyramid”Effective canister testing uses three layers, from fastest to slowest:
- 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.
- 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.
- 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.
Unit testing in Rust
Section titled “Unit testing in Rust”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.
Structuring canisters for testability
Section titled “Structuring canisters for testability”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) });}Writing unit tests
Section titled “Writing unit tests”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:
cargo test --libFor a complete working example that shows mocking inter-canister calls, stable memory, and async endpoints, see the unit_testable_rust_canister example.
Unit testing in Motoko
Section titled “Unit testing in Motoko”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); });});mops testIntegration testing with PocketIC
Section titled “Integration testing with PocketIC”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.
Rust PocketIC
Section titled “Rust PocketIC”Add pocket-ic to your dev dependencies:
[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:
# Build the WASM firstcargo build --target wasm32-unknown-unknown --release
# Run integration tests (in tests/ directory, not --lib)cargo testFor advanced PocketIC usage: multi-subnet topologies, time travel, NNS subnet setup, and JavaScript/TypeScript testing with Pic JS: see PocketIC.
Performance benchmarking
Section titled “Performance benchmarking”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:
cargo install canbenchAdd an optional dependency to your Cargo.toml:
[dependencies]canbench-rs = { version = "0.1.1", optional = true }Create a canbench.yml pointing to your compiled WASM:
build_cmd: cargo build --release --target wasm32-unknown-unknown --features canbench-rs
wasm_path: ./target/wasm32-unknown-unknown/release/<YOUR_CANISTER>.wasmWriting benchmarks
Section titled “Writing benchmarks”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)); }}Running benchmarks
Section titled “Running benchmarks”canbenchSample 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.
Containerized test networks
Section titled “Containerized test networks”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.
Configure a containerized network
Section titled “Configure a containerized network”Add a Docker-based network to 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]Run tests against the network
Section titled “Run tests against the network”# Start the containerized networkicp network start docker-test
# Deploy to the test environmenticp deploy -e test
# Run your test scripts against http://localhost:8001# ...
# Stop and clean upicp network stop docker-testThe 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:
icp network status docker-test --jsonFor the full containerized network configuration reference: including environment variables, volume mounts, and custom images: see the icp-cli containerized networks guide.
Choosing the right approach
Section titled “Choosing the right approach”| Scenario | Recommended approach |
|---|---|
| Business logic, pure functions | Unit tests (Rust #[test] or Motoko mops test) |
| Upgrade hooks, stable memory encoding | PocketIC integration tests |
| Inter-canister calls | Unit tests (mocked) + PocketIC for end-to-end |
| Performance regression detection | canbench benchmarks in CI |
| Deployment config, asset canister | Containerized network tests |
| Candid interface compatibility | candid_parser::utils::service_equal in unit tests |
Next steps
Section titled “Next steps”- PocketIC: Advanced integration testing: multi-subnet, time travel, Pic JS for TypeScript
- Canister management: lifecycle: Test upgrade paths before deploying
- Canister management: logs: Add observability for debugging test failures