Simulation Testing (Rust)

12 min read
To Share and +4 nLEARNs

Before we Start

This article do not show the possibilities a simulation testing framework can do; it discusses about how to create an MVP simulation testing framework for your project. For more advanced changes, do your own research (DYOR) and check out some links one included in this article.

NOTE: Simulation tests will go deprecated as Sandbox comes up. Check out workspaces-rs and workspaces-js.

Brief Introduction

The difference between unit testing and simulation testing is; the former is restricted to testing within the library; while the latter simulate a call from the outside in. Unit testing mostly test internal frameworks for correct logic; while simulation testing tries to simulate user actions and check whether things goes as the developer expects. Treat each function as a simulation.

If you’re fantasized with one test one assertion, please don’t. Even for unit test, one do not agree with one assertion per test function framework; what says simulation tests requires more than one assertions within a single function/simulation.

As usual, the simplest example is the Greeting App; so we’ll use that and write a simulation test for it.

Greeting Simulation test

You don’t need to write your own greeting app: just run this command:

npx create-near-app --contract=rust <your_project_name>

Violà! You now have a greeting app example with a simple frontend. Follow the instruction to deploy the contract and test around with it until you’re familiar before moving on. Checking out the last few lines of the output.

We suggest that you begin by typing: 
cd <your_project_name>
yarn dev

Copy the command and test out how the app works until you’re familiar with it. After testing, stop the deployment temporarily: it’s quite annoying to redeploy everytime you make a small change(s) from one’s point of view if we don’t need the frontend.

Create Simulation Test Folder

Without consider NEAR, simulation testing is also called integration testing in (pure) Rust. If you want to know more, please refer to The Rust Book or Rust By Example to check how to perform integration testing.

In short, you require a folder entirely outside of src called tests. So we’ll do that now. Assuming you already cd into your project directory, run this:

cd contract
mkdir tests 
mkdir tests/sim
touch tests/sim/main.rs

Navigate and open up <your_project_name>/contract/tests/sim/main.rs and open it up in the editor of your choice.

Now, you can either dump everything into one file, or you could split it. Generally, you have a utils.rs to make non-test functions that are reused. You can have another .rs file for multiple short tests. If you have longer tests, like the simulation tests from the lockup contract, one suggests one file per test; and name your file accordingly. You wouldn’t want your readers to see a file with 1000+ lines of code.

Now, even though the Greeting wouldn’t have long simulation tests, one will show you what one means by one test per file by deliberately separating them. You don’t have to do it, though.

Include Dependencies

We need a library called near-sdk-sim. This example uses near-sdk v3.1.0, so we’ll use the corresponding near-sdk-sim v3.2.0 (one don’t know why they don’t match, though). Bear in mind, however, if the version don’t match, simulation tests won’t run, because it says Import near_sdk::some_fn is not equal to near_sdk::some_fn, which is confusing (but actually near_sdk::some_fn is indeed different from near_sdk::some_fn: they have different versions!)

One actually likes writing with near-sdk v4.0.0-pre.4, but that requires some changes to the contract for it to run. The changes aren’t complicated, mostly about AccountId is no longer String and some other smaller stuffs; but that’s not the point of this article, so we’ll stick with 3.1.0 and perhaps update this article in the future if it updates. (Plus there’s some bug with v4 simulation which we’ll talk about later).

Let’s go to Cargo.toml: it should now look like this: (see new section dev-dependencies). It’s important to ensure rlib is present too!

[package]
name = "greeter"
version = "0.1.0"
authors = ["Near Inc <[email protected]>"]
edition = "2018"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
near-sdk = "3.1.0"

[dev-dependencies]
near-sdk-sim = "3.2.0"

[profile.release]
codegen-units = 1
# Tell `rustc` to optimize for small code size.
opt-level = "z"
lto = true
debug = false
panic = "abort"
# Opt into extra safety checks on arithmetic operations https://stackoverflow.com/a/64136471/249801
overflow-checks = true

[workspace]
members = []

Previously one wrote a guide on reducing contract size; including removing “rlib”. To use simulation tests, though, it requires “rlib”, so it’s a trade-off between contract size and doing simulation tests.

If you have stopped yarn dev, now compile the contract once to download the library. In contract, run:

cargo build

Make sure you have lots of disk space (recommended 20GB free space) as this takes up quite a lot of space!

For smooth experience during first-time compilation, try compiling on a machine that has 16 vCPU and 8 GB RAM. Especially librocksdb-sys will take a loooooooooooooooong time to compile. It isn’t clear whether compilation is transferable (certainly not across OS, but unsure within the same OS). Since one rented a VM on Azure, one could easily change the size temporarily and change it back to a smaller (and cheaper) size after compilation, hence no conflict.

Let’s move back to write the simulation tests in main.rs.

Prepare the wasm file

Please be aware: everytime you make changes to your contract, you need to rebuild the wasm. We made a script here to build your wasm file and move it to the top directory res folder.

So run this: (from the contract directory)

mkdir res 
touch build.sh

Then, include the content below inside contract/build.sh so you can run bash build.sh (inside contract folder) instead of typing the command out every time.

#!/bin/bash 
set -e 

export WASM=greeter.wasm 

RUSTFLAGS='-C link-arg=-s' cargo build --target wasm32-unknown-unknown --release 
cp target/wasm32-unknown-unknown/release/$WASM res/

Imports

This is easy: we treat the library as a library. Remember, those that are pub(crate) originally in the library cannot be used in simulation tests (since this is outside). It’s just like you compile it and someone is using your code. Ensure to import those functions that you need.

If we check the src.lib.rs, we see there’s (only) a Welcome struct. When you import it, you add the Contract keyword behind. For example:

use greeter::{
    WelcomeContract 
};

For the lockup contract, their main struct is called LockupContract, so when they import, it’s LockupContractContract. One do not know why they made it this way, perhaps for non-conflict; just add it!

Include contract files

The next thing before we test it works, is to include the wasm file. This is a must. Also, if you have a utils.rs, this should NOT be put there; otherwise you need to think hard how to make it discoverable from other files. To not think hard, we put it in main.rs:

// Directories are relative to top-level Cargo directory. 
near_sdk_sim::lazy_static_include_bytes! {
      GREETER_WASM_BYTES => "res/greeter.wasm" 
      // other wasm bytes.  
}

What one means by “top-level Cargo directory” means the contract directory. Certainly you can discover stuffs outside it with ../../some_file if you ever need it. For example, if you don’t use the res but the yarn dev, the out/main.wasm is outside the contract directory. To import that, we do:

near_sdk_sim::lazy_static_include_bytes! {
     GREETER_WASM_BYTES => "../out/main.wasm" 
}

Since this is a macro, ensure that you don’t accidentally put a “comma” (“,”) after the last item; otherwise you might get weird error messages and Rust refuses to compile.

Unfortunately, we couldn’t really test this function while it goes until we created the helper function (a complete MVP) and an MVP test.

Initialization Function

To not repeat the setup function, we include them inside basic_setup(). Check the basic_setup() of the lockup contract for another example (which includes deployment of other contracts than their main testing contract). Here, we’ll also do the same, but we don’t have other contract to setup so we’ll skip that and just include the necessary functions in basic_setup().

Make a utils.rs:

touch tests/sim/utils.rs

Inside utils.rs, we insert the content:

use crate::*; 

/// 300 TGas 
pub const MAX_GAS: u64 = 300_000_000_000_000; 

/// 1 NEAR (just a random number) 
pub const MIN_BALANCE_FOR_STORAGE: u128 = 1_000_000_000_000_000_000_000_000; 

pub const GREETER_ACCOUNT_ID: &str = "greeter"; 

pub(crate) fn basic_setup() -> (UserAccount, UserAccount) { 
  let mut genesis_config = GenesisConfig::default(); 
  genesis_config.block_prod_time = 0; 
  let root = init_simulator(Some(genesis_config)); 

  let alice = root.create_user( 
    "alice".to_string(), to_yocto("200") 
  ); 

  (root, alice) 
}

There are some important things to note here. The first is this block of code:

let mut genesis_config = GenesisConfig::default(); 
genesis_config.block_prod_time = 0; 
let root = init_simulator(Some(genesis_config));

The Genesis is the first block of the blockchain. However, genesis and genesis time isn’t really the same. For example, the root represents the blockchain itself. It’s not the near or testnet top-level account: it’s the blockchain. However, if you check out the explorer on testnet, we see that it’s created during the Genesis. So, the root comes first, then it’s packaged with some of the accounts during Genesis time. We create a simulator of the Genesis called root.

Here, what we mean by Genesis is not genesis time, but the “ultimate root”. It’s the “parent account” of all top-level accounts.

Usually, we don’t need to modify the GenesisConfig; and if you need, this is one example.

If you ever need to make changes to the genesis, check out the docs for values you can change. Then, you can modify it line 2 in the code block above by assigning each field a value. Finally, you need to initialize the simulator with init_simulator.

If you don’t need modification, you can initialize a simulator with no configuration (which will use the default) like this:

let root = init_simulator(None);

Next, we have the root creating an account called “alice” for us. The first argument is the account name, the second is how many NEAR to give to the account.

Because root is the Genesis, it can only create top-level accounts like neartestnetalice. It cannot create sub-accounts like alice.near: only the parent account near can create alice.near, not the Genesis.

One thing we don’t have here is deployment with root. For our contract, we use the deploy! macro which we’ll do in the test function instead of here. But if you have other wasm file, like the lockup contract does, they can’t use the deploy! macro, so this is how they did it.

For example on the whitelist contract; it’s deployed on root like this:

let _whitelist = root.deploy_and_init( 
  &WHITELIST_WASM_BYTES, 
  STAKING_POOL_WHITELIST_ACCOUNT_ID.to_string(), 
  "new", &json!({
    "foundation_account_id": foundation.valid_account_id(), 
  }).to_string().into_bytes(), 
  to_yocto("30"), 
  MAX_GAS, 
);

Because the requirement for an init function to be called once during deployment is so common, there’s a function deploy_and_init. If the contract does not have a deploy function (assuming whitelist doesn’t have one here), we can do this.

let _whitelist = root.deploy(
 &WHITELIST_WASM_BYTES,
 STAKING_POOL_WHITELIST_ACCOUNT_ID.parse().unwrap(),
 to_yocto("30") 
);

and in reality there’s no deploy_and_init function, so we call it manually. To do this, we need an account to call it who have the ability to do so. For the lockup, it’s the foundation

let foundation = root.create_user("foundation".to_string(), to_yocto("10000"));
 foundation.call(
   STAKING_POOL_WHITELIST_ACCOUNT_ID.to_string(),
   "new", 
   &json!({
      "foundation_account_id": foundation.valid_account_id()
   }).to_string().into_bytes(),
   MAX_GAS,
   NO_DEPOSIT, 
).assert_success();

We note that reality and simulation have some differences.

Lastly, don’t forget to import it to main.rs and import the required functions:

use near_sdk::Balance;
 use near_sdk_sim::runtime::GenesisConfig;
 use near_sdk_sim::{init_simulator, to_yocto, UserAccount};
 pub(crate) mod utils;
 use utils::*;

Building first test function

We’re ready to build first test function. First, import the required functions in main.rs:

use near_sdk_sim::{deploy};

This is the deploy macro.

Create a file for the test:

touch tests/sim/test_set_and_get_greeting.rs

As one comes from a Python background, one like to name functions starting with test. You don’t have to. Here, one adopt naming the file name starting with test_; while the action testing function inside without. Example, we’ll have a set_and_get_greeting() function inside test_set_and_get_greeting.rs (file).

Import the file into main.rs before we forget:

mod test_set_and_get_greeting;

We don’t need pub(crate) like utils does as it doesn’t need to share anything with other files.

The first thing we need in the set_and_get_greeting function is to deploy the contract.

 let greeter_amount = to_yocto("1000");
 let (root, alice) = basic_setup();

 let greeter = deploy!(
   contract: WelcomeContract, 
   contract_id: GREETER_ACCOUNT_ID.to_string(),
   bytes: &GREETER_WASM_BYTES,
   signer_account: root,
   deposit: MIN_BALANCE_FOR_STORAGE + greeter_amount 
);

If we have a custom #[init] method, we include these after deposit arguments:

gas: MAX_GAS,
 init_method: <method_name>(method_args)

However, if we don’t have, we remove them. For a bunch of traits that match the macro, check the docs. You need to match at least one of them; otherwise Rust refuses to compile.

(Question: Does the order matters? Or just the bunch of kwargs needs to match one of the traits?)

Note that unlike reality, the deployment is done by root again (you can see from signer_account). In reality, it’s done by some account responsible for it.

Next, let’s set a greeting and get the greeting and assert they’re as expected.

It seems like people like to assign to a variable call res which is reused over and over again. It’s not the clearest way; but we surely can do that to not cram our head and think of variable names. res just means “results” returned from a particular function call.

It’s a good practice to assign your res with a type, (irregardless of whether Rust can infer the type or not), so you know what type is returned.

Remember we have view_method and change_method in smart contract. For the contract deployed with deploy! (which is the smart contract you can import and the one you’re mainly testing), we can use view_method_call and function_call respectively. We’ll speak in a while if we have external wasm how to call.

Our set_greeting is a change_method, so we’ll use a function_call. A function_call takes in a PendingContractTxGas and Deposit.

The PendingContractTx is just the function, and other arguments are easy to interpret what it is. Let’s see our set_greeting:

let greeting: String = "Hello, NEAR!".to_owned();
alice.function_call(
  greeter.contract.set_greeting(greeting),
  MAX_GAS, 0 
).assert_success();

Ensure you pass in the respective arguments in the function. We also call assert_success() at the end to make sure the Promise is fulfilled. The above is imitating the near-cli:

near call $GREETER_CONTRACT set_greeting '{
  "message": "Hello, NEAR!"
 }' --gas=$MAX_GAS --amount=0 --accountId=$ALICE_ACCOUNT_ID

Then, we can have the view function call. If you check the function, get_greeting takes an Account Id of type String and returns a String.

let res: String = alice.view_method_call( 
  greeter.contract.get_greeting(alice.account_id().to_string())
 ).unwrap_json();

One suspects you don’t need .account_id().to_string(), just .account_id() is sufficient. Here, we’re just making it explicit because it takes in a String. If it takes in AccountId, we could just call .account_id() without any confusion. (Especially when AccountId no longer equals to String starting near-sdk-rs v4.)

As the result returned is a JSON, we unwrap it with unwrap_json().

Then, we could make assertions on the result.

assert_eq!(res, greeting);

Recall greeting is a variable we assigned earlier on, which is “Hello, NEAR!”.

Running the integration test

If you just want to run the integration test, run
cargo test --tests sim (because it’s in the tests/sim folder). If you want to run every test, including unit tests, run cargo test.

Note, for some reason it takes like 30 seconds or more (irregardless of how many CPU cores you have); you have to wait before the test even starts.

A reminder again: if you make changes to the contract, you need to rebuild it; otherwise you’ll wonder why it doesn’t run and you believe it will run now so…

Complete Code

You can find the complete code here: https://github.com/Wabinab/NEAR_greeter_integration_tests

Conclusion

Now, you can repeat other tests (if you have any) by creating new file, link it using mod to main.rs, write the tests inside. It’s a fun exercise: the more you write, the more you understand.

A note on upgrading to v4.0.0-pre.4

The whitelist contract deploy_and_init function needs changes on these:

AccountId is no longer String

  • So replace all "alice".to_string() with "alice".parse().unwrap(). If the replacement is inside a function which it cannot parse, you need to create a variable. (This is especially true in the deploy! macro, which has no type inference).

    let alice_account: AccountId = "alice".parse().unwrap(); 
    
    // pass it to the function. 
    • valid_account_id is deprecated. Use account_id() instead. This occurs in json!.

    • Any integer passed to json! requires specification. Example: v3.2.0 allows 10, but v4.0.0-pre.4 don’t allow: you must say 10u64 or any other types.

    • The #[quickcheck] macro has a bug and fails the test with v4. One file an error on Github; as of writing, the Dev team doesn’t reply yet.

    Feel free to check out the lockup contract simulation test on one’s book (currently in pre-Alpha as of writing) for perhaps more tips and tricks not listed here. (This is MVP, anyways).

    References

288
Scroll to Top