原文作者:PaperMoon团队
在 Polkadot SDK 中,单元测试(Unit Testing)是保障 Pallet 功能正确性和稳定性的核心手段。通过编写系统化的测试用例,可以验证 Pallet 中的函数是否按照预期运行,并确保相关的存储数据与事件在交互过程中被正确处理。
在本指南中,你将学习如何:
• 合理组织测试模块结构
• 测试可调度函数(Dispatchable Functions)
• 验证存储状态变更
• 校验事件是否正确触发
• 测试错误处理逻辑
• 在测试中使用创世配置(Genesis Configuration)
这些内容将帮助你建立完整的 Pallet 测试体系。
理解 FRAME 测试工具
FRAME(Framework for Runtime Aggregation of Modularized Entities)为 Pallet 测试提供了一套专业的宏与工具,极大提升了测试效率和可读性。
1. 常用断言宏(Assertion Macros)
FRAME 提供了多个高频使用的测试宏:
• assert_ok!:断言调度调用执行成功
• assert_noop!:断言调用失败且不产生状态变更
• assert_eq!:Rust 标准相等性断言
2. assert_noop! 说明
assert_noop! 用于验证:
• 调用失败
• 且不会修改任何链上状态
该宏在测试错误分支时尤为重要,可确保系统在异常情况下保持一致性。
System Pallet 测试辅助工具
frame_system Pallet 提供了多个测试辅助接口:
• System::events():获取当前测试中产生的全部事件
• System::assert_last_event():断言最后一个事件
• System::set_block_number():设置当前区块高度
事件与区块号说明
默认情况下,在区块高度为 0(创世区块)时不会触发事件。
如果测试涉及事件校验,必须先设置:
cd pallets/pallet-custom/src
创建文件:
touch tests.rs
- 在 lib.rs 中引入测试模块
在 src/lib.rs 中添加:
#[cfg(test)]
mod mock;
#[cfg(test)]
mod tests;
完整示例:
#![cfg_attr(not(feature = "std"), no_std)]
pub use pallet::*;
#[cfg(test)]
mod mock;
#[cfg(test)]
mod tests;
#[frame::pallet]
pub mod pallet {
// ...
}
配置测试模块结构
在 src/tests.rs 中添加基础结构:
use crate::{mock::*, Error, Event};
use frame::deps::frame_support::{assert_noop, assert_ok};
use frame::deps::sp_runtime::DispatchError;
该配置主要用于:
• 引入 Mock Runtime
• 引入错误类型与事件类型
• 使用 FRAME 测试宏
• 测试权限错误(DispatchError)
完整 Pallet 代码参考
(以下代码为测试对象的完整实现,用于后续测试验证)
#![cfg_attr(not(feature = "std"), no_std)]
extern crate alloc;
pub use pallet::*;
#[frame::pallet]
pub mod pallet {
use alloc::vec::Vec;
use frame::prelude::*;
#[pallet::pallet]
pub struct Pallet<T>(_);
#[pallet::config]
pub trait Config: frame_system::Config {
type RuntimeEvent: From<Event<Self>> + IsType<<Self as frame_system::Config>::RuntimeEvent>;
#[pallet::constant]
type CounterMaxValue: Get<u32>;
}
#[pallet::event]
#[pallet::generate_deposit(pub(super) fn deposit_event)]
pub enum Event<T: Config> {
CounterValueSet {
new_value: u32,
},
CounterIncremented {
new_value: u32,
who: T::AccountId,
amount: u32,
},
CounterDecremented {
new_value: u32,
who: T::AccountId,
amount: u32,
},
}
#[pallet::error]
pub enum Error<T> {
NoneValue,
Overflow,
Underflow,
CounterMaxValueExceeded,
}
#[pallet::storage]
pub type CounterValue<T> = StorageValue<_, u32, ValueQuery>;
#[pallet::storage]
pub type UserInteractions<T: Config> = StorageMap<
_,
Blake2_128Concat,
T::AccountId,
u32,
ValueQuery
>;
#[pallet::genesis_config]
#[derive(DefaultNoBound)]
pub struct GenesisConfig<T: Config> {
pub initial_counter_value: u32,
pub initial_user_interactions: Vec<(T::AccountId, u32)>,
}
#[pallet::genesis_build]
impl<T: Config> BuildGenesisConfig for GenesisConfig<T> {
fn build(&self) {
CounterValue::<T>::put(self.initial_counter_value);
for (account, count) in &self.initial_user_interactions {
UserInteractions::<T>::insert(account, count);
}
}
}
#[pallet::call]
impl<T: Config> Pallet<T> {
#[pallet::call_index(0)]
#[pallet::weight(0)]
pub fn set_counter_value(origin: OriginFor<T>, new_value: u32) -> DispatchResult {
ensure_root(origin)?;
ensure!(new_value <= T::CounterMaxValue::get(), Error::<T>::CounterMaxValueExceeded);
CounterValue::<T>::put(new_value);
Self::deposit_event(Event::CounterValueSet { new_value });
Ok(())
}
#[pallet::call_index(1)]
#[pallet::weight(0)]
pub fn increment(origin: OriginFor<T>, amount: u32) -> DispatchResult {
let who = ensure_signed(origin)?;
let current_value = CounterValue::<T>::get();
let new_value = current_value.checked_add(amount).ok_or(Error::<T>::Overflow)?;
ensure!(new_value <= T::CounterMaxValue::get(), Error::<T>::CounterMaxValueExceeded);
CounterValue::<T>::put(new_value);
UserInteractions::<T>::mutate(&who, |count| {
*count = count.saturating_add(1);
});
Self::deposit_event(Event::CounterIncremented { new_value, who, amount });
Ok(())
}
#[pallet::call_index(2)]
#[pallet::weight(0)]
pub fn decrement(origin: OriginFor<T>, amount: u32) -> DispatchResult {
let who = ensure_signed(origin)?;
let current_value = CounterValue::<T>::get();
let new_value = current_value.checked_sub(amount).ok_or(Error::<T>::Underflow)?;
CounterValue::<T>::put(new_value);
UserInteractions::<T>::mutate(&who, |count| {
*count = count.saturating_add(1);
});
Self::deposit_event(Event::CounterDecremented { new_value, who, amount });
Ok(())
}
}
}
编写第一个测试
下面我们从最基础的功能测试开始。
测试 Increment 基础功能
目标:验证 increment 函数是否正确更新状态并触发事件。
#[test]
fn increment_works() {
new_test_ext().execute_with(|| {
System::set_block_number(1);
let account = 1u64;
assert_ok!(CustomPallet::increment(RuntimeOrigin::signed(account), 50));
assert_eq!(crate::CounterValue::<Test>::get(), 50);
System::assert_last_event(
Event::CounterIncremented {
new_value: 50,
who: account,
amount: 50,
}
.into(),
);
assert_eq!(crate::UserInteractions::<Test>::get(account), 1);
});
}
运行测试:
cargo test --package pallet-custom increment_works
输出结果:
test tests::increment_works ... ok
说明测试通过。
测试错误场景
错误处理是区块链系统安全性的重要保障,必须重点测试。
-
溢出保护测试(Overflow)
#[test]
fn increment_fails_on_overflow() {
new_test_ext_with_counter(u32::MAX).execute_with(|| {
assert_noop!(
CustomPallet::increment(RuntimeOrigin::signed(1), 1),
Error::<Test>::Overflow
);
});
}
验证数值溢出时系统能正确拒绝。
-
下溢保护测试(Underflow)
#[test]
fn decrement_fails_on_underflow() {
new_test_ext_with_counter(10).execute_with(|| {
assert_noop!(
CustomPallet::decrement(RuntimeOrigin::signed(1), 11),
Error::<Test>::Underflow
);
});
}
用于防止负数状态产生。
测试访问控制
验证 Root 权限控制逻辑是否生效。
rust
#[test]
fn set_counter_value_requires_root() {
new_test_ext().execute_with(|| {
let alice = 1u64;
assert_noop!(
CustomPallet::set_counter_value(RuntimeOrigin::signed(alice), 100),
DispatchError::BadOrigin
);
assert_ok!(CustomPallet::set_counter_value(RuntimeOrigin::root(), 100));
assert_eq!(crate::CounterValue::<Test>::get(), 100);
});
}
该测试确保:
• 普通用户无法调用
• Root 用户可正常调用
测试事件触发
事件是区块链系统对外的重要信息接口,必须确保其准确性。
示例:
rust
#[test]
fn set_counter_value_works() {
new_test_ext().execute_with(|| {
System::set_block_number(1);
assert_ok!(CustomPallet::set_counter_value(RuntimeOrigin::root(), 100));
assert_eq!(crate::CounterValue::<Test>::get(), 100);
System::assert_last_event(Event::CounterValueSet { new_value: 100 }.into());
});
}
测试要点:
-
设置区块高度
-
执行调用
-
校验最后事件
测试创世配置
验证 Genesis 配置是否正确初始化状态。
rust
#[test]
fn genesis_config_works() {
new_test_ext_with_interactions(42, vec![(1, 5), (2, 10)]).execute_with(|| {
assert_eq!(crate::CounterValue::<Test>::get(), 42);
assert_eq!(crate::UserInteractions::<Test>::get(1), 5);
assert_eq!(crate::UserInteractions::<Test>::get(2), 10);
});
}
该测试可确保链启动时状态正确。
运行全部测试
执行:
rust
cargo test --package pallet-custom
示例输出:
rust
test tests::increment_works ... ok
test tests::set_counter_value_works ... ok
...
test result: ok. 15 passed
说明所有测试通过。
Mock Runtime 自动测试说明
你会看到两个自动生成测试:
rust
mock::__construct_runtime_integrity_test::runtime_integrity_tests
mock::test_genesis_config_builds
作用:
• 验证 Runtime 结构完整性
• 校验 Genesis 构建过程
这些测试用于保障测试环境本身的正确性。
总结
至此,你已经完成了一套完整的 Pallet 单元测试体系,覆盖了:
• 功能正确性验证
• 错误处理机制
• 权限控制
• 事件系统
• 状态变更
• 创世初始化
这些测试模式是 Polkadot Pallet 开发中的标准实践。
在开发更复杂业务逻辑时,建议始终遵循:
先设计测试,再实现功能(Test-Driven Development)
这样可以显著降低运行时风险,提高代码质量和可维护性。
原文链接:https://docs.polkadot.com/parachains/customize-runtime/pallet-development/pallet-testing/