开场三问:这玩意儿解决啥问题?为什么要测?你现在卡在哪?

  • 合约为啥必须测?链上代码一旦部署,不能像后端那样随便热修,出错就等于把门锁焊死——所以上线前必须把核心路径测透。
  • ink! 怎么测?它提供本地“离链”测试环境,直接在 Rust 里用 #[ink::test] 就能模拟调用者、余额、事件等,不必连链。
  • 你卡在哪?多半是“不知道从哪搭环境、测试怎么写、事件怎么断言”。下面给你一条“最小可运行”路线。

快速类比:把合约测试当成“搬家前的空房自检”

部署合约就像正式交房入住:水电气、门禁、下水道都要先自检。ink! 的 #[ink::test] 就是物业提供的模拟电表和门禁卡:不用上真网,就能在本地把“开门、关门、扣费、记录事件”一条条走通。

原理速写:五个钉子先钉牢

  • #[ink::test] 在本地跑离链测试,执行你的消息函数(#[ink(message)])并模拟链上上下文。
  • ink::env::test 提供默认账户、设置调用者、读取已记录事件等 API。
  • 测试编译时一般启用 std feature,依赖在 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 包。

失败复现与修复(两例)

  1. 忘记启用 std feature,测试编译报错:在 Cargo.toml 中为依赖与 dev-dependencies 都开启 features = ["std"]
  2. 没设置调用者直接断言余额:用 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 方式。