mcpField
M
move-unit-testing

move-unit-testing

github.com/mystenlabs测试与质量19364 次安装今天更新
代码仓库

关于 move-unit-testing

面向编写 Sui Move 单元测试的 AI Agent 的参考技能,依据文档中的测试规范,涵盖断言、失败场景、上下文、模拟和清理。

主要功能

  1. 01测试函数名避免冗余前缀
  2. 02使用可显示诊断信息的相等断言
  3. 03标注预期中止的位置
  4. 04选择虚拟上下文或场景
  5. 05使用当前 API 清理对象

适用场景

  1. 01为单个操作编写精简测试
  2. 02测试多笔交易和共享对象流程
  3. 03检查跨模块的授权失败
  4. 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()

评论

支持 Markdown