From 2d1cba7bb9299be16abfd497e6d4890744350a94 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Tue, 16 Jun 2026 19:53:22 +0200 Subject: [PATCH 1/5] chore: migrate rust/unit_testable_rust_canister to icp-cli - Replace dfx.json with icp.yaml using @dfinity/rust@v3.3.0 recipe - Move src/hello_canister/ to backend/, rename package to "backend" - Rename hello_canister.did to backend/backend.did - Update lib.rs candid_interface_compatibility test to reference backend.did - Update integration_tests.rs to use backend crate and backend.wasm paths - Add Makefile with test target exercising all public canister endpoints - Add CI workflow with cargo test --lib step plus icp deploy + make test - Delete old dfx-based CI workflow rust-unit-testable-rust-canister-example.yml - Update README with icp-cli deploy instructions, preserve architecture docs Co-Authored-By: Claude Sonnet 4.6 --- ...st-unit-testable-rust-canister-example.yml | 37 ------ .../workflows/unit_testable_rust_canister.yml | 31 +++++ rust/unit_testable_rust_canister/Cargo.lock | 2 +- rust/unit_testable_rust_canister/Cargo.toml | 2 +- rust/unit_testable_rust_canister/Makefile | 50 ++++++++ rust/unit_testable_rust_canister/README.md | 117 ++++++++++-------- .../hello_canister => backend}/Cargo.toml | 2 +- .../backend.did} | 0 .../src/canister_api.rs | 0 .../hello_canister => backend}/src/counter.rs | 0 .../src/governance.rs | 0 .../hello_canister => backend}/src/lib.rs | 2 +- .../src/stable_memory.rs | 0 .../src/types/mod.rs | 0 .../src/types/nns_governance.rs | 0 .../tests/integration_tests.rs | 23 ++-- rust/unit_testable_rust_canister/dfx.json | 16 --- rust/unit_testable_rust_canister/icp.yaml | 9 ++ 18 files changed, 170 insertions(+), 121 deletions(-) delete mode 100644 .github/workflows/rust-unit-testable-rust-canister-example.yml create mode 100644 .github/workflows/unit_testable_rust_canister.yml create mode 100644 rust/unit_testable_rust_canister/Makefile rename rust/unit_testable_rust_canister/{src/hello_canister => backend}/Cargo.toml (94%) rename rust/unit_testable_rust_canister/{src/hello_canister/hello_canister.did => backend/backend.did} (100%) rename rust/unit_testable_rust_canister/{src/hello_canister => backend}/src/canister_api.rs (100%) rename rust/unit_testable_rust_canister/{src/hello_canister => backend}/src/counter.rs (100%) rename rust/unit_testable_rust_canister/{src/hello_canister => backend}/src/governance.rs (100%) rename rust/unit_testable_rust_canister/{src/hello_canister => backend}/src/lib.rs (99%) rename rust/unit_testable_rust_canister/{src/hello_canister => backend}/src/stable_memory.rs (100%) rename rust/unit_testable_rust_canister/{src/hello_canister => backend}/src/types/mod.rs (100%) rename rust/unit_testable_rust_canister/{src/hello_canister => backend}/src/types/nns_governance.rs (100%) rename rust/unit_testable_rust_canister/{src/hello_canister => backend}/tests/integration_tests.rs (96%) delete mode 100644 rust/unit_testable_rust_canister/dfx.json create mode 100644 rust/unit_testable_rust_canister/icp.yaml diff --git a/.github/workflows/rust-unit-testable-rust-canister-example.yml b/.github/workflows/rust-unit-testable-rust-canister-example.yml deleted file mode 100644 index 7006e35336..0000000000 --- a/.github/workflows/rust-unit-testable-rust-canister-example.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: rust-unit-testable-rust-canister -on: - push: - branches: - - master - pull_request: - paths: - - rust/unit_testable_rust_canister/** - - .github/workflows/provision-darwin.sh - - .github/workflows/provision-linux.sh - - .github/workflows/rust-unit-testable-rust-canister-example.yml -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true -jobs: - rust-unit-testable-rust-canister-example-darwin: - runs-on: macos-15 - steps: - - uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1.2.0 - - name: Provision Darwin - run: bash .github/workflows/provision-darwin.sh - - name: Rust Unit Testable Rust Canister Darwin - run: | - pushd rust/unit_testable_rust_canister - cargo test - popd - rustunit-testable-rust-canister-example-linux: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@50fbc622fc4ef5163becd7fab6573eac35f8462e # v1.2.0 - - name: Provision Linux - run: bash .github/workflows/provision-linux.sh - - name: Rust Unit Testable Rust Canister Linux - run: | - pushd rust/unit_testable_rust_canister - cargo test - popd diff --git a/.github/workflows/unit_testable_rust_canister.yml b/.github/workflows/unit_testable_rust_canister.yml new file mode 100644 index 0000000000..d65cfc9254 --- /dev/null +++ b/.github/workflows/unit_testable_rust_canister.yml @@ -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 diff --git a/rust/unit_testable_rust_canister/Cargo.lock b/rust/unit_testable_rust_canister/Cargo.lock index c93da82060..a841484c17 100644 --- a/rust/unit_testable_rust_canister/Cargo.lock +++ b/rust/unit_testable_rust_canister/Cargo.lock @@ -725,7 +725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "hello_canister" +name = "backend" version = "0.1.0" dependencies = [ "async-trait", diff --git a/rust/unit_testable_rust_canister/Cargo.toml b/rust/unit_testable_rust_canister/Cargo.toml index be9bb95246..9d8eb14090 100644 --- a/rust/unit_testable_rust_canister/Cargo.toml +++ b/rust/unit_testable_rust_canister/Cargo.toml @@ -1,6 +1,6 @@ [workspace] members = [ - "src/hello_canister", + "backend", ] resolver = "2" diff --git a/rust/unit_testable_rust_canister/Makefile b/rust/unit_testable_rust_canister/Makefile new file mode 100644 index 0000000000..bb26c719ba --- /dev/null +++ b/rust/unit_testable_rust_canister/Makefile @@ -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) diff --git a/rust/unit_testable_rust_canister/README.md b/rust/unit_testable_rust_canister/README.md index 00faacdf01..910ce537bc 100644 --- a/rust/unit_testable_rust_canister/README.md +++ b/rust/unit_testable_rust_canister/README.md @@ -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 @@ -11,9 +12,8 @@ The canister uses a dependency injection pattern that avoids complex generics th ```rust pub struct CanisterApi { - pub governance: Box, - pub storage: Box, - // other dependencies... + governance: Arc, + counter: Arc, } ``` @@ -42,18 +42,16 @@ fn complex_function(api: &CanisterApi) -> Result { 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 @@ -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)); } ``` @@ -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. @@ -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 @@ -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 -``` \ No newline at end of file +For information on security best practices when developing on ICP, see the [security overview](https://docs.internetcomputer.org/guides/security/overview). diff --git a/rust/unit_testable_rust_canister/src/hello_canister/Cargo.toml b/rust/unit_testable_rust_canister/backend/Cargo.toml similarity index 94% rename from rust/unit_testable_rust_canister/src/hello_canister/Cargo.toml rename to rust/unit_testable_rust_canister/backend/Cargo.toml index 0d295ad278..a7c9a81264 100644 --- a/rust/unit_testable_rust_canister/src/hello_canister/Cargo.toml +++ b/rust/unit_testable_rust_canister/backend/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "hello_canister" +name = "backend" version = "0.1.0" edition = "2021" diff --git a/rust/unit_testable_rust_canister/src/hello_canister/hello_canister.did b/rust/unit_testable_rust_canister/backend/backend.did similarity index 100% rename from rust/unit_testable_rust_canister/src/hello_canister/hello_canister.did rename to rust/unit_testable_rust_canister/backend/backend.did diff --git a/rust/unit_testable_rust_canister/src/hello_canister/src/canister_api.rs b/rust/unit_testable_rust_canister/backend/src/canister_api.rs similarity index 100% rename from rust/unit_testable_rust_canister/src/hello_canister/src/canister_api.rs rename to rust/unit_testable_rust_canister/backend/src/canister_api.rs diff --git a/rust/unit_testable_rust_canister/src/hello_canister/src/counter.rs b/rust/unit_testable_rust_canister/backend/src/counter.rs similarity index 100% rename from rust/unit_testable_rust_canister/src/hello_canister/src/counter.rs rename to rust/unit_testable_rust_canister/backend/src/counter.rs diff --git a/rust/unit_testable_rust_canister/src/hello_canister/src/governance.rs b/rust/unit_testable_rust_canister/backend/src/governance.rs similarity index 100% rename from rust/unit_testable_rust_canister/src/hello_canister/src/governance.rs rename to rust/unit_testable_rust_canister/backend/src/governance.rs diff --git a/rust/unit_testable_rust_canister/src/hello_canister/src/lib.rs b/rust/unit_testable_rust_canister/backend/src/lib.rs similarity index 99% rename from rust/unit_testable_rust_canister/src/hello_canister/src/lib.rs rename to rust/unit_testable_rust_canister/backend/src/lib.rs index b50aadfc2c..6c51b80874 100644 --- a/rust/unit_testable_rust_canister/src/hello_canister/src/lib.rs +++ b/rust/unit_testable_rust_canister/backend/src/lib.rs @@ -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 = diff --git a/rust/unit_testable_rust_canister/src/hello_canister/src/stable_memory.rs b/rust/unit_testable_rust_canister/backend/src/stable_memory.rs similarity index 100% rename from rust/unit_testable_rust_canister/src/hello_canister/src/stable_memory.rs rename to rust/unit_testable_rust_canister/backend/src/stable_memory.rs diff --git a/rust/unit_testable_rust_canister/src/hello_canister/src/types/mod.rs b/rust/unit_testable_rust_canister/backend/src/types/mod.rs similarity index 100% rename from rust/unit_testable_rust_canister/src/hello_canister/src/types/mod.rs rename to rust/unit_testable_rust_canister/backend/src/types/mod.rs diff --git a/rust/unit_testable_rust_canister/src/hello_canister/src/types/nns_governance.rs b/rust/unit_testable_rust_canister/backend/src/types/nns_governance.rs similarity index 100% rename from rust/unit_testable_rust_canister/src/hello_canister/src/types/nns_governance.rs rename to rust/unit_testable_rust_canister/backend/src/types/nns_governance.rs diff --git a/rust/unit_testable_rust_canister/src/hello_canister/tests/integration_tests.rs b/rust/unit_testable_rust_canister/backend/tests/integration_tests.rs similarity index 96% rename from rust/unit_testable_rust_canister/src/hello_canister/tests/integration_tests.rs rename to rust/unit_testable_rust_canister/backend/tests/integration_tests.rs index b1e93b15f4..49acc7d459 100644 --- a/rust/unit_testable_rust_canister/src/hello_canister/tests/integration_tests.rs +++ b/rust/unit_testable_rust_canister/backend/tests/integration_tests.rs @@ -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 @@ -22,7 +22,7 @@ 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 { +fn get_backend_wasm() -> Vec { 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)) @@ -30,8 +30,7 @@ fn get_hello_canister_wasm() -> Vec { /// 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"); @@ -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 {}; @@ -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, @@ -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 }; @@ -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 @@ -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); @@ -384,7 +383,7 @@ fn update Deserialize<'de>>( } } -/// Generic query call helper +/// Generic query call helper fn query Deserialize<'de>>( pic: &PocketIc, canister_id: Principal, diff --git a/rust/unit_testable_rust_canister/dfx.json b/rust/unit_testable_rust_canister/dfx.json deleted file mode 100644 index 3e377c04f0..0000000000 --- a/rust/unit_testable_rust_canister/dfx.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "version": 1, - "canisters": { - "hello_canister": { - "type": "rust", - "package": "hello_canister", - "candid": "src/hello_canister/hello_canister.did" - } - }, - "networks": { - "local": { - "bind": "127.0.0.1:4943", - "type": "ephemeral" - } - } -} diff --git a/rust/unit_testable_rust_canister/icp.yaml b/rust/unit_testable_rust_canister/icp.yaml new file mode 100644 index 0000000000..316aa4e9f2 --- /dev/null +++ b/rust/unit_testable_rust_canister/icp.yaml @@ -0,0 +1,9 @@ +networks: + - name: local + mode: managed +canisters: + - name: backend + recipe: + type: "@dfinity/rust@v3.3.0" + configuration: + package: backend From bf607806a4de976df9ff6cd81ce9c247254a96fa Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Wed, 17 Jun 2026 19:01:18 +0200 Subject: [PATCH 2/5] chore(rust/unit_testable_rust_canister): test.sh over Makefile, bump CI image to 1.0.1 Co-Authored-By: Claude Sonnet 4.6 --- .../workflows/unit_testable_rust_canister.yml | 4 +- rust/unit_testable_rust_canister/Makefile | 50 ------------------- rust/unit_testable_rust_canister/README.md | 2 +- rust/unit_testable_rust_canister/test.sh | 50 +++++++++++++++++++ 4 files changed, 53 insertions(+), 53 deletions(-) delete mode 100644 rust/unit_testable_rust_canister/Makefile create mode 100755 rust/unit_testable_rust_canister/test.sh diff --git a/.github/workflows/unit_testable_rust_canister.yml b/.github/workflows/unit_testable_rust_canister.yml index d65cfc9254..24d01569ce 100644 --- a/.github/workflows/unit_testable_rust_canister.yml +++ b/.github/workflows/unit_testable_rust_canister.yml @@ -15,7 +15,7 @@ concurrency: jobs: rust-unit_testable_rust_canister: runs-on: ubuntu-24.04 - container: ghcr.io/dfinity/icp-dev-env-rust:1.0.0 + container: ghcr.io/dfinity/icp-dev-env-rust:1.0.1 env: ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: @@ -28,4 +28,4 @@ jobs: run: | icp network start -d icp deploy - make test + bash test.sh diff --git a/rust/unit_testable_rust_canister/Makefile b/rust/unit_testable_rust_canister/Makefile deleted file mode 100644 index bb26c719ba..0000000000 --- a/rust/unit_testable_rust_canister/Makefile +++ /dev/null @@ -1,50 +0,0 @@ -.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) diff --git a/rust/unit_testable_rust_canister/README.md b/rust/unit_testable_rust_canister/README.md index 910ce537bc..413a366a2a 100644 --- a/rust/unit_testable_rust_canister/README.md +++ b/rust/unit_testable_rust_canister/README.md @@ -175,7 +175,7 @@ cargo test ```bash icp network start -d icp deploy -make test +bash test.sh icp network stop ``` diff --git a/rust/unit_testable_rust_canister/test.sh b/rust/unit_testable_rust_canister/test.sh new file mode 100755 index 0000000000..a1a939ad3b --- /dev/null +++ b/rust/unit_testable_rust_canister/test.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -e + +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) From 43fa59ac7a6a3431ff7824b9d41401f0c8560753 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 19 Jun 2026 17:18:30 +0200 Subject: [PATCH 3/5] improve(rust/unit_testable_rust_canister): pocket-ic 14.0.0, integration tests in CI - Update pocket-ic from 9.0.2 to 14.0.0 (API is compatible, no code changes needed) - Add PocketIC integration test step to CI using dfinity/pocketic action - README documents how to install PocketIC server and run integration tests locally Co-Authored-By: Claude Sonnet 4.6 --- .../workflows/unit_testable_rust_canister.yml | 9 ++++++- rust/unit_testable_rust_canister/README.md | 27 +++++++++++++++++-- .../backend/Cargo.toml | 2 +- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/workflows/unit_testable_rust_canister.yml b/.github/workflows/unit_testable_rust_canister.yml index 24d01569ce..419ca37b89 100644 --- a/.github/workflows/unit_testable_rust_canister.yml +++ b/.github/workflows/unit_testable_rust_canister.yml @@ -20,9 +20,16 @@ jobs: ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - name: Unit and integration tests + - name: Unit tests working-directory: rust/unit_testable_rust_canister run: cargo test --lib + - name: Install PocketIC server + uses: dfinity/pocketic@07bfae058ce2aa56994759b563531a7e8d98ba96 # main + with: + pocket-ic-server-version: "14.0.0" + - name: PocketIC integration tests + working-directory: rust/unit_testable_rust_canister + run: cargo test --test integration_tests - name: Deploy and test working-directory: rust/unit_testable_rust_canister run: | diff --git a/rust/unit_testable_rust_canister/README.md b/rust/unit_testable_rust_canister/README.md index 413a366a2a..cc566e437e 100644 --- a/rust/unit_testable_rust_canister/README.md +++ b/rust/unit_testable_rust_canister/README.md @@ -160,13 +160,36 @@ git clone https://github.com/dfinity/examples cd examples/rust/unit_testable_rust_canister ``` -### Run unit and integration tests +### Run unit tests ```bash # Fast unit tests (recommended for development) cargo test --lib +``` + +### Run PocketIC integration tests + +Integration tests use PocketIC and require the [PocketIC server](https://github.com/dfinity/pocketic/releases). Install it first: + +```bash +# macOS +curl -sL https://github.com/dfinity/pocketic/releases/download/14.0.0/pocket-ic-x86_64-darwin.gz | gunzip > pocket-ic-server +# Linux +curl -sL https://github.com/dfinity/pocketic/releases/download/14.0.0/pocket-ic-x86_64-linux.gz | gunzip > pocket-ic-server +chmod +x pocket-ic-server +export POCKET_IC_BIN=$(pwd)/pocket-ic-server +``` + +Then: + +```bash +# Build WASM first (required by integration tests) +cargo build --target wasm32-unknown-unknown --release + +# Run integration tests +cargo test --test integration_tests -# All tests including PocketIC integration tests +# Or run everything cargo test ``` diff --git a/rust/unit_testable_rust_canister/backend/Cargo.toml b/rust/unit_testable_rust_canister/backend/Cargo.toml index a7c9a81264..b9cb3ec823 100644 --- a/rust/unit_testable_rust_canister/backend/Cargo.toml +++ b/rust/unit_testable_rust_canister/backend/Cargo.toml @@ -17,7 +17,7 @@ async-trait = "0.1" [dev-dependencies] candid_parser = "0.2.1" -pocket-ic = "9.0.2" +pocket-ic = "14.0.0" candid = "0.10" tokio = { version = "1.0", features = ["macros", "rt"] } walkdir = "2.0" From 5a8cd395f88d96fd593fb3f222342728bf587aca Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 19 Jun 2026 17:30:11 +0200 Subject: [PATCH 4/5] fix(rust/unit_testable_rust_canister): build WASM before PocketIC integration tests The integration tests try to load the WASM at runtime but the in-test rebuild logic fails in CI due to working directory differences. Explicitly build the WASM first (same pattern as guards PR 1396). Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/unit_testable_rust_canister.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/unit_testable_rust_canister.yml b/.github/workflows/unit_testable_rust_canister.yml index 419ca37b89..3ed86a2f57 100644 --- a/.github/workflows/unit_testable_rust_canister.yml +++ b/.github/workflows/unit_testable_rust_canister.yml @@ -27,9 +27,11 @@ jobs: uses: dfinity/pocketic@07bfae058ce2aa56994759b563531a7e8d98ba96 # main with: pocket-ic-server-version: "14.0.0" - - name: PocketIC integration tests + - name: Build WASM and run PocketIC integration tests working-directory: rust/unit_testable_rust_canister - run: cargo test --test integration_tests + run: | + cargo build --package backend --target wasm32-unknown-unknown --release + cargo test --test integration_tests - name: Deploy and test working-directory: rust/unit_testable_rust_canister run: | From fb98cdfce6a25d483c743ec15fda1334e6d1ece5 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Fri, 19 Jun 2026 17:31:12 +0200 Subject: [PATCH 5/5] fix(rust/unit_testable_rust_canister): point to candid_type_generation example, not a branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The candid-type-generation branch no longer exists as such — the content is now the rust/candid_type_generation example in this repo. Co-Authored-By: Claude Sonnet 4.6 --- rust/unit_testable_rust_canister/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/unit_testable_rust_canister/README.md b/rust/unit_testable_rust_canister/README.md index cc566e437e..04459111e0 100644 --- a/rust/unit_testable_rust_canister/README.md +++ b/rust/unit_testable_rust_canister/README.md @@ -133,7 +133,7 @@ backend/ ## Type Generation This example includes generated types for external canisters (NNS Governance). -For automatic type generation from Candid files, see the `candid-type-generation` branch which demonstrates: +For automatic type generation from Candid files, see the [candid_type_generation](../../rust/candid_type_generation) example which demonstrates: - Generating Rust types from `.did` files - Maintaining type compatibility across canister updates