关于 move-unit-testing
面向编写 Sui Move 单元测试的 AI Agent 的参考技能,依据文档中的测试规范,涵盖断言、失败场景、上下文、模拟和清理。
主要功能
- 01测试函数名避免冗余前缀
- 02使用可显示诊断信息的相等断言
- 03标注预期中止的位置
- 04选择虚拟上下文或场景
- 05使用当前 API 清理对象
适用场景
- 01为单个操作编写精简测试
- 02测试多笔交易和共享对象流程
- 03检查跨模块的授权失败
- 04避免使用已弃用的清理模式
MCP 相关
环境支持时,建议查询 Sui 文档 MCP 服务 https://sui.mcp.kapa.ai,以核实信息并补充参考资料未涵盖的细节。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ mystenlabs/ skills --skill move-unit-testing安装后在 Claude Code 中输入/move-unit-testing确认已加载。
附带文件evals/code-evals.jsonevals/evals.json
详细介绍
move-unit-testing
MCP 工具: 如果你的环境中有 Sui 文档 MCP 服务,请同时查询它(
https://sui.mcp.kapa.ai),以获取最新答案。请用它验证信息,并补充这些参考文件中未涵盖的细节。
概述
AI Agent 编写 Move 单元测试时,经常使用过时或不够理想的模式。本技能介绍 Sui 官方 Move 代码质量检查清单和测试文档中的正确测试惯例。
所有模式均来源于 https://move-book.com/guides/code-quality-checklist 和 https://move-book.com/testing/
测试模块中不要使用 test_ 前缀
_tests 模块中的测试函数不应使用 test_ 前缀。模块名已经表明这些函数是测试。请使用读起来像语句的描述性名称。
// WRONG — redundant prefix
module my_package::my_module_tests;
#[test]
fun test_create_pool() { /* ... */ }
#[test]
fun test_swap_fails_on_zero() { /* ... */ }
// CORRECT — descriptive statement names
module my_package::my_module_tests;
#[test]
fun create_pool_with_initial_liquidity() { /* ... */ }
#[test]
fun swap_aborts_on_zero_input() { /* ... */ }
比较时使用 assert_eq!,而不是 assert!
assert_eq! 在失败时会显示两个值,因此更容易调试。进行相等性检查时,绝不要使用 assert!(x == y) 或 assert!(x == y, 0)。
// WRONG — no diagnostic info on failure
assert!(result == 100);
assert!(result == expected_value, 0);
// CORRECT — shows both values on failure
use std::unit_test::assert_eq;
assert_eq!(result, 100);
assert_eq!(result, expected_value);
仅当要检查布尔条件且没有可比较的内容时,才使用普通的 assert!:
// assert! is fine for boolean checks
assert!(is_valid);
assert!(vec.length() > 0);
测试中的 assert! 不要使用中止代码
在测试中,不要向 assert! 传入数字中止代码。它们可能会意外匹配应用错误代码,造成调试混乱。
// WRONG — numeric code may collide with app errors
assert!(is_success, 0);
assert!(balance > 0, 1);
// CORRECT — no abort code
assert!(is_success);
assert!(balance > 0);
将 #[test] 和 #[expected_failure] 合并到同一行
// WRONG — separate attributes
#[test]
#[expected_failure(abort_code = EInvalidInput, location = my_app)]
fun invalid_input_aborts() { /* ... */ }
// CORRECT — merged on one line
#[test, expected_failure(abort_code = EInvalidInput, location = my_app)]
fun invalid_input_aborts() { /* ... */ }
跨模块中止时,使用带 location 的 expected_failure
如果中止发生在与测试不同的模块中,必须指定 location。否则,测试框架会认为中止应发生在测试模块中,导致测试失败。
// Test module: my_package::app_tests
// Abort happens in: my_package::app
const ENotAuthorized: u64 = 0; // mirror the constant value from app module
// WRONG — no location; test fails because abort comes from `app`, not `app_tests`
#[test, expected_failure(abort_code = ENotAuthorized)]
fun unauthorized_call_aborts() { /* ... */ }
// CORRECT — location points to the module where the abort originates
#[test, expected_failure(abort_code = ENotAuthorized, location = app)]
fun unauthorized_call_aborts() { /* ... */ }
location 的值仅为模块名(例如 app),而不是完整限定路径(my_package::app)。使用完整限定形式会导致编译错误:“Unexpected module member identifier.”
expected_failure 测试中跳过清理
标注了 expected_failure 的测试会中止——中止点之后的任何清理代码都是不可达代码。预期中止之后不要调用 .end() 或销毁对象。
// WRONG — cleanup after abort is dead code
#[test, expected_failure(abort_code = my_app::EInsufficientBalance)]
fun withdraw_more_than_balance_aborts() {
let mut scenario = test_scenario::begin(@0xA);
my_app::withdraw(1000, scenario.ctx());
scenario.end(); // never reached
}
// CORRECT — let it abort naturally
#[test, expected_failure(abort_code = my_app::EInsufficientBalance)]
fun withdraw_more_than_balance_aborts() {
let mut scenario = test_scenario::begin(@0xA);
my_app::withdraw(1000, scenario.ctx());
// no cleanup needed — test aborts above
}
简单测试使用 tx_context::dummy()
如果测试只需要 TxContext,且不需要模拟多笔交易,请使用 tx_context::dummy(),而不要使用完整的 test_scenario。只有在确实需要模拟多笔交易、共享对象或地址间转移时,才使用 test_scenario。
// WRONG — unnecessary overhead for a simple test
#[test]
fun mint_returns_correct_value() {
let mut scenario = test_scenario::begin(@0xA);
let item = app::create_item(100, scenario.ctx());
assert_eq!(item.value(), 100);
std::unit_test::destroy(item);
scenario.end();
}
// CORRECT — dummy context is sufficient
#[test]
fun mint_returns_correct_value() {
let ctx = &mut tx_context::dummy();
let item = app::create_item(100, ctx);
assert_eq!(item.value(), 100);
std::unit_test::destroy(item);
}
使用 test_scenario 的场景: 共享对象、多笔交易流程、测试地址间转移、测试 init 函数、纪元/时间操作。
使用 tx_context::dummy() 的场景: 纯函数测试、单次操作测试,以及任何只需要 ctx 来创建对象的测试。
多笔交易和授权测试使用 test_scenario
当需要模拟多笔交易、不同发送者、共享对象或测试 init 函数时,请使用 test_scenario。核心 API:
| 函数 | 用途 |
|---|---|
test_scenario::begin(@addr) | 以 @addr 作为首个发送者启动场景 |
scenario.next_tx(@addr) | 开始一笔由 @addr 作为发送者的新交易 |
scenario.take_from_sender<T>() | 取出发送给当前发送者的自有对象 |
scenario.return_to_sender(obj) | 将自有对象归还给当前发送者 |
scenario.take_shared<T>() | 按类型取出共享对象 |
test_scenario::return_shared(obj) | 归还共享对象 |
scenario.has_most_recent_for_sender<T>() | 检查发送者是否拥有类型为 T 的对象 |
scenario.end() | 结束场景(在不会中止的测试中必须调用) |
成功测试 — 创建并验证
#[test]
fun owner_can_update_item() {
let owner = @0xA;
let mut scenario = test_scenario::begin(owner);
// Tx 1: create an item (transferred to owner inside create_item)
app::create_item(b"sword".to_string(), scenario.ctx());
// Tx 2: owner takes the item and updates it
scenario.next_tx(owner);
let mut item = scenario.take_from_sender<Item>();
app::set_name(&mut item, b"great sword".to_string());
assert_eq!(app::name(&item), b"great sword".to_string());
scenario.return_to_sender(item);
scenario.end();
}
未授权调用者测试
#[test, expected_failure(abort_code = app::ENotOwner, location = app)]
fun non_owner_cannot_update_item() {
let owner = @0xA;
let attacker = @0xB;
let mut scenario = test_scenario::begin(owner);
// Tx 1: owner creates a shared item
app::create_shared_item(b"shield".to_string(), scenario.ctx());
// Tx 2: attacker tries to update it — should abort
scenario.next_tx(attacker);
let mut item = scenario.take_shared<Item>();
app::admin_update(&mut item, b"hacked".to_string(), scenario.ctx());
// no cleanup — test aborts above
}
共享对象测试
#[test]
fun shared_counter_increments() {
let mut scenario = test_scenario::begin(@0xA);
// Tx 1: create and share
app::create_counter(scenario.ctx());
// Tx 2: anyone can increment
scenario.next_tx(@0xB);
let mut counter = scenario.take_shared<Counter>();
app::increment(&mut counter);
assert_eq!(app::value(&counter), 1);
test_scenario::return_shared(counter);
scenario.end();
}
测试 init 函数
#[test]
fun init_creates_admin_cap() {
let mut scenario = test_scenario::begin(@0xA);
// init is called automatically for the first tx in begin()
// if the module has an init function — but in tests you call it explicitly:
app::init_for_testing(scenario.ctx());
scenario.next_tx(@0xA);
assert!(scenario.has_most_recent_for_sender<AdminCap>());
scenario.end();
}
注意:模块通常会提供 init_for_testing 或 test_init 辅助函数,因为无法在测试中直接调用 init。请使用 #[test_only] 限定这些辅助函数。
使用 std::unit_test::destroy 进行清理
使用 std::unit_test::destroy 清理测试对象。旧的 sui::test_utils::destroy 已弃用。
// WRONG — deprecated
use sui::test_utils::destroy;
// WRONG — custom cleanup functions
nft.destroy_for_testing();
// CORRECT — use std::unit_test::destroy
use std::unit_test::destroy;
destroy(nft);
destroy(app);
快速参考
| 模式 | 正确写法 | 常见错误 |
|---|---|---|
| 测试函数命名 | create_pool_succeeds() | test_create_pool() |
| 相等断言 | assert_eq!(x, 100) | assert!(x == 100, 0) |
| 布尔断言 | assert!(is_valid) | assert!(is_valid, 0) |
| 测试属性 | #[test, expected_failure(...)] | 分开使用 #[test] 和 #[expected_failure] |
| 预期失败后的清理 | 让其中止,不进行清理 | 中止后调用 .end() |
| 简单测试上下文 | tx_context::dummy() | 简单测试中使用完整的 test_scenario |
| 对象清理 | std::unit_test::destroy(obj) | sui::test_utils::destroy(obj)(已弃用)、obj.destroy_for_testing() |