# 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);
}
}
```