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::testexposes default accounts, caller setup, recorded events, etc.- Enable
stdfor tests and useink-as-dependencyindev-dependenciesto link macros/runtime. - Events (
#[ink(event)]) are recorded in tests; assert them viarecorded_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
stdin 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/
