13 个版本 (4 个稳定版)

4.0.0-pre.92022年5月12日
4.0.0-pre.72022年2月3日
4.0.0-pre.52021年12月24日
4.0.0-pre.42021年10月15日
3.0.1 2021年3月30日

#91 in #near

Download history 177/week @ 2024-03-12 188/week @ 2024-03-19 155/week @ 2024-03-26 242/week @ 2024-04-02 117/week @ 2024-04-09 175/week @ 2024-04-16 189/week @ 2024-04-23 162/week @ 2024-04-30 158/week @ 2024-05-07 175/week @ 2024-05-14 209/week @ 2024-05-21 173/week @ 2024-05-28 165/week @ 2024-06-04 123/week @ 2024-06-11 166/week @ 2024-06-18 133/week @ 2024-06-25

612 每月下载量
用于 near-accounts-plugins-wra…

GPL-3.0 许可证

1MB
20K SLoC

NEAR 模拟器 & 跨合约测试库

在编写 NEAR 合同时,使用 Rust 或其他 Wasm 编译语言(如 AssemblyScript),您选择的语言(例如在您的 Rust 项目的 src/lib.rs 文件中的 mod test)的默认测试方法非常适合测试单个合同在隔离状态下的行为。

但是,区块链和智能合约的真正力量来自跨合约调用。您如何确保您的跨合约代码按预期工作呢?

作为第一步,您可以使用这个库!通过它,您可以

  • 测试跨合约调用
  • 分析您的合同对 gas存储 的使用情况,为已部署合同的成本建立下限,并在部署前快速识别问题区域。
  • 检查复杂交易链中所有调用的中间状态

要本地查看此文档,请克隆此仓库,然后从此文件夹运行 cargo doc --open

变更日志

3.2.0

  • GenesisConfig 中引入 block_prod_time 持续时间(以纳秒为单位),以定义产生块之间的持续时间。
  • RuntimeStandalone 中公开 cur_blockgenesis_config。这允许操作块时间。
  • 使用RuntimeConfig::from_protocol_version来修复存储成本问题。
  • 将根账户余额设置为十亿个代币。

入门指南

本节将指导您通过我们建议的方法将模拟测试添加到您的项目中。想要一个例子?请查看可交易代币示例

依赖项版本

目前,这个包依赖于nearcore的GitHub仓库,因此这个包也必须是一个git依赖。此外,这个包的依赖项与构建Wasm智能合约冲突,因此您必须将其添加到以下位置

[dev-dependencies]
near-sdk-sim = "4.0.0-pre.9"

并且还要更新near-sdk

[dependencies]
near-sdk = "4.0.0-pre.9"

请注意,您需要添加版本的tag(或commit)。

工作空间设置

如果您想检查一个Rust合约的gas和存储使用情况,可以将上述依赖项添加到您的项目根目录下的Cargo.toml中。如果您想测试跨合约调用,我们建议设置一个cargo 工作空间。以下是操作方法

假设您有一个名为contract的文件夹中的现有合约项目。

在它里面创建一个名为contract的子文件夹,并将contract的原始内容移动到这个子文件夹中。现在您将拥有contract/contract。如果您想,可以将根文件夹重命名为类似contractscontract-wrap的名称。以下是一些bash命令来完成这项工作

mkdir contract-wrap
mv contract contract-wrap

现在在项目的根目录中(例如contract-wrap),创建一个新的Cargo.toml。您需要添加正常的[package]部分,但与大多数项目不同,您没有dependencies,只有dev-dependencies和一个workspace

[dev-dependencies]
near-sdk = "4.0.0-pre.9"
near-sdk-sim = "4.0.0-pre.9"
contract = { path = "./contract" }

[workspace]
members = [
  "contract"
]

现在,当您想要创建测试合约时,您可以在项目中添加一个新的子文件夹,并在根目录下的此Cargo.toml中为其添加一行。

其他清理工作

  • 您可以将嵌套项目的[profile.release]设置从嵌套项目移动到工作空间的根目录,因为工作空间成员会从工作空间根继承这些设置。
  • 您还可以删除嵌套项目的target,因为所有工作空间成员都将构建到根项目的target目录中
  • 如果您使用的是cargo build进行构建,现在您可以一次性使用cargo build --all构建所有工作空间成员

测试文件

在您的项目根目录中(例如上面示例中的contract-wrap),创建一个包含Rust文件的tests目录。这里的内容将被cargo test自动运行。

在此文件夹内,为自己创建一个新的测试仓库存档,通过创建一个 tests/sim 目录和一个 tests/sim/main.rs 文件来实现。此文件将粘合(即组合)此文件夹内其他文件(即模块)。我们很快会向其中添加内容。目前您可以将其留空。

现在创建一个 tests/sim/utils.rs 文件。此文件将导出适用于所有测试的常用函数。在文件中,您需要包含想要测试的合约的字节。

near_sdk_sim::lazy_static_include::lazy_static_include_bytes! {
    // update `contract.wasm` for your contract's name
    CONTRACT_WASM_BYTES => "target/wasm32-unknown-unknown/release/contract.wasm",

    // if you run `cargo build` without `--release` flag:
    CONTRACT_WASM_BYTES => "target/wasm32-unknown-unknown/debug/contract.wasm",
}

请注意,这意味着您必须先 build,然后才能 test!由于 cargo test 不会重新生成您的模拟测试所依赖的 wasm 文件,您需要在运行 cargo test 命令之前,使用 cargo build --all --target wasm32-unknown-unknown。如果您对合约进行了修改,并且 您发誓它现在应该通过,请尝试重新构建!

现在您可以创建一个初始化模拟器的函数。

use near_sdk_sim::{init_simulator, to_yocto, STORAGE_AMOUNT};

const CONTRACT_ID: &str = "contract";

pub fn init() -> (UserAccount, UserAccount, UserAccount) {
    // Use `None` for default genesis configuration; more info below
    let root = init_simulator(None);

    let contract = root.deploy(
        &CONTRACT_WASM_BYTES,
        CONTRACT_ID.to_string(),
        STORAGE_AMOUNT // attached deposit
    );

    let alice = root.create_user(
        "alice".parse().unwrap(),
        to_yocto("100") // initial balance
    );

    (root, contract, alice)
}

现在您可以在 tests/sim/first_tests.rs 中添加一个使用此 init 函数的测试文件。对于您添加到此目录的每个文件,您都需要在 tests/sim/main.rs 中添加一行。让我们为到目前为止的两个文件都添加一行。

// in tests/sim/main.rs
mod utils;
mod first_tests;

现在向 first_tests.rs 中添加一些测试。

use near_sdk::serde_json::json;
use near_sdk_sim::DEFAULT_GAS;

use crate::utils::init;

#[test]
fn simulate_some_view_function() {
    let (root, contract, _alice) = init();

    let actual: String = root.view(
        contract.account_id(),
        "view_something",
        &json!({
            "some_param": "some_value".to_string(),
        }).to_string().into_bytes(),
    ).unwrap_json();

    assert_eq!("expected".to_string(), actual);
}

#[test]
fn simulate_some_change_method() {
    let (root, contract, _alice) = init();

    let result = root.call(
        contract.account_id(),
        "change_something",
        json!({
            "some_param": "some_value".to_string(),
        }).to_string().into_bytes(),
        DEFAULT_GAS,
        1, // deposit
    );

    assert!(result.is_ok());
}

可选宏

上述方法是一个良好的起点,即使您的 Wasm 文件是从除 Rust 之外的语言编译的,它也能正常工作。

但是,如果您的原始文件是 Rust,并且您希望在测试时获得更好的用户体验,near-sdk-sim 提供了一个很好的额外功能。

near-sdk-sim 修改了 near_bindgen 宏,该宏来自 near-sdk,以创建从您的合约生成的额外结构体+实现,并在名称末尾添加 Contract,例如 xxxxxContract。所以如果您有一个名称设置为 token 的合约,并在其 src/lib.rs 中设置了此名称

#[near_bindgen]
struct Token {
    ...
}

#[near_bindgen]
impl Token {
    ...
}

那么在您的模拟测试中,您可以导入 TokenContract

use token::TokenContract;

// or rename it maybe
use token::TokenContract as OtherNamedContract;

现在您可以简化上一节中的 init 及测试代码

// in utils.rs
use near_sdk_sim::{deploy, init_simulator, to_yocto, STORAGE_AMOUNT};
use token::TokenContract;

const CONTRACT_ID: &str = "contract";

pub fn init() -> (UserAccount, ContractAccount<TokenContract>, UserAccount) {
    let root = init_simulator(None);

    let contract = deploy!(
        contract: TokenContract,
        contract_id: CONTRACT_ID,
        bytes: &CONTRACT_WASM_BYTES,
        signer_account: root
    );

    let alice = root.create_user(
        "alice".parse().unwrap(),
        to_yocto("100") // initial balance
    );

    (root, contract, alice)
}

// in first_tests.rs
use near_sdk_sim::{call, view};
use crate::utils::init;

#[test]
fn simulate_some_view_function() {
    let (root, contract, _alice) = init();

    let actual: String = view!(
        contract.view_something("some_value".to_string()),
    ).unwrap_json();

    assert_eq!("expected", actual);
}

#[test]
fn simulate_some_change_method() {
    let (root, contract, _alice) = init();

    // uses default gas amount
    let result = call!(
        root,
        contract.change_something("some_value".to_string()),
        deposit = 1,
    );

    assert!(result.is_ok());
}

常见模式

分析燃气成本

对于由 callcall! 触发的交易链,您可以检查 gas_burnttokens_burnt,其中 tokens_burnt 将等于 gas_burnt 乘以在创世配置中设置的 gas_price。您还可以打印出 profile_data 以查看深入的燃气使用细分。

let outcome = some_account.call(
    "some_contract",
    "method",
    &json({
        "some_param": "some_value",
    }).to_string().into_bytes(),
    DEFAULT_GAS,
    0,
);

println!(
    "profile_data: {:#?} \n\ntokens_burnt: {}",
    outcome.profile_data(),
    (outcome.tokens_burnt()) as f64 / 1e24
);

let expected_gas_ceiling = 5 * u64::pow(10, 12); // 5 TeraGas
assert!(outcome.gas_burnt() < expected_gas_ceiling);

TeraGas 单位在这里 解释

请记住,使用 --nocapture 运行测试以查看 println! 的输出。

cargo test -- --nocapture

println! 的输出可能如下所示

profile_data: ------------------------------
Total gas: 1891395594588
Host gas: 1595600369775 [84% total]
Action gas: 0 [0% total]
Wasm execution: 295795224813 [15% total]
------ Host functions --------
base -> 7678275219 [0% total, 0% host]
contract_compile_base -> 35445963 [0% total, 0% host]
contract_compile_bytes -> 48341969250 [2% total, 3% host]
read_memory_base -> 28708495200 [1% total, 1% host]
read_memory_byte -> 634822611 [0% total, 0% host]
write_memory_base -> 25234153749 [1% total, 1% host]
write_memory_byte -> 539306856 [0% total, 0% host]
read_register_base -> 20137321488 [1% total, 1% host]
read_register_byte -> 17938284 [0% total, 0% host]
write_register_base -> 25789702374 [1% total, 1% host]
write_register_byte -> 821137824 [0% total, 0% host]
utf8_decoding_base -> 3111779061 [0% total, 0% host]
utf8_decoding_byte -> 15162184908 [0% total, 0% host]
log_base -> 3543313050 [0% total, 0% host]
log_byte -> 686337132 [0% total, 0% host]
storage_write_base -> 192590208000 [10% total, 12% host]
storage_write_key_byte -> 1621105941 [0% total, 0% host]
storage_write_value_byte -> 2047223574 [0% total, 0% host]
storage_write_evicted_byte -> 2119742262 [0% total, 0% host]
storage_read_base -> 169070537250 [8% total, 10% host]
storage_read_key_byte -> 711908259 [0% total, 0% host]
storage_read_value_byte -> 370326330 [0% total, 0% host]
touching_trie_node -> 1046627135190 [55% total, 65% host]
------ Actions --------
------------------------------


tokens_burnt: 0.00043195379520539996

分析 存储 成本

使用 deploy! 创建的 ContractAccount 或使用 root.create_user 创建的 UserAccount,您可以调用 account() 来获取存储在模拟区块链中的 Account 信息。

let account = root.account().unwrap();
let balance = account.amount;
let locked_in_stake = account.locked;
let storage_usage = account.storage_usage;

您可以使用这些信息来详细分析合约调用如何改变账户的存储使用情况。

检查复杂交易链中所有调用的中间状态

假设您有一个 callcall!

let outcome = some_account.call(
    "some_contract",
    "method",
    &json({
        "some_param": "some_value",
    }).to_string().into_bytes(),
    DEFAULT_GAS,
    0,
);

如果这里的 some_contract.method 执行跨合约调用,near-sdk-sim 将允许所有这些调用完成。然后,您可以通过 outcome 结构体来检查整个调用链。一些有用的方法

您可以使用这些与 println!美化打印插值 一起使用

println!("{:#?}", outcome.promise_results);

请记住,使用 --nocapture 运行您的测试以查看 println! 输出

cargo test -- --nocapture

您可能会看到如下内容

[
    Some(
        ExecutionResult {
            outcome: ExecutionOutcome {
                logs: [],
                receipt_ids: [
                    `2bCDBfWgRkzGggXLuiXqhnVGbxwRz7RP3qa8WS5nNw8t`,
                ],
                burnt_gas: 2428220615156,
                tokens_burnt: 0,
                status: SuccessReceiptId(2bCDBfWgRkzGggXLuiXqhnVGbxwRz7RP3qa8WS5nNw8t),
            },
        },
    ),
    Some(
        ExecutionResult {
            outcome: ExecutionOutcome {
                logs: [],
                receipt_ids: [],
                burnt_gas: 18841799405111,
                tokens_burnt: 0,
                status: Failure(Action #0: Smart contract panicked: panicked at 'Not an integer: ParseIntError { kind: InvalidDigit }', test-contract/src/lib.rs:85:56),
            },
        },
    )
]

由于 ExecutionResult 还未包含合约或方法的名称,因此很难判断哪个调用是哪个。为了帮助调试,您可以在合约方法中使用 log!。所有 log! 输出都将显示在上面的 ExecutionOutcomes 中的 logs 数组中。

检查预期的交易失败

如果您想检查上述调用链中某个交易的 logsstatus 中的某些内容,您可以使用字符串匹配。要检查上述失败是否符合您的预期,您可以

use near_sdk_sim::transaction::ExecutionStatus;

#[test]
fn simulate_some_failure() {
    let outcome = some_account.call(...);

    assert_eq!(res.promise_errors().len(), 1);

    if let ExecutionStatus::Failure(execution_error) =
        &outcome.promise_errors().remove(0).unwrap().outcome().status
    {
        assert!(execution_error.to_string().contains("ParseIntError"));
    } else {
        unreachable!();
    }
}

promise_errors 返回上述提到的 promise_results 方法的过滤版本。

解析 logs 非常简单,无论是从 get_receipt_results 还是从 logs 直接

调整创世配置

对于许多模拟测试,使用 init_simulator(None) 就足够了。这使用的是 默认的创世配置设置

GenesisConfig {
    genesis_time: 0,
    gas_price: 100_000_000,
    gas_limit: std::u64::MAX,
    genesis_height: 0,
    epoch_length: 3,
    runtime_config: RuntimeConfig::default(),
    state_records: vec![],
    validators: vec![],
}

如果您想覆盖其中的一些值,例如模拟您的合约在 gas 价格 提高到 10 倍时的行为

use near_sdk_sim::runtime::GenesisConfig;

pub fn init () {
    let mut genesis = GenesisConfig::default();
    genesis.gas_price = genesis.gas_price * 10;
    let root = init_simulator(Some(genesis));
}

依赖项

~65MB
~1M SLoC