This guide follows our house rules (AuroraProgram.rust.md): keep it runnable, reproducible, and beginner-friendly.

Why test? What to use? Where do you get stuck?

  • You can’t hotfix an on-chain contract. Ship with confidence by testing state updates, events, and failures early.
  • ink! provides an off-chain test environment via #[ink::test], so you can simulate callers, balances, and events without a node.
  • Common blockers: “Where do I start?”, “How to assert events?”, “How to set caller/balances?”. We’ll solve these with a minimal path you can copy-paste.

Quick analogy

Think of testing like a pre-move apartment inspection: water, power, doors, drains — all checked before moving in. #[ink::test] is the mock utility panel and keycard — no chain needed to verify your contract’s behavior.

Key ideas

  • #[ink::test] runs off-chain and executes your #[ink(message)] functions with a simulated context.
  • ink::env::test exposes default accounts, caller setup, recorded events, etc.
  • Enable std for tests and use ink-as-dependency in dev-dependencies to link macros/runtime.
  • Events (#[ink(event)]) are recorded in tests; assert them via recorded_events().
  • e2e (via ink_e2e) comes after unit tests; it spawns a local node and verifies real RPC behavior.

Minimal walkthrough: counter + event + tests

Environment verified on macOS 14.x (ARM), rustc 1.80+ stable, cargo-contract 4.x.

1) Setup

rustup default stable
rustup target add wasm32-unknown-unknown
cargo install cargo-contract --force

cargo contract new ink-testing-demo
cd ink-testing-demo

2) Cargo.toml (commented)

[package]
name = "ink-testing-demo"
version = "0.1.0"
edition = "2021"

[lib]
name = "ink_testing_demo"
path = "lib.rs"
crate-type = ["cdylib", "rlib"]
# cdylib produces the .wasm for chain deployment; rlib is useful for tests/linkage.

[dependencies]
ink = { version = "5", default-features = false, features = ["std"] }
# Use std in tests; on-chain we rely on no_std (see lib.rs header attr).

[dev-dependencies]
# Use ink as a dependency during tests for #[ink::test] linkage
ink = { version = "5", default-features = false, features = ["std", "ink-as-dependency"] }

[features]
default = ["std"]
std = [
    "ink/std",
]

3) lib.rs (with inline comments)

// no_std on-chain, std for local tests
#![cfg_attr(not(feature = "std"), no_std)]

// The macro wires the module into a contract: constructors/messages/events.
#[ink::contract]
mod ink_testing_demo {
    use ink::env::{self, DefaultEnvironment};
    use ink::storage::Mapping;

    #[ink(storage)]
    pub struct Counter {
        value: u32,
        balances: Mapping<AccountId, Balance>, // simple balance book: AccountId -> Balance
    }

    #[derive(scale::Encode, scale::Decode, Debug, PartialEq, Eq)]
    #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
    pub enum Error { InsufficientBalance }
    pub type Result<T> = core::result::Result<T, Error>; // prefer errors over panic!

    #[ink(event)]
    pub struct Incremented {
        #[ink(topic)]
        who: Option<AccountId>, // event indexable field
        by: u32,
    }

    impl Counter {
        #[ink(constructor)]
        pub fn new(init: u32) -> Self {
            Self { value: init, balances: Mapping::default() }
        }

        #[ink(message)]
        pub fn increment(&mut self) {
            self.value = self.value.saturating_add(1); // avoid unsigned overflow
            let caller = self.env().caller();
            self.env().emit_event(Incremented { who: Some(caller), by: 1 });
        }

        #[ink(message)]
        pub fn spend(&mut self, amount: Balance) -> Result<()> {
            let caller = self.env().caller();
            let mut bal = self.balances.get(caller).unwrap_or(0);
            if bal < amount { return Err(Error::InsufficientBalance); }
            bal -= amount;
            self.balances.insert(caller, &bal);
            Ok(())
        }

        #[ink(message)]
        pub fn top_up(&mut self, amount: Balance) {
            let caller = self.env().caller();
            let bal = self.balances.get(caller).unwrap_or(0).saturating_add(amount);
            self.balances.insert(caller, &bal);
        }

        #[ink(message)]
        pub fn get(&self) -> u32 { self.value }
        #[ink(message)]
        pub fn balance_of(&self, who: AccountId) -> Balance { self.balances.get(who).unwrap_or(0) }
    }

    #[cfg(test)]
    mod tests {
        use super::*;
        use ink::env::{self, test, DefaultEnvironment};

        #[ink::test]
        fn counter_increments_and_emits_event() {
            let mut c = Counter::new(10);
            let accounts = test::default_accounts::<DefaultEnvironment>();
            test::set_caller::<DefaultEnvironment>(accounts.alice); // simulate caller
            c.increment();

            assert_eq!(c.get(), 11);
            let events: Vec<test::EmittedEvent> = test::recorded_events().collect();
            assert_eq!(events.len(), 1);
        }

        #[ink::test]
        fn spend_requires_enough_balance() {
            let mut c = Counter::new(0);
            let accounts = test::default_accounts::<DefaultEnvironment>();
            test::set_caller::<DefaultEnvironment>(accounts.bob);

            let err = c.spend(10).unwrap_err();
            assert_eq!(err, Error::InsufficientBalance);

            c.top_up(50);
            assert!(c.spend(10).is_ok());
            assert_eq!(c.balance_of(accounts.bob), 40);
        }
    }
}

What you’ll see: cargo test shows two tests ok, and one recorded event.

4) Run tests and build

cargo test
cargo contract build --release

Troubleshooting

  • Enable std in both dependencies and dev-dependencies for tests.
  • Always set a caller for message calls in tests.
  • Lock ink! version across the team to avoid macro drift.

Optional: tiny e2e template

Add to Cargo.toml:

[dev-dependencies]
ink_e2e = "5"

[features]
e2e = []

Append to lib.rs:

#[cfg(all(test, feature = "e2e"))]
mod e2e {
    use super::*;
    use ink_e2e::build_message;

    type E2EResult<T> = Result<T, Box<dyn std::error::Error>>;

    #[ink_e2e::test]
    async fn increment_works(mut client: ink_e2e::Client<C, E>) -> E2EResult<()> {
        let constructor = crate::ink_testing_demo::CounterRef::new(10);
        let acc_id = client
            .instantiate("ink-testing-demo", &ink_e2e::alice(), constructor, 0, None)
            .await?
            .account_id;

        let call = build_message::<crate::ink_testing_demo::CounterRef>(acc_id.clone())
            .call(|c| c.increment());
        client.call(&ink_e2e::alice(), call, 0, None).await?;

        let get = build_message::<crate::ink_testing_demo::CounterRef>(acc_id.clone())
            .call(|c| c.get());
        let rv = client.call_dry_run(&ink_e2e::alice(), &get, 0, None).await.return_value();
        assert_eq!(rv, 11);
        Ok(())
    }
}

Run:

cargo test --features e2e -- --nocapture

Note: first run may auto-download/spawn substrate-contracts-node. If that fails, install it manually from the official repo or use Docker.

See also

  • Counter intro → /tutorials/smart-contracts/rust-smart-contract-ink-counter/
  • Events and error handling → /tutorials/smart-contracts/ink-events-error-handling/
  • Access control patterns → /tutorials/smart-contracts/rust-smart-contract-access-control/