move-unit-testing
Skill20 starsChanged 15 days agoApache-2.0View on GitHub
Use when writing unit tests for Move smart contracts on Sui. Applies to test function naming, assertions, test attributes, context usage, and cleanup patterns. Use whenever user asks to write tests, add tests, or test a Move module.
In this repository30 other files it gives its agents.
- accessing-dataSkill
- composable-move-functionsSkill
- deepbook-marginSkill
- deepbook-moveSkill
- deepbook-overviewSkill
- deepbook-predictSkill
- deepbook-sdkSkill
- frontend-appsSkill
- generate-sui-agent-configSkill
- kioskSkill
- modern-move-syntaxSkill
- move-securitySkill
- naming-conventionsSkill
- sui-object-modelSkill
- onchain-randomnessSkill
- ptbsSkill
- sui-bridgeSkill
- sui-buildSkill
- sui-clientSkill
- sui-for-ethereumSkill
- sui-installSkill
- sui-move-projectSkill
- sui-moveSkill
- sui-networks-gasSkill
- sui-overviewSkill
- sui-publishSkill
- sui-sdksSkill
- sui-ts-sdk-backendSkill
- walrus-sitesSkill
- zkloginSkill
The file
We split this skill into the rules it gives an agent
8 rules. About 0 are common, 1 is distinctive, and 7 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
7
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 14
All patterns sourced from https://move-book.com/guides/code-quality-checklist and https://move-book.com/testing/
- Very specific: line 18
Test functions inside _tests modules should NOT be prefixed with test_.
- Very specific: line 56
Use plain assert! only for boolean conditions where there's nothing to compare:
- Very specific: line 66
Do not pass numeric abort codes to assert!
- Very specific: line 93
When the abort happens in a different module than the test, you must specify location.
- Very specific: line 136
If a test only needs a TxContext and doesn't need multi-transaction simulation, use tx_context::dummy() instead of a full test_scenario.
- Very specific: line 165
Use test_scenario when you need to simulate multiple transactions, different senders, shared objects, or test init functions.
- Very specific: line 263
Use std::unit_test::destroy to clean up test objects.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
move-unit-testing
MCP tool: When available in your environment, also query the Sui documentation MCP server (
https://sui.mcp.kapa.ai) for up-to-date answers. Use it for verification and for details not covered by these reference files.
Overview
AI agents consistently use outdated or suboptimal patterns when writing Move unit tests. This skill covers the correct testing conventions from the official Sui Move code quality checklist and testing documentation.
All patterns sourced from https://move-book.com/guides/code-quality-checklist and https://move-book.com/testing/
No test_ Prefix in Test Modules
Test functions inside _tests modules should NOT be prefixed with test_. The module name already indicates these are tests. Use descriptive names that read as statements.
// 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() { /* ... */ }Use assert_eq! Instead of assert! for Comparisons
assert_eq! displays both values on failure, making debugging much easier. Never use assert!(x == y) or assert!(x == y, 0) for equality checks.
// 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);Use plain assert! only for boolean conditions where there's nothing to compare:
// assert! is fine for boolean checks
assert!(is_valid);
assert!(vec.length() > 0);No Abort Codes in Test assert!
Do not pass numeric abort codes to assert! in tests. They can accidentally match application error codes and confuse debugging.
// 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);Merge #[test] and #[expected_failure] on One Line
// 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() { /* ... */ }expected_failure with location for cross-module aborts
When the abort happens in a different module than the test, you must specify location. Without it, the test framework expects the abort to originate in the test module and the test fails.
// 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() { /* ... */ }The location value is just the module name (e.g., app), not the fully qualified path (my_package::app). Using the fully qualified form causes a compile error: "Unexpected module member identifier."
Skip Cleanup in expected_failure Tests
Tests annotated with expected_failure will abort — any cleanup code after the abort point is dead code. Don't call .end() or destroy objects after the expected abort.
// 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
}Use tx_context::dummy() for Simple Tests
If a test only needs a TxContext and doesn't need multi-transaction simulation, use tx_context::dummy() instead of a full test_scenario. Reserve test_scenario for tests that actually need to simulate multiple transactions, shared objects, or transfers between addresses.
// 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);
}When to use test_scenario: shared objects, multi-transaction flows, testing transfers between addresses, testing init functions, epoch/time manipulation.
When to use tx_context::dummy(): pure function tests, single-operation tests, anything that just needs a ctx to create objects.
test_scenario for Multi-Transaction and Authorization Tests
Use test_scenario when you need to simulate multiple transactions, different senders, shared objects, or test init functions. The core API:
| Function | Purpose |
test_scenario::begin(@addr) | Start a scenario with @addr as the first sender |
scenario.next_tx(@addr) | Advance to a new transaction with @addr as sender |
scenario.take_from_sender<T>() | Take an owned object sent to the current sender |
scenario.return_to_sender(obj) | Return an owned object to the current sender |
scenario.take_shared<T>() | Take a shared object by type |
test_scenario::return_shared(obj) | Return a shared object |
scenario.has_most_recent_for_sender<T>() | Check if sender has an object of type T |
scenario.end() | Finalize the scenario (must be called in non-aborting tests) |
Success test — create and verify
#[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();
}Unauthorized caller test
#[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
}Shared object test
#[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();
}Testing init functions
#[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();
}Note: modules typically expose a init_for_testing or test_init helper since init itself is not directly callable in tests. Use #[test_only] to gate these helpers.
Use std::unit_test::destroy for Cleanup
Use std::unit_test::destroy to clean up test objects. The old sui::test_utils::destroy is deprecated.
// 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);Quick Reference
| Pattern | Correct | Common Mistake |
| Test function naming | create_pool_succeeds() | test_create_pool() |
| Equality assertions | assert_eq!(x, 100) | assert!(x == 100, 0) |
| Boolean assertions | assert!(is_valid) | assert!(is_valid, 0) |
| Test attributes | #[test, expected_failure(...)] | Separate #[test] and #[expected_failure] |
| Expected failure cleanup | Let it abort, no cleanup | Calling .end() after abort |
| Simple test context | tx_context::dummy() | Full test_scenario for simple tests |
| Object cleanup | std::unit_test::destroy(obj) | sui::test_utils::destroy(obj) (deprecated), obj.destroy_for_testing() |
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

