# Sui Move Unit Test 今天要介紹Sui Move智能合約的單元測試(Unit Test) 參考資料 : [為什麼程式需要單元測試? - 概念篇](https://www.gss.com.tw/blog/why-program-need-unit-test-intro) [官方單元測試影片](https://www.youtube.com/watch?v=wL8cBPSSXWg) [Sui GitHub](https://github.com/sui-foundation/sui-move-intro-course/blob/main/unit-three/lessons/6_unit_testing.md) ## 建立test_demo project ```javascript sui move new test_demo ``` 在sources目錄建立一個test.move檔 ### Sui Move 的單元測試有三個註釋: #[test_only] #[test] #[expected_failure] ## 註釋說明 ### #[test_only] 1.如果放在module,則表示該module是用於測試用的,點Run Test會自動跑module內有#[test]註釋的全部fun ```rust #[test_only] module test_demo::test { use sui::math; use std::debug; } ``` 2.如果放在fun上,則表示該方法是給其他測試module測試用的,自己module無法調用 ```rust #[test_only] /// Wrapper of module initializer for testing public fun test_init(ctx: &mut TxContext) { init(MANAGED {}, ctx); } ``` ### #[test] 需放在fun上方 1.不帶參數 ```rust #[test] // get max number public fun test_max() { assert!(math::max(10, 100) == 100, 1); } ``` ```javascript PS D:\vscode\workspace\test_demo> sui move test test_max UPDATING GIT DEPENDENCY https://github.com/MystenLabs/sui.git INCLUDING DEPENDENCY Sui INCLUDING DEPENDENCY MoveStdlib BUILDING test_demo Running Move unit tests [ PASS ] 0x0::math_tests::test_max Test result: OK. Total tests: 1; passed: 1; failed: 0 ``` 2.帶參數 參數採用形式為參數名稱對應地址 #[test(參數1變數名稱 = address ,參數n變數名稱 = address>)] ```rust #[test(x = @0xabc)] // print x address value public fun test_address(x : address) { debug::print(&x); } ``` 如果fun有參數,還是使用#[test]的話會報錯 ```rust #[test] // print x address value public fun test_address(x : address) { debug::print(&x); } ``` ```javascript PS D:\vscode\workspace\test_demo> sui move test test_address UPDATING GIT DEPENDENCY https://github.com/MystenLabs/sui.git INCLUDING DEPENDENCY Sui INCLUDING DEPENDENCY MoveStdlib BUILDING test_demo error[E10005]: unable to generate test ┌─ .\sources\math_test.move:12:7 │ 12 │ #[test] │ ^^^^ Missing test parameter assignment in test. Expected a parameter to be assigned in this attribute 13 │ public fun test_address(x : address) { │ ------------ - Corresponding to this parameter │ │ │ Error found in this test ``` ### #[expected_failure] 測試錯誤代碼,需搭配#[test]使用,且內部邏輯要是錯誤的才會PASS,否則會報錯 1.不帶參數(回傳任何錯誤代碼都可以) 取最小值,內部邏輯正確 ```rust #[test] #[expected_failure] public fun test_min() { assert!(math::min(10, 100) == 10, 1); } ``` ```javascript PS D:\vscode\workspace\test_demo> sui move test test_min UPDATING GIT DEPENDENCY https://github.com/MystenLabs/sui.git INCLUDING DEPENDENCY Sui INCLUDING DEPENDENCY MoveStdlib BUILDING test_demo Running Move unit tests [ FAIL ] 0x0::math_tests::test_min Test failures: Failures in 0x0::math_tests: ┌── test_min ────── │ Test did not error as expected └────────────────── Test result: FAILED. Total tests: 1; passed: 0; failed: 1 ``` 取最小值,內部邏輯錯誤 ```rust #[test] #[expected_failure] public fun test_min() { assert!(math::min(10, 100) == 100, 1); } ``` ```javascript PS D:\vscode\workspace\test_demo> sui move test test_min UPDATING GIT DEPENDENCY https://github.com/MystenLabs/sui.git INCLUDING DEPENDENCY Sui INCLUDING DEPENDENCY MoveStdlib BUILDING test_demo Running Move unit tests [ PASS ] 0x0::math_tests::test_min Test result: OK. Total tests: 1; passed: 1; failed: 0 ``` 2.帶參數(返回指定的錯誤代碼) 返回正確的錯誤代碼 ```rust #[test] #[expected_failure(abort_code = 1)] public fun test_min() { assert!(math::min(10, 100) == 100, 1); } ``` 如果返回非預期的錯誤代碼,則會報錯 ```rust #[test] #[expected_failure(abort_code = 1)] public fun test_min_1() { assert!(math::min(10, 100) == 100, 0); } ``` ```javascript PS D:\vscode\workspace\test_demo> sui move test test_min UPDATING GIT DEPENDENCY https://github.com/MystenLabs/sui.git INCLUDING DEPENDENCY Sui INCLUDING DEPENDENCY MoveStdlib BUILDING test_demo Running Move unit tests [ FAIL ] 0x0::math_tests::test_min Test failures: Failures in 0x0::math_tests: ┌── test_min ────── │ error[E11001]: test failure │ ┌─ .\sources\math_test.move:22:9 │ │ │ 21 │ public fun test_min() { │ │ -------- In this function in 0x0::math_tests │ 22 │ assert!(math::min(10, 100) == 100, 0); │ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Test did not abort with expected code. Expected test to abort with code 1, but instead it aborted with code 0 originating in the module 0000000000000000000000000000000000000000000000000000000000000000::math_tests rooted here │ │ └────────────────── Test result: FAILED. Total tests: 1; passed: 0; failed: 1 ``` ### Sui Move 提供的 unit test module ```rust sui::test_scenario ``` :::spoiler test_scenario內部Code ```rust // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 #[test_only] module sui::test_scenario { use std::option::{Self, Option}; use sui::object::{Self, ID, UID}; use sui::tx_context::{Self, TxContext}; use sui::vec_map::VecMap; /// the transaction failed when generating these effects. For example, a circular ownership /// of objects was created const ECouldNotGenerateEffects: u64 = 0; /// Transaction ended without all shared and immutable objects being returned or with those /// objects being transferred or wrapped const EInvalidSharedOrImmutableUsage: u64 = 1; /// Attempted to return an object to the inventory that was not previously removed from the /// inventory during the current transaction. Can happen if the user attempts to call /// `return_to_address` on a locally constructed object rather than one returned from a /// `test_scenario` function such as `take_from_address`. const ECantReturnObject: u64 = 2; /// Attempted to retrieve an object of a particular type from the inventory, but it is empty. /// Can happen if the user already transferred the object or a previous transaction failed to /// transfer the object to the user. const EEmptyInventory: u64 = 3; /// Object of that ID was not found in that inventory. It was possibly already taken const EObjectNotFound: u64 = 4; /// Utility for mocking a multi-transaction Sui execution in a single Move procedure. /// A `Scenario` maintains a view of the global object pool built up by the execution. /// These objects can be accessed via functions like `take_from_sender`, which gives the /// transaction sender access to objects in (only) their inventory. /// Example usage: /// ``` /// let addr1: address = 0; /// let addr2: address = 1; /// // begin a test scenario in a context where addr1 is the sender /// let scenario = &mut test_scenario::begin(addr1); /// // addr1 sends an object to addr2 /// { /// let some_object: SomeObject = ... // construct an object /// transfer::public_transfer(some_object, copy addr2) /// }; /// // end the first transaction and begin a new one where addr2 is the sender /// // Starting a new transaction moves any objects transferred into their respective /// // inventories. In other words, if you call `take_from_sender` before `next_tx`, `addr2` /// // will not yet have `some_object` /// test_scenario::next_tx(scenario, addr2); /// { /// // remove the SomeObject value from addr2's inventory /// let obj = test_scenario::take_from_sender<SomeObject>(scenario); /// // use it to test some function that needs this value /// SomeObject::some_function(obj) /// }; /// ... // more txes /// test_scenario::end(scenario); /// ``` struct Scenario { txn_number: u64, ctx: TxContext, } /// The effects of a transaction struct TransactionEffects has drop { /// The objects created this transaction created: vector<ID>, /// The objects written/modified this transaction written: vector<ID>, /// The objects deleted this transaction deleted: vector<ID>, /// The objects transferred to an account this transaction transferred_to_account: VecMap<ID, /* owner */ address>, /// The objects transferred to an object this transaction transferred_to_object: VecMap<ID, /* owner */ ID>, /// The objects shared this transaction shared: vector<ID>, /// The objects frozen this transaction frozen: vector<ID>, /// The number of user events emitted this transaction num_user_events: u64, } /// Begin a new multi-transaction test scenario in a context where `sender` is the tx sender public fun begin(sender: address): Scenario { Scenario { txn_number: 0, ctx: tx_context::new_from_hint(sender, 0, 0, 0, 0), } } /// Advance the scenario to a new transaction where `sender` is the transaction sender /// All objects transferred will be moved into the inventories of the account or the global /// inventory. In other words, in order to access an object with one of the various "take" /// functions below, e.g. `take_from_address_by_id`, the transaction must first be ended via /// `next_tx`. /// Returns the results from the previous transaction /// Will abort if shared or immutable objects were deleted, transferred, or wrapped. /// Will abort if TransactionEffects cannot be generated public fun next_tx(scenario: &mut Scenario, sender: address): TransactionEffects { // create a seed for new transaction digest to ensure that this tx has a different // digest (and consequently, different object ID's) than the previous tx scenario.txn_number = scenario.txn_number + 1; let epoch = tx_context::epoch(&scenario.ctx); let epoch_timestamp_ms = tx_context::epoch_timestamp_ms(&scenario.ctx); scenario.ctx = tx_context::new_from_hint( sender, scenario.txn_number, epoch, epoch_timestamp_ms, 0, ); // end the transaction end_transaction() } /// Advance the scenario to a new epoch and end the transaction /// See `next_tx` for further details public fun next_epoch(scenario: &mut Scenario, sender: address): TransactionEffects { tx_context::increment_epoch_number(&mut scenario.ctx); next_tx(scenario, sender) } /// Advance the scenario to a new epoch, `delta_ms` milliseconds in the future and end /// the transaction. /// See `next_tx` for further details public fun later_epoch( scenario: &mut Scenario, delta_ms: u64, sender: address, ): TransactionEffects { tx_context::increment_epoch_timestamp(&mut scenario.ctx, delta_ms); next_epoch(scenario, sender) } /// Ends the test scenario /// Returns the results from the final transaction /// Will abort if shared or immutable objects were deleted, transferred, or wrapped. /// Will abort if TransactionEffects cannot be generated public fun end(scenario: Scenario): TransactionEffects { let Scenario { txn_number: _, ctx: _ } = scenario; end_transaction() } // == accessors and helpers == /// Return the `TxContext` associated with this `scenario` public fun ctx(scenario: &mut Scenario): &mut TxContext { &mut scenario.ctx } /// Generate a fresh ID for the current tx associated with this `scenario` public fun new_object(scenario: &mut Scenario): UID { object::new(&mut scenario.ctx) } /// Return the sender of the current tx in this `scenario` public fun sender(scenario: &Scenario): address { tx_context::sender(&scenario.ctx) } /// Return the number of concluded transactions in this scenario. /// This does not include the current transaction, e.g. this will return 0 if `next_tx` has /// not yet been called public fun num_concluded_txes(scenario: &Scenario): u64 { scenario.txn_number } /// Accessor for `created` field of `TransactionEffects` public fun created(effects: &TransactionEffects): vector<ID> { effects.created } /// Accessor for `written` field of `TransactionEffects` public fun written(effects: &TransactionEffects): vector<ID> { effects.written } /// Accessor for `deleted` field of `TransactionEffects` public fun deleted(effects: &TransactionEffects): vector<ID> { effects.deleted } /// Accessor for `transferred_to_account` field of `TransactionEffects` public fun transferred_to_account(effects: &TransactionEffects): VecMap<ID, address> { effects.transferred_to_account } /// Accessor for `transferred_to_object` field of `TransactionEffects` public fun transferred_to_object(effects: &TransactionEffects): VecMap<ID, ID> { effects.transferred_to_object } /// Accessor for `shared` field of `TransactionEffects` public fun shared(effects: &TransactionEffects): vector<ID> { effects.shared } /// Accessor for `frozen` field of `TransactionEffects` public fun frozen(effects: &TransactionEffects): vector<ID> { effects.frozen } /// Accessor for `num_user_events` field of `TransactionEffects` public fun num_user_events(effects: &TransactionEffects): u64 { effects.num_user_events } // == from address == /// Remove the object of type `T` with ID `id` from the inventory of the `account` /// An object is in the address's inventory if the object was transferred to the `account` /// in a previous transaction. Using `return_to_address` is similar to `transfer` and you /// must wait until the next transaction to re-take the object. /// Aborts if there is no object of type `T` in the inventory with ID `id` public native fun take_from_address_by_id<T: key>( scenario: &Scenario, account: address, id: ID, ): T; /// Returns the most recent object of type `T` transferred to address `account` that has not /// been taken public native fun most_recent_id_for_address<T: key>(account: address): Option<ID>; /// Returns all ids of type `T` transferred to address `account`. public native fun ids_for_address<T: key>(account: address): vector<ID>; /// helper that returns true iff `most_recent_id_for_address` returns some public fun has_most_recent_for_address<T: key>(account: address): bool { option::is_some(&most_recent_id_for_address<T>(account)) } /// Helper combining `take_from_address_by_id` and `most_recent_id_for_address` /// Aborts if there is no object of type `T` in the inventory of `account` public fun take_from_address<T: key>(scenario: &Scenario, account: address): T { let id_opt = most_recent_id_for_address<T>(account); assert!(option::is_some(&id_opt), EEmptyInventory); take_from_address_by_id(scenario, account, option::destroy_some(id_opt)) } /// Return `t` to the inventory of the `account`. `transfer` can be used directly instead, /// but this function is helpful for test cleanliness as it will abort if the object was not /// originally taken from this account public fun return_to_address<T: key>(account: address, t: T) { let id = object::id(&t); assert!(was_taken_from_address(account, id), ECantReturnObject); sui::transfer::transfer_impl(t, account) } /// Returns true if the object with `ID` id was in the inventory for `account` public native fun was_taken_from_address(account: address, id: ID): bool; // == from sender == /// helper for `take_from_address_by_id` that operates over the transaction sender public fun take_from_sender_by_id<T: key>(scenario: &Scenario, id: ID): T { take_from_address_by_id(scenario, sender(scenario), id) } /// helper for `most_recent_id_for_address` that operates over the transaction sender public fun most_recent_id_for_sender<T: key>(scenario: &Scenario): Option<ID> { most_recent_id_for_address<T>(sender(scenario)) } /// helper that returns true iff `most_recent_id_for_sender` returns some public fun has_most_recent_for_sender<T: key>(scenario: &Scenario): bool { option::is_some(&most_recent_id_for_address<T>(sender(scenario))) } /// helper for `take_from_address` that operates over the transaction sender public fun take_from_sender<T: key>(scenario: &Scenario): T { take_from_address(scenario, sender(scenario)) } /// helper for `return_to_address` that operates over the transaction sender public fun return_to_sender<T: key>(scenario: &Scenario, t: T) { return_to_address(sender(scenario), t) } /// Returns true if the object with `ID` id was in the inventory for the sender public fun was_taken_from_sender(scenario: &Scenario, id: ID): bool { was_taken_from_address(sender(scenario), id) } /// Returns all ids of type `T` transferred to the sender. public fun ids_for_sender<T: key>(scenario: &Scenario): vector<ID> { ids_for_address<T>(sender(scenario)) } // == immutable == /// Remove the immutable object of type `T` with ID `id` from the global inventory /// Aborts if there is no object of type `T` in the inventory with ID `id` public native fun take_immutable_by_id<T: key>(scenario: &Scenario, id: ID): T; /// Returns the most recent immutable object of type `T` that has not been taken public native fun most_recent_immutable_id<T: key>(): Option<ID>; /// helper that returns true iff `most_recent_immutable_id` returns some public fun has_most_recent_immutable<T: key>(): bool { option::is_some(&most_recent_immutable_id<T>()) } /// Helper combining `take_immutable_by_id` and `most_recent_immutable_id` /// Aborts if there is no immutable object of type `T` in the global inventory public fun take_immutable<T: key>(scenario: &Scenario): T { let id_opt = most_recent_immutable_id<T>(); assert!(option::is_some(&id_opt), EEmptyInventory); take_immutable_by_id(scenario, option::destroy_some(id_opt)) } /// Return `t` to the global inventory public fun return_immutable<T: key>(t: T) { let id = object::id(&t); assert!(was_taken_immutable(id), ECantReturnObject); sui::transfer::freeze_object_impl(t) } /// Returns true if the object with `ID` id was an immutable object in the global inventory public native fun was_taken_immutable(id: ID): bool; // == shared == /// Remove the shared object of type `T` with ID `id` from the global inventory /// Aborts if there is no object of type `T` in the inventory with ID `id` public native fun take_shared_by_id<T: key>(scenario: &Scenario, id: ID): T; /// Returns the most recent shared object of type `T` that has not been taken public native fun most_recent_id_shared<T: key>(): Option<ID>; /// helper that returns true iff `most_recent_id_shared` returns some public fun has_most_recent_shared<T: key>(): bool { option::is_some(&most_recent_id_shared<T>()) } /// Helper combining `take_shared_by_id` and `most_recent_id_shared` /// Aborts if there is no shared object of type `T` in the global inventory public fun take_shared<T: key>(scenario: &Scenario): T { let id_opt = most_recent_id_shared<T>(); assert!(option::is_some(&id_opt), EEmptyInventory); take_shared_by_id(scenario, option::destroy_some(id_opt)) } /// Return `t` to the global inventory public fun return_shared<T: key>(t: T) { let id = object::id(&t); assert!(was_taken_shared(id), ECantReturnObject); sui::transfer::share_object_impl(t) } /// Returns true if the object with `ID` id was an shared object in the global inventory native fun was_taken_shared(id: ID): bool; // == internal == // internal function that ends the transaction, realizing changes native fun end_transaction(): TransactionEffects; // TODO: Add API's for inspecting user events, printing the user's inventory, ... } ``` ::: <br> 範例Code : 這個module功用是創建一個用於管理的自定義Coin ```rust module test_demo::managed { use std::option; use sui::coin::{Self, Coin, TreasuryCap}; use sui::transfer; use sui::tx_context::{Self, TxContext}; use sui::test_scenario::{Self, next_tx, ctx}; struct MANAGED has drop {} fun init(witness: MANAGED, ctx: &mut TxContext) { let (treasury_cap, metadata) = coin::create_currency<MANAGED>(witness, 2, b"MANAGED", b"MNG", b"", option::none(), ctx); transfer::public_freeze_object(metadata); transfer::public_transfer(treasury_cap, tx_context::sender(ctx)) } public entry fun mint( treasury_cap: &mut TreasuryCap<MANAGED>, amount: u64, recipient: address, ctx: &mut TxContext ) { coin::mint_and_transfer(treasury_cap, amount, recipient, ctx) } public entry fun burn(treasury_cap: &mut TreasuryCap<MANAGED>, coin: Coin<MANAGED>) { coin::burn(treasury_cap, coin); } #[test_only] public fun test_init(ctx: &mut TxContext) { init(MANAGED {}, ctx); } #[test] fun mint_burn() { let addr1 = @0xA; let scenario = test_scenario::begin(addr1); init(MANAGED {}, ctx(&mut scenario)); // Mint a `Coin<MANAGED>` object next_tx(&mut scenario, addr1); let treasurycap = test_scenario::take_from_sender<TreasuryCap<MANAGED>>(&scenario); mint(&mut treasurycap, 100, addr1, test_scenario::ctx(&mut scenario)); test_scenario::return_to_address<TreasuryCap<MANAGED>>(addr1, treasurycap); // Burn a `Coin<MANAGED>` object next_tx(&mut scenario, addr1); let coin = test_scenario::take_from_sender<Coin<MANAGED>>(&scenario); let treasurycap = test_scenario::take_from_sender<TreasuryCap<MANAGED>>(&scenario); burn(&mut treasurycap, coin); test_scenario::return_to_address<TreasuryCap<MANAGED>>(addr1, treasurycap); // Cleans up the scenario object test_scenario::end(scenario); } } ``` 接下來我們在sources目錄創建一個managed_test.move,用於測試managed的init功能 ```rust #[test_only] module test_demo::managed_test { use test_demo::managed::{Self, MANAGED}; use sui::coin::{Coin, TreasuryCap}; use sui::test_scenario::{Self, next_tx, ctx}; #[test] fun mint_burn() { // Initialize a mock sender address let addr1 = @0xA; // Begins a multi transaction scenario with addr1 as the sender let scenario = test_scenario::begin(addr1); // Run the managed coin module init function managed::test_init(ctx(&mut scenario)); // Mint a `Coin<MANAGED>` object next_tx(&mut scenario, addr1); let treasurycap = test_scenario::take_from_sender<TreasuryCap<MANAGED>>(&scenario); managed::mint(&mut treasurycap, 100, addr1, test_scenario::ctx(&mut scenario)); test_scenario::return_to_address<TreasuryCap<MANAGED>>(addr1, treasurycap); // Burn a `Coin<MANAGED>` object next_tx(&mut scenario, addr1); let coin = test_scenario::take_from_sender<Coin<MANAGED>>(&scenario); let treasurycap = test_scenario::take_from_sender<TreasuryCap<MANAGED>>(&scenario); managed::burn(&mut treasurycap, coin); test_scenario::return_to_address<TreasuryCap<MANAGED>>(addr1, treasurycap); // Cleans up the scenario object test_scenario::end(scenario); } } ```