Introduction

ic-test is a command-line tool that helps to set up and manage Rust canister tests on the Internet Computer (IC). The tool makes it easier to create a test project and includes the basic files and setup needed for both IC canisters and optionally EVM (Ethereum Virtual Machine) smart contracts.

The tool reads the dfx.json (must exist) and the foundry.toml (may exist) files in order to build the test environment automatically. It uses pocket-ic and alloy (foundry) to run tests. The generated code and helpers provide:

  • A simple way to start a test project.
  • A single, easy-to-use interface for testing IC Canisters and EVM smart contracts.
  • Type checking and auto-completion support.
  • Easy functions for deploying and calling canisters or contracts.

Overview

ic-test will:

  • Read dfx.json to get canister details.
  • Read foundry.toml to get contract details.
  • Generate Rust types from Candid (.did) files.
  • Generate contract interfaces from Solidity (.sol) files.
  • Provide API to work with .wasm canisters and .json contract files in tests.

Prerequisites

  • Rust
  • DFX – to build and locally deploy canisters.
  • Foundry – optional, if you want to test EVM contract's interaction with canisters.

Installation

Install the ic-test CLI tool:

cargo install ic-test

Command line options

Tool usage

ic-test <COMMAND> [OPTIONS]

Without arguments it starts in interactive mode to create a new test project. If an ic-test.json config file exists already, the update mode will regenerate the existing test project bindings.

Create a new test project

ic-test new tests
  • Creates a new test project in the tests folder.
  • Looks for canisters and contracts, generates API bindings and a sample test.
  • Generates an ic-test.json configuration file.
  • Fails if the tests folder already exists, the user would need to choose a different name.

Update/regenerate an existing test project

ic-test update

Regenerates bindings using the configuration in ic-test.json.

Tutorials

Here are some tutorials demonstrating how to create the test projects with the ic-test tool.

"Counter" Tutorial

Let's create a simple project and test it using the ic-test.

Create a "Hello, World!" canister:

dfx new hello-ic-test --type rust --no-frontend

Compile the project:

dfx start --clean --background

dfx canister create --all

dfx build

Generate test bindings

If there are uncommitted changes, either commit them before generating or use the --force flag:

ic-test new tests --force

This creates a tests package with:

  • Canister API bindings in tests/src/bindings
  • Test environment setup logic in test_setup.rs
  • A test template in tests.rs

Example test

Edit tests.rs:

use ic_test::IcpTest;

use crate::test_setup;

#[tokio::test]
async fn test_greet() {
    let test_setup::Env {
        icp_test,
        hello_ic_test_backend,
    } = test_setup::setup(IcpTest::new().await).await;

    let result = hello_ic_test_backend
        .greet("ic-test".to_string())
        .call()
        .await;

    assert_eq!(result, "Hello, ic-test!");
}

Run tests:

cargo test

Adding a counter

Update the canister backend:

#![allow(unused)]
fn main() {
use std::cell::RefCell;

#[ic_cdk::query]
fn greet(name: String) -> String {
    format!("Hello, {}!", name)
}

#[derive(Clone, Default)]
struct CounterState {
    value: u64,
    increment: u64,
}

thread_local! {
    static STATE: RefCell<CounterState> = RefCell::new(CounterState::default());
}

#[ic_cdk::init]
fn init(init_value: u64, increment: u64) {
    STATE.with(|state| {
        *state.borrow_mut() = CounterState {
            value: init_value,
            increment,
        };
    });
}

#[ic_cdk::update]
fn increment_counter() {
    STATE.with(|state| {
        let mut s = state.borrow_mut();
        s.value += s.increment;
    });
}

#[ic_cdk::query]
fn get_counter() -> u64 {
    STATE.with(|state| state.borrow().value)
}
}

Update Candid file hello-ic-test-backend.did:

service : (nat64, nat64) -> {
  "greet": (text) -> (text) query;
  "get_counter": () -> (nat64) query;
  "increment_counter": () -> ();
}

Set initialization arguments in dfx.json:

{
  "canisters": {
    "hello-ic-test-backend": {
      "candid": "src/hello-ic-test-backend/hello-ic-test-backend.did",
      "package": "hello-ic-test-backend",
      "type": "rust",
      "init_arg": "(50, 73)"
    }
  },
  "defaults": {
    "build": {
      "args": "",
      "packtool": ""
    }
  },
  "output_env_file": ".env",
  "version": 1
}

Regenerate the bindings:

dfx build

ic-test

The ic-test will enter interactive mode and prompt user to allow overwriting the test_setup.rs file. Upon confirmation the the test_setup.rs is regenerated with the initialization parameters:

//...
    let hello_ic_test_backend = hello_ic_test_backend::deploy(&icp_user, 50, 73)
        .call()
        .await;

// ...

New test

Add a new test in tests.rs:

#![allow(unused)]
fn main() {
#[tokio::test]
async fn test_counter() {
    let test_setup::Env {
        icp_test,
        hello_ic_test_backend,
    } = test_setup::setup(IcpTest::new().await).await;

    let result = hello_ic_test_backend.get_counter().call().await;

    assert_eq!(result, 50u64);

    hello_ic_test_backend.increment_counter().call().await;

    let result = hello_ic_test_backend.get_counter().call().await;

    assert_eq!(result, 123u64); // 50 + 73
}
}

Run tests:

cargo test

EVM RPC Integration

Initial preparations

In this examples we'll explore testing a canister that uses integration with the Ethereum smart contracts and will see how one can create canister, EVM or the hybrid tests.

For a quick start, clone the ic-test-examples repository and enter the eth-balance project:

git clone https://github.com/wasm-forge/ic-test-examples.git
cd ic-test-examples/eth-balance

In the cloned examples repository enter the project eth-balance. It is a basic implementation of a canister that connects to an EVM-RPC service and requests for a current Eth balance on any Ethereum address. You can try deploy it and see that the canister actually works ( and start dfx if it not running already):

dfx start --background --clean
dfx deploy

Now, call the canister to request balance of some Eth account:

dfx canister call eth-balance-backend get_eth_balance '("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")'

This project already has the ic-test integration. You can make sure the test project is working by launching tests, but in order to deploy smart contracts that are part of the tests, you need to build them:

cd evm
forge build
cd ..

cargo test

If the project compiles and tests are green, you have successfully tested canister communication with the local Anvil node containing custom smart contracts.

Project structure

eth-balance
├── Cargo.lock
├── Cargo.toml
├── dfx.json                 # `dfx` contiguration.
├── evm
│   ├── foundry.toml         # The toml file used by ic-test to gather existing smart contracts.
│   ├── lib                  # Forge standard library.
│   │   └── ...
│   ├── out                  # Compiled smart contracts.
│   │   └── ...
│   ├── script             
│   │   ├── Counter.s.sol    # Smart contract installation scripts.
│   │   └── Sender.s.sol
│   ├── src                  # Smart contract source files.
│   │   ├── Counter.sol
│   │   └── Sender.sol
│   ├── test_counter.sh      # example shell scripts to test smart contracts from the command line using `anvil` and `cast`.
│   └── test_sender.sh
├── ic-test.json             # ic-test configuration what can be used to regenerate the test project.
├── README.md                # 
├── src                      
│   └── eth-balance-backend  # eth-balance canister backend source.
│       ├── Cargo.toml
│       ├── eth-balance-backend.did  # The candid file used to generate canister bindings.
│       └── src
│           └── lib.rs
└── tests                    # Test project created by the `ic-test`.
    ├── Cargo.toml           
    └── src
        ├── bindings         # Canister and smart contract bindings generated by the `ic-test`
        │   ├── eth_balance_backend.rs
        │   ├── evm_rpc.rs
        │   └── mod.rs
        ├── lib.rs           
        ├── test_setup.rs    # Test preparation.
        └── tests.rs         # Actual tests that will be run on `cargo test`.

The evm folder contains an EVM Forge project that was created with forge init evm. You can find there the initial contract Counter.sol. It is a "counter" example that can initialize its counter value and increment it. The second contract is the Sender.sol, it can transfer its Ethereum to any given address.

Sender contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract Sender {
    address public owner;

    constructor() {
        owner = msg.sender; // Set the contract deployer as the owner
    }

    // Function to receive Ether. Required for the contract to accept ETH.
    receive() external payable {}

    // Send Ether from the contract to a recipient, the function is payable so that we can send the money along the call
    function sendEther(address payable receiver, uint256 eth) external payable {
        require(msg.sender == owner, "Only owner can send Ether");
        require(address(this).balance >= eth, "Insufficient balance");

        (bool sent, ) = receiver.call{value: eth}("");
        require(sent, "Failed to send Ether");
    }

    // Check contract balance
    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

Creating a new test project

In this tutorial we want to create our own test project using the ic-test tool. We can trick the ic-test tool to think that there is no test project by deleting or renaming the ic-test.json:

rm ic-test.json

Now, to create the test project named my-tests, enter:

ic-test new my-tests

This will create a new test project and add it to the workspace:

my-tests
├── Cargo.toml
└── src
    ├── bindings
    │   ├── eth_balance_backend.rs
    │   ├── evm_rpc.rs
    │   └── mod.rs
    ├── lib.rs
    ├── test_setup.rs
    └── tests.rs

You can check the ic-test.json to see which canisters and contracts were found and their respective file locations:

{
  "test_folder": "my-tests",
  "icp_setup": {
    "dfx_json": "dfx.json",
    "skip_dfx_json": false,
    "canisters": {
      "eth-balance-backend": {
        "name": "eth-balance-backend",
        "var_name": "eth_balance_backend",
        "service_name": "EthBalanceBackendCanister",
        "candid_path": "src/eth-balance-backend/eth-balance-backend.did",
        "generate_bindings": true,
        "wasm": "target/wasm32-unknown-unknown/release/eth_balance_backend.wasm",
        "specified_id": null
      },
      "evm_rpc": {
        "name": "evm_rpc",
        "var_name": "evm_rpc",
        "service_name": "EvmRpcCanister",
        "candid_path": ".dfx/local/canisters/evm_rpc/constructor.did",
        "init_arg": "(record {})",
        "generate_bindings": true,
        "wasm": ".dfx/local/canisters/evm_rpc/evm_rpc.wasm.gz",
        "specified_id": "7hfb6-caaaa-aaaar-qadga-cai"
      }
    }
  },
  "evm_setup": {
    "foundry_toml_path": "./evm",
    "skip_foundry_toml": false,
    "foundry_src": "src",
    "foundry_out": "out",
    "contracts": {
      "Counter": {
        "name": "Counter",
        "var_name": "counter",
        "sol_json": "./evm/out/Counter.sol/Counter.json"
      },
      "Sender": {
        "name": "Sender",
        "var_name": "sender",
        "sol_json": "./evm/out/Sender.sol/Sender.json"
      }
    }
  }
}

Edit the src/tests.rs file, and add a new test:

#![allow(unused)]
fn main() {
//...
#[tokio::test]
async fn test_eth_transfer() {
    let env = test_setup::setup(IcpTest::new().await).await;

    let address1 = env.evm_user.address;
    let destination_address = env.icp_test.evm.test_user(1).address;

    let result = env
        .eth_balance_backend
        .get_eth_balance(address1.to_string())
        .call()
        .await;

    let eth = parse_ether(&result).unwrap();

    // assert the main user still has around 10000 Ether after deploying contracts
    assert!(parse_ether("10000").unwrap() - eth < parse_ether("0.01").unwrap());

    let result = env
        .eth_balance_backend
        .get_eth_balance(destination_address.to_string())
        .call()
        .await;

    // assert the second user has exactly 10000 Eth (the initial test value)
    assert_eq!(result, "10000");

    // prepare payment to send via the Sender contract
    let payment = parse_ether("100.01").unwrap();

    // The amount we want to send
    let amount_to_send = parse_ether("100.0").unwrap();

    // call Sender.sendEther
    let receipt = env
        .sender
        .sendEther(destination_address, amount_to_send)
        .value(payment)
        .send()
        .await
        .unwrap()
        .get_receipt()
        .await
        .unwrap();

    assert!(receipt.status());

    let result = env
        .eth_balance_backend
        .get_eth_balance(destination_address.to_string())
        .call()
        .await;

    // assert the second user has now 10100 Eth
    assert_eq!(result, "10100");
}

}

In this test you are using the eth_balance_backend canister to read Ether value from the first and second Anvil users (their initial wallets contain 10000.0 Ethers, but the first user has slightly less because some amount was spent to deploy the Sender and Counter contracts).

Then 100 Ethers are transfered from the first user to the second user with the Sender smart contract. The final value of the second account is expected to be 10100 Ethers:

#![allow(unused)]
fn main() {
assert_eq!(result, "10100");
}

You can change the last assertion to see that the test fails if the value is wrong.

Note: the sendEther contract is executed via send command because it executes a transaction and changes the state of the network, this also costs a bit of Ethereum, hence the value() call adds the amount to send and a little extra Ethereum for the contract to run.