Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 0 additions & 37 deletions .github/workflows/rust-unit-testable-rust-canister-example.yml

This file was deleted.

31 changes: 31 additions & 0 deletions .github/workflows/unit_testable_rust_canister.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: unit_testable_rust_canister

on:
push:
branches: [master]
pull_request:
paths:
- rust/unit_testable_rust_canister/**
- .github/workflows/unit_testable_rust_canister.yml

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
rust-unit_testable_rust_canister:
runs-on: ubuntu-24.04
container: ghcr.io/dfinity/icp-dev-env-rust:1.0.1
env:
ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Unit and integration tests
working-directory: rust/unit_testable_rust_canister
run: cargo test --lib
- name: Deploy and test
working-directory: rust/unit_testable_rust_canister
run: |
icp network start -d
icp deploy
bash test.sh
2 changes: 1 addition & 1 deletion rust/unit_testable_rust_canister/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion rust/unit_testable_rust_canister/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
members = [
"src/hello_canister",
"backend",
]
resolver = "2"

Expand Down
117 changes: 65 additions & 52 deletions rust/unit_testable_rust_canister/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Unit Testable Rust Canister

This repository demonstrates how to structure a Rust canister for comprehensive unit testing by isolating
non-deterministic dependencies behind interfaces.
This example demonstrates how to structure a Rust canister for comprehensive unit testing by isolating
non-deterministic dependencies behind interfaces. It uses dependency injection so that inter-canister
calls and stable memory operations can all be mocked in fast pure-Rust unit tests.

## Architecture

Expand All @@ -11,9 +12,8 @@ The canister uses a dependency injection pattern that avoids complex generics th

```rust
pub struct CanisterApi {
pub governance: Box<dyn GovernanceApiTrait>,
pub storage: Box<dyn StorageApiTrait>,
// other dependencies...
governance: Arc<dyn GovernanceApi>,
counter: Arc<dyn Counter>,
}
```

Expand Down Expand Up @@ -42,18 +42,16 @@ fn complex_function(api: &CanisterApi) -> Result<T, E> {

Non-deterministic operations are abstracted behind traits:

- **Inter-canister calls** → `GovernanceApiTrait`
- **Stable memory operations** → `StorageApiTrait`
- **Time-based operations** → `TimeApiTrait`
- **Inter-canister calls** → `GovernanceApi`
- **Stable memory operations** → `Counter` (backed by `StableMemoryCounter`)

**Benefit**: The entire dependency tree can be mocked, allowing you to test all canister logic in pure Rust unit tests
without any IC integration.

Technically Stable Memory can be fully test in Rust, but in cases where more complex logic is needed to update the
contents
of stable memory in a way that works for tests, you can simplify your testing by putting it behind an interface that
abstracts away the actual storage implementation. This makes it easier to evolve your storage layer without
needing to update tests.
Technically stable memory can be fully tested in Rust, but in cases where more complex logic is needed to update the
contents of stable memory in a way that works for tests, you can simplify your testing by putting it behind an
interface that abstracts away the actual storage implementation. This makes it easier to evolve your storage layer
without needing to update tests.

## Testing Strategy

Expand All @@ -63,32 +61,32 @@ Unit tests run in milliseconds and can test complex business logic by mocking al

```rust
#[test]
fn test_complex_governance_logic() {
let mut mock_governance = MockGovernanceApi::new();
mock_governance.expect_get_proposal_info()
.returning(|_| Ok(mock_proposal()));
fn test_counter_endpoints() {
let governance = Arc::new(MockGovernanceApi::new());
let counter = Arc::new(TestCounter::new());
let api = CanisterApi::new(governance, counter);

let api = CanisterApi::new_with_mocks(mock_governance, /* other mocks */);
let response = api.get_count();
assert_eq!(response.count, Some(0));

// Test complex logic without any IC integration
let result = complex_function(&api);
assert_eq!(result, expected_result);
let response = api.increment_count();
assert_eq!(response.new_count, Some(1));
}
```

### Integration Tests (Slower, End-to-End)

Integration tests use PocketIC to verify the complete system works together:
Integration tests use PocketIC to verify the complete system works together, including actual
inter-canister calls to a locally deployed NNS Governance canister:

```rust
#[test]
fn test_end_to_end_workflow() {
let pic = PocketIc::new();
let canister_id = deploy_canister(&pic);
fn test_counter_functionality() {
let pic = PocketIcBuilder::new().with_nns_subnet().build();
let canister_id = deploy_backend_canister(&pic);

// Test actual inter-canister calls
let response = pic.update_call(canister_id, "method", args);
// assertions...
let response: GetCountResponse = query(&pic, canister_id, "get_count", encode_one(GetCountRequest {}).unwrap());
assert_eq!(response.count, Some(0));
}
```

Expand All @@ -102,7 +100,7 @@ verify system integration.

## Keeping Up With Mainnet Canister Changes

Additionally, in the PocketIC tests, we rely on setting up Governance proposals via init arguments. That capability
In the PocketIC integration tests, we rely on setting up Governance proposals via init arguments. That capability
could be removed in the future, as it's not part of the stable interface of the canister. In that case, mocking out
canisters would become harder, as you would need to also create a ledger and neurons and proposals. This setup can
be error-prone, and would need to be kept in sync with mainnet.
Expand All @@ -116,17 +114,20 @@ minimal testing.
## Project Structure

```
src/
├── lib.rs # Canister entry points and initialization
├── canister_api.rs # Main API struct and dependency injection
├── counter.rs # Counter trait and implementation (abstraction over storage)
├── governance.rs # NNS Governance trait and implementations
├── stable_memory.rs # Storage operations and trait definitions
├── types/
│ ├── mod.rs # Request/response types and external canister types
│ └── nns_governance.rs # NNS Governance canister type definitions, generated from governance candid.
└── tests/
└── integration_tests.rs # Slower end-to-end tests
backend/
├── Cargo.toml
├── backend.did # Candid interface
└── src/
├── lib.rs # Canister entry points and initialization
├── canister_api.rs # Main API struct and dependency injection
├── counter.rs # Counter trait and implementation (abstraction over storage)
├── governance.rs # NNS Governance trait and implementations
├── stable_memory.rs # Storage operations and trait definitions
└── types/
├── mod.rs # Request/response types
└── nns_governance.rs # NNS Governance canister type definitions
tests/
└── integration_tests.rs # Slower end-to-end tests using PocketIC
```

## Type Generation
Expand All @@ -146,26 +147,38 @@ For automatic type generation from Candid files, see the `candid-type-generation
4. **Easy Debugging**: Unit tests can isolate specific scenarios without IC complexity
5. **Maintainable Code**: Clear separation between business logic and IC integration

## Running Tests
## Build and deploy from the command line

### Prerequisites
- [icp-cli](https://cli.internetcomputer.org): `npm install -g @icp-sdk/icp-cli @icp-sdk/ic-wasm`
- Rust toolchain with `wasm32-unknown-unknown` target

### Install

```bash
git clone https://github.com/dfinity/examples
cd examples/rust/unit_testable_rust_canister
```

### Run unit and integration tests

```bash
# Fast unit tests (recommended for development)
cargo test --lib

# All tests (including integration tests)
# All tests including PocketIC integration tests
cargo test
```

The unit tests demonstrate testing the same functionality as integration tests but with significantly better performance
and easier setup.

## Deployment

To deploy locally, but without NNS governance canister.
### Deploy and test

```bash
dfx start --background
dfx create canister hello_canister
dfx deploy
icp network start -d
icp deploy
bash test.sh
icp network stop
```

## Security considerations and best practices

```
For information on security best practices when developing on ICP, see the [security overview](https://docs.internetcomputer.org/guides/security/overview).
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[package]
name = "hello_canister"
name = "backend"
version = "0.1.0"
edition = "2021"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ mod tests {
// Get the directory where this crate's Cargo.toml is located
let manifest_dir = env::var("CARGO_MANIFEST_DIR")
.expect("CARGO_MANIFEST_DIR environment variable not set");
let candid_file_path = PathBuf::from(&manifest_dir).join("hello_canister.did");
let candid_file_path = PathBuf::from(&manifest_dir).join("backend.did");

// Read the declared interface from the .did file
let declared_interface_str =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ use std::time::SystemTime;
use walkdir::WalkDir;

// Import all request/response types from the library
use hello_canister::types::nns_governance::{
use backend::types::nns_governance::{
Followees, Governance, NetworkEconomics, NeuronId, Proposal, ProposalData, ProposalId,
};
use hello_canister::types::*;
use backend::types::*;

// IC commit used for downloading official NNS WASM files
// This should match what's currently deployed in production
Expand All @@ -22,16 +22,15 @@ const NNS_GOVERNANCE_CANISTER_ID: &str = "rrkah-fqaaa-aaaaa-aaaaq-cai";
const NNS_ROOT_CANISTER_ID: &str = "r7inp-6aaaa-aaaaa-aaabq-cai";

// WASM will be loaded dynamically with smart rebuilding
fn get_hello_canister_wasm() -> Vec<u8> {
fn get_backend_wasm() -> Vec<u8> {
let wasm_path = ensure_wasm_built();
std::fs::read(&wasm_path)
.unwrap_or_else(|e| panic!("Failed to read WASM file at {:?}: {}", wasm_path, e))
}

/// Ensures the WASM is built and up-to-date, returns path to the WASM file
fn ensure_wasm_built() -> PathBuf {
let wasm_path =
PathBuf::from("../../target/wasm32-unknown-unknown/release/hello_canister.wasm");
let wasm_path = PathBuf::from("../../target/wasm32-unknown-unknown/release/backend.wasm");
let src_dir = Path::new("src");
let cargo_toml = Path::new("Cargo.toml");

Expand Down Expand Up @@ -203,7 +202,7 @@ fn setup_nns_governance(pic: &PocketIc) -> Principal {
#[test]
fn test_counter_functionality() {
let pic = setup_pocket_ic();
let canister_id = deploy_hello_canister(&pic);
let canister_id = deploy_backend_canister(&pic);

// Initial counter should be 0
let request = GetCountRequest {};
Expand Down Expand Up @@ -243,7 +242,7 @@ fn test_counter_functionality() {
query(&pic, canister_id, "get_count", encode_one(request).unwrap());
assert_eq!(response.count, Some(2));

// Increment counter
// Decrement counter
let request = IncrementCountRequest {};
let response: IncrementCountResponse = update(
&pic,
Expand All @@ -264,7 +263,7 @@ fn test_get_proposal_titles() {
let pic = setup_pocket_ic();
setup_nns_governance(&pic);

let canister_id = deploy_hello_canister(&pic);
let canister_id = deploy_backend_canister(&pic);

// Test listing proposals (should return mock data)
let request = GetProposalTitlesRequest { limit: None };
Expand Down Expand Up @@ -310,7 +309,7 @@ fn test_get_proposal_titles() {
#[test]
fn test_get_proposal_info() {
let pic = setup_pocket_ic();
let canister_id = deploy_hello_canister(&pic);
let canister_id = deploy_backend_canister(&pic);
setup_nns_governance(&pic);

// Test with a proposal ID
Expand Down Expand Up @@ -352,12 +351,12 @@ fn setup_pocket_ic() -> PocketIc {
.build()
}

fn deploy_hello_canister(pic: &PocketIc) -> Principal {
fn deploy_backend_canister(pic: &PocketIc) -> Principal {
let canister_id = pic.create_canister();
pic.add_cycles(canister_id, 2_000_000_000_000);

// Use smart WASM rebuilding - will only rebuild if source files changed
let wasm_binary = get_hello_canister_wasm();
let wasm_binary = get_backend_wasm();

pic.install_canister(canister_id, wasm_binary, vec![], None);

Expand All @@ -384,7 +383,7 @@ fn update<T: CandidType + for<'de> Deserialize<'de>>(
}
}

/// Generic query call helper
/// Generic query call helper
fn query<T: CandidType + for<'de> Deserialize<'de>>(
pic: &PocketIc,
canister_id: Principal,
Expand Down
Loading
Loading