Testing
Dubhe generates an init_test.move helper that provides factory functions for creating isolated storage objects in unit tests. This page covers common testing patterns.
The test bootstrap helpers
Every generated project includes sources/codegen/init_test.move with three helpers:
// auto-generated — do not edit
module mygame::init_test;
/// Create a DappHub for testing without sharing it.
public fun create_dapp_hub_for_testing(ctx: &mut TxContext): dubhe::dapp_service::DappHub {
dubhe::dapp_system::create_dapp_hub_for_testing(ctx)
}
/// Create a DappStorage for this DApp without sharing it.
/// Use this to test functions that access global resources or run guards.
public fun create_dapp_storage_for_testing(ctx: &mut TxContext): dubhe::dapp_service::DappStorage {
dubhe::dapp_system::create_dapp_storage_for_testing<mygame::dapp_key::DappKey>(ctx)
}
/// Create a UserStorage for `owner` without sharing it.
/// Use this to test functions that read or write per-user resources.
public fun create_user_storage_for_testing(
owner: address,
ctx: &mut TxContext,
): dubhe::dapp_service::UserStorage {
dubhe::dapp_system::create_user_storage_for_testing<mygame::dapp_key::DappKey>(owner, ctx)
}These create in-memory storage objects with no gas cost — no shared objects or transactions are needed in tests.
Basic test structure
#[test_only]
module mygame::level_test;
use mygame::init_test;
use mygame::level;
use dubhe::dapp_service::UserStorage;
#[test]
public fun test_set_and_get_level() {
let player = @0xA1;
let ctx = &mut sui::tx_context::dummy();
// Create isolated UserStorage for the player
let mut user_storage = init_test::create_user_storage_for_testing(player, ctx);
// Initial state: no record
assert!(!level::has(&user_storage));
// Write
level::set(&mut user_storage, 5, ctx);
// Read back
assert!(level::get(&user_storage) == 5);
// Cleanup
dubhe::dapp_service::destroy_user_storage(user_storage);
}Simulating multiple callers
Each caller gets their own UserStorage — storage isolation is guaranteed by the object model:
#[test]
public fun test_two_players_are_isolated() {
let alice = @0xA1;
let bob = @0xA2;
let ctx = &mut sui::tx_context::dummy();
// Alice and Bob each have their own UserStorage
let mut alice_storage = init_test::create_user_storage_for_testing(alice, ctx);
let mut bob_storage = init_test::create_user_storage_for_testing(bob, ctx);
level::set(&mut alice_storage, 10, ctx);
level::set(&mut bob_storage, 99, ctx);
// Verify isolation
assert!(level::get(&alice_storage) == 10);
assert!(level::get(&bob_storage) == 99);
dubhe::dapp_service::destroy_user_storage(alice_storage);
dubhe::dapp_service::destroy_user_storage(bob_storage);
}Testing global resources
Global resources use DappStorage:
#[test]
public fun test_global_counter_increments() {
let ctx = &mut sui::tx_context::dummy();
let mut dapp_storage = init_test::create_dapp_storage_for_testing(ctx);
// Initialize global counter
total_players::set(&mut dapp_storage, 0, ctx);
assert!(total_players::get(&dapp_storage) == 0);
total_players::set(&mut dapp_storage, 1, ctx);
assert!(total_players::get(&dapp_storage) == 1);
dubhe::dapp_service::destroy_dapp_storage(dapp_storage);
}Testing failure cases
Use #[expected_failure] to assert a transaction must abort:
#[test]
#[expected_failure]
public fun test_get_missing_record_aborts() {
let ctx = &mut sui::tx_context::dummy();
let user_storage = init_test::create_user_storage_for_testing(@0xA1, ctx);
// Reading a record that doesn't exist must abort
level::get(&user_storage);
dubhe::dapp_service::destroy_user_storage(user_storage);
}To assert a specific abort code, use #[expected_failure(abort_code = ...)]:
#[test]
#[expected_failure(abort_code = dubhe::error::ENoPermission)]
public fun test_non_admin_cannot_pause() {
let attacker = @0xBAD;
let ctx = &mut sui::tx_context::dummy();
let mut dapp_storage = init_test::create_dapp_storage_for_testing(ctx);
// Must abort: ctx.sender() (@0x0 in dummy) is not the DApp admin
dapp_system::set_paused<DappKey>(&mut dapp_storage, true, ctx);
dubhe::dapp_service::destroy_dapp_storage(dapp_storage);
}Testing access control guards
ensure_latest_version checks that the DappStorage.version matches migrate::on_chain_version(). create_dapp_storage_for_testing initialises version to 1 and a freshly generated migrate.move also returns 1, so both match and the guard passes by default.
To test that a stale version is rejected, simulate an upgraded contract by setting ON_CHAIN_VERSION = 2 in migrate.move while the test storage still has version 1:
use dubhe::dapp_system;
use mygame::dapp_key::DappKey;
use mygame::migrate;
#[test]
public fun test_current_version_passes() {
let ctx = &mut sui::tx_context::dummy();
let dapp_storage = init_test::create_dapp_storage_for_testing(ctx);
// Both storage version (1) and on_chain_version() (1) match — no abort
dapp_system::ensure_latest_version<DappKey>(&dapp_storage, migrate::on_chain_version());
dubhe::dapp_service::destroy_dapp_storage(dapp_storage);
}Testing with both storage types
When testing a system function that uses both dapp_storage (guards, global resources) and user_storage (user data), create both:
#[test]
public fun test_attack_monster() {
let player = @0xA1;
let ctx = &mut sui::tx_context::dummy();
let dapp_storage = init_test::create_dapp_storage_for_testing(ctx);
let mut user_storage = init_test::create_user_storage_for_testing(player, ctx);
// Set up player state
level::set(&mut user_storage, 5, ctx);
stats::set(&mut user_storage, 100, 1000, 50, ctx); // attack, hp, defense
// Call the system function under test
// (in real test you'd call the entry fun directly; here shown as inline logic)
error::player_not_found(level::has(&user_storage));
let lv = level::get(&user_storage);
level::set(&mut user_storage, lv + 1, ctx);
assert!(level::get(&user_storage) == 6);
dubhe::dapp_service::destroy_dapp_storage(dapp_storage);
dubhe::dapp_service::destroy_user_storage(user_storage);
}Complete test file example
#[test_only]
module mygame::combat_test;
use mygame::init_test;
use mygame::{level, stats};
use mygame::error;
const ALICE: address = @0xA1;
const BOB: address = @0xA2;
#[test]
public fun test_level_up_increases_level() {
let ctx = &mut sui::tx_context::dummy();
let mut alice_storage = init_test::create_user_storage_for_testing(ALICE, ctx);
level::set(&mut alice_storage, 1, ctx);
stats::set(&mut alice_storage, 10, 100, 5, ctx);
let lv = level::get(&alice_storage);
level::set(&mut alice_storage, lv + 1, ctx);
assert!(level::get(&alice_storage) == 2);
dubhe::dapp_service::destroy_user_storage(alice_storage);
}
#[test]
#[expected_failure]
public fun test_attack_aborts_for_unregistered_player() {
let ctx = &mut sui::tx_context::dummy();
let alice_storage = init_test::create_user_storage_for_testing(ALICE, ctx);
// Alice has no level record — must abort
error::player_not_found(level::has(&alice_storage));
dubhe::dapp_service::destroy_user_storage(alice_storage);
}Running tests with the Dubhe CLI
From the repository root that contains dubhe.config.ts:
dubhe testRun only tests whose fully qualified name contains a substring (same rules as the positional filter in sui move test):
dubhe test mygame::level_test::test_set_and_get_level
dubhe test --test test_set_and_get_levelList all tests without executing:
dubhe test --listFor gas limits and other flags, see the CLI test command.