开场三问:这玩意儿解决啥问题?为什么要测?你现在卡在哪?
- 合约为啥必须测?链上代码一旦部署,不能像后端那样随便热修,出错就等于把门锁焊死——所以上线前必须把核心路径测透。
- ink! 怎么测?它提供本地“离链”测试环境,直接在 Rust 里用
#[ink::test]就能模拟调用者、余额、事件等,不必连链。 - 你卡在哪?多半是“不知道从哪搭环境、测试怎么写、事件怎么断言”。下面给你一条“最小可运行”路线。
快速类比:把合约测试当成“搬家前的空房自检”
部署合约就像正式交房入住:水电气、门禁、下水道都要先自检。ink! 的 #[ink::test] 就是物业提供的模拟电表和门禁卡:不用上真网,就能在本地把“开门、关门、扣费、记录事件”一条条走通。
原理速写:五个钉子先钉牢
#[ink::test]在本地跑离链测试,执行你的消息函数(#[ink(message)])并模拟链上上下文。ink::env::test提供默认账户、设置调用者、读取已记录事件等 API。- 测试编译时一般启用
stdfeature,依赖在dev-dependencies中启用ink-as-dependency便于测试链接。 - 事件(
#[ink(event)])在测试里也会被记录,可用recorded_events()断言数量与(可选)解码内容。 - e2e 测试需要起本地链(或沙盒)并用
ink_e2e走完整 RPC 流程,适合在单元测试稳定后再加。
实战步骤:最小可运行的“可增量计数器 + 事件”并写测试
环境(验证机):macOS 14.x (ARM)、rustc 1.80+ stable、cargo-contract 4.x。
1) 初始化工程
rustup default stable
rustup target add wasm32-unknown-unknown
cargo install cargo-contract --force
cargo contract new ink-testing-demo
cd ink-testing-demo
执行完会得到一个带基础结构的 ink! 项目。
2) 完整 Cargo.toml(带必要注释)
[package]
name = "ink-testing-demo"
version = "0.1.0"
edition = "2021"
[lib]
name = "ink_testing_demo"
path = "lib.rs"
crate-type = ["cdylib", "rlib"]
# 说明:链上部署需要生成 `.wasm`(由 cdylib 产出),本地测试/依赖链接用 rlib。
[dependencies]
ink = { version = "5", default-features = false, features = ["std"] }
# 说明:测试阶段通常启用 `std`,便于运行时/日志等能力;发布到链上使用 no_std(见 lib.rs 顶部)。
[dev-dependencies]
# 测试阶段以“依赖”的方式使用 ink 宏/运行时(便于 `#[ink::test]` 链接通过)
ink = { version = "5", default-features = false, features = ["std", "ink-as-dependency"] }
[features]
default = ["std"]
std = [
"ink/std",
]
说明:版本号以当下稳定版本为准;若你的环境 ink! 版本不同,只要保留同等 feature 思路即可跑通测试。
3) 完整 lib.rs:消息 + 事件 + 单元测试(行内注释解释关键点)
// 在链上运行时(no_std)去掉标准库依赖;本地测试启用 `std`。
#![cfg_attr(not(feature = "std"), no_std)]
// ink::contract 宏会把本模块生成为合约:识别 constructor/message/event,并生成必要胶水。
#[ink::contract]
mod ink_testing_demo {
// DefaultEnvironment 提供常见别名(AccountId/Balance/Hash 等),测试里也会用到。
use ink::env::{self, DefaultEnvironment};
use ink::storage::Mapping;
#[ink(storage)]
pub struct Counter {
value: u32,
balances: Mapping<AccountId, Balance>, // 简易余额簿:AccountId -> Balance
}
#[derive(scale::Encode, scale::Decode, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
pub enum Error {
InsufficientBalance,
}
// 统一结果类型,便于消息返回明确错误而不是 panic!
pub type Result<T> = core::result::Result<T, Error>;
#[ink(event)]
pub struct Incremented {
#[ink(topic)] // topic 字段可被索引器/前端检索
who: Option<AccountId>, // 触发者
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) {
// 使用 saturating_add 避免无符号溢出导致的 panic/不确定行为
self.value = self.value.saturating_add(1);
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); // 未充值默认 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() {
// 1) 构造合约(本地内存里)
let mut c = Counter::new(10);
// 2) 使用默认账号集并设置调用者(模拟发起人)
let accounts = test::default_accounts::<DefaultEnvironment>();
test::set_caller::<DefaultEnvironment>(accounts.alice);
// 3) 执行消息函数
c.increment();
// 4) 断言状态变化
assert_eq!(c.get(), 11);
// 5) 断言事件被记录(此处只校验数量,必要时可解码 payload 做更细校验)
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);
// 没有充值前消费会失败(返回自定义错误而非 panic!)
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);
}
}
}
跑完你能看到什么:cargo test 输出中两个测试均为 ok,并且事件计数为 1。
4) 跑测试与构建产物
cargo test
cargo contract build --release
cargo test 在本地离链环境执行,cargo contract build 生成可部署的 .wasm 和 .contract 包。
失败复现与修复(两例)
- 忘记启用
stdfeature,测试编译报错:在Cargo.toml中为依赖与 dev-dependencies 都开启features = ["std"]。 - 没设置调用者直接断言余额:用
ink::env::test::set_caller::<DefaultEnvironment>(...)指定账户,再调用消息。
性能与权衡:为什么先写单元测试,再考虑 e2e?
- 单元测试(离链)启动快、定位精确,适合覆盖核心状态机逻辑与事件。
- e2e(集成)能验证链上实际行为,但依赖节点/沙盒,成本更高、速度更慢。
- 建议先把
#[ink::test]覆盖面做满,回归稳定后,再为关键路径加 e2e。
常见坑清单
- 忘写
#![cfg_attr(not(feature = "std"), no_std)]导致链上加载失败。 - 事件里没加
#[ink(topic)],导致索引器难以检索。 - 测试里不设调用者/环境,断言跟真实行为不一致。
- 版本不一致:团队成员未锁定 ink! 版本,宏生成代码变化带来 CI 失败。
总结与下一步
- 一句话复述:本文帮你用
#[ink::test]在本地把消息、状态、事件跑通并断言。 - 下一步可执行清单:
- 为“转账/授权/权限检查”等路径写更多测试;
- 引入
ink_e2e为关键交互补 e2e; - 结合上一章“事件与错误处理”统一完善可观测性与错误返回(参见下方链接)。
相关阅读:
- 计数器入门篇 → /tutorials/smart-contracts/rust-smart-contract-ink-counter/
- 事件与错误处理 → /tutorials/smart-contracts/ink-events-error-handling/
- 访问控制模式篇 → /tutorials/smart-contracts/rust-smart-contract-access-control/
附:最小 e2e 测试模板(可选)
为什么需要:当单元测试覆盖到位后,e2e 用来验证真实链上调用与 gas/weight、事件、账户权限是否符合预期。
Cargo.toml 额外依赖(dev-dependencies):
# 追加到 [dev-dependencies]
ink_e2e = "5"
# 可选:用 feature 开关启用 e2e,避免默认跑慢测试
[features]
e2e = []
在 lib.rs 中追加一个带条件编译的 e2e 模块:
// 仅在测试且开启 `e2e` 特性时编译
#[cfg(all(test, feature = "e2e"))]
mod e2e {
use super::*;
use ink_e2e::build_message;
type E2EResult<T> = Result<T, Box<dyn std::error::Error>>;
// 该宏会拉起本地 contracts 节点(首次可能自动下载),并提供客户端
#[ink_e2e::test]
async fn increment_works(mut client: ink_e2e::Client<C, E>) -> E2EResult<()> {
// 1) 部署合约(包名来自 Cargo.toml 的 [package].name)
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;
// 2) 调用 increment 消息
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?;
// 3) 只读查询 get(dry run),断言为 11
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(())
}
}
运行:
cargo test --features e2e -- --nocapture
提示:首次运行可能自动下载/拉起 substrate-contracts-node;若失败,可按官方指引手动安装该节点或换用 Docker 方式。
