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.0
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
make test
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
50 changes: 50 additions & 0 deletions rust/unit_testable_rust_canister/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
.PHONY: test

test:
@echo "=== Test 1: get_count returns 0 initially ==="
@result=$$(icp canister call --query backend get_count '(record {})') && \
echo "$$result" && \
echo "$$result" | grep -q 'count = opt (0' && \
echo "PASS" || (echo "FAIL" && exit 1)

@echo "=== Test 2: increment_count returns new_count = 1 ==="
@result=$$(icp canister call backend increment_count '(record {})') && \
echo "$$result" && \
echo "$$result" | grep -q 'new_count = opt (1' && \
echo "PASS" || (echo "FAIL" && exit 1)

@echo "=== Test 3: get_count returns 1 after increment ==="
@result=$$(icp canister call --query backend get_count '(record {})') && \
echo "$$result" && \
echo "$$result" | grep -q 'count = opt (1' && \
echo "PASS" || (echo "FAIL" && exit 1)

@echo "=== Test 4: increment_count again returns new_count = 2 ==="
@result=$$(icp canister call backend increment_count '(record {})') && \
echo "$$result" && \
echo "$$result" | grep -q 'new_count = opt (2' && \
echo "PASS" || (echo "FAIL" && exit 1)

@echo "=== Test 5: decrement_count returns new_count = 1 ==="
@result=$$(icp canister call backend decrement_count '(record {})') && \
echo "$$result" && \
echo "$$result" | grep -q 'new_count = opt (1' && \
echo "PASS" || (echo "FAIL" && exit 1)

@echo "=== Test 6: get_count returns 1 after decrement ==="
@result=$$(icp canister call --query backend get_count '(record {})') && \
echo "$$result" && \
echo "$$result" | grep -q 'count = opt (1' && \
echo "PASS" || (echo "FAIL" && exit 1)

@echo "=== Test 7: get_proposal_info with missing proposal_id returns error ==="
@result=$$(icp canister call backend get_proposal_info '(record { proposal_id = null })') && \
echo "$$result" && \
echo "$$result" | grep -q 'Missing proposal_id' && \
echo "PASS" || (echo "FAIL" && exit 1)

@echo "=== Test 8: get_proposal_titles returns titles list ==="
@result=$$(icp canister call backend get_proposal_titles '(record { limit = null })') && \
echo "$$result" && \
echo "$$result" | grep -q 'titles' && \
echo "PASS" || (echo "FAIL" && exit 1)
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
make test
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
Loading
Loading