Testing async flows
Test NEAR cross-contract flows in a near-workspaces sandbox: a failing mock callee, gas starvation, per-receipt outcomes and logs, and yield timeouts.
Advanced18 min read3-question check
Unit tests with testing_env! run a single method in isolation. They can show which promises a method builds, but they never execute them, so they cannot tell you whether your callback really restores a balance when the token contract panics. For that you need a sandbox: a local NEAR node where real receipts run across real contracts. In Rust that is near-workspaces.
The trick that makes async tests useful is a mock callee you can make fail on demand. Every interesting branch of a callback is a failure branch.
A callee that fails on command#
use near_sdk::json_types::U128;
use near_sdk::{log, near, require, AccountId};
#[near(contract_state)]
#[derive(Default)]
pub struct MockFt {
fail: bool,
}
#[near]
impl MockFt {
pub fn set_fail(&mut self, fail: bool) {
self.fail = fail;
}
#[payable]
pub fn ft_transfer(&mut self, receiver_id: AccountId, amount: U128, memo: Option<String>) {
let _ = memo;
require!(!self.fail, "mock: transfer failed");
log!("mock: sent {} to {}", amount.0, receiver_id);
}
}The contract under test is a vault like the one in Callbacks don’t roll back the caller: new(token), a #[private] credit(account_id, amount) for seeding balances, balance_of(account_id) -> U128, and withdraw(amount) -> Promise, which refuses to start with less than 25 Tgas, debits, calls ft_transfer, and chains on_withdraw. The callback logs refunded and restores the balance on failure, and returns true or false.
Add near-workspaces (with its unstable feature, which provides compile_project), tokio, anyhow and serde_json as dev-dependencies of the test crate.
Three tests every call flow needs#
use near_workspaces::network::Sandbox;
use near_workspaces::result::ExecutionFinalResult;
use near_workspaces::types::Gas;
use near_workspaces::{Account, Contract, Worker};
use serde_json::json;
struct Env {
_worker: Worker<Sandbox>, // keep the sandbox alive for the whole test
vault: Contract,
ft: Contract,
alice: Account,
}
async fn setup() -> anyhow::Result<Env> {
let worker = near_workspaces::sandbox().await?;
let vault_wasm = near_workspaces::compile_project("./contracts/vault").await?;
let ft_wasm = near_workspaces::compile_project("./contracts/mock-ft").await?;
let vault = worker.dev_deploy(&vault_wasm).await?;
let ft = worker.dev_deploy(&ft_wasm).await?;
let alice = worker.dev_create_account().await?;
vault.call("new").args_json(json!({ "token": ft.id() })).transact().await?.into_result()?;
// called by the vault account itself, so #[private] lets it through
vault
.call("credit")
.args_json(json!({ "account_id": alice.id(), "amount": "100" }))
.transact()
.await?
.into_result()?;
Ok(Env { _worker: worker, vault, ft, alice })
}
async fn balance(env: &Env) -> anyhow::Result<String> {
let b: String = env
.vault
.view("balance_of")
.args_json(json!({ "account_id": env.alice.id() }))
.await?
.json()?;
Ok(b)
}
async fn withdraw(env: &Env, amount: &str, tgas: u64) -> anyhow::Result<ExecutionFinalResult> {
Ok(env
.alice
.call(env.vault.id(), "withdraw")
.args_json(json!({ "amount": amount }))
.gas(Gas::from_tgas(tgas))
.transact()
.await?)
}
/// Print one line per receipt: who ran it, gas, failure, logs.
fn dump(res: &ExecutionFinalResult) {
for o in res.receipt_outcomes() {
println!(
"{} burnt {} Tgas failed={} logs={:?}",
o.executor_id, o.gas_burnt.as_tgas(), o.is_failure(), o.logs
);
}
}
#[tokio::test]
async fn happy_path_debits() -> anyhow::Result<()> {
let env = setup().await?;
let res = withdraw(&env, "40", 100).await?;
dump(&res);
assert!(res.receipt_failures().is_empty());
assert!(res.json::<bool>()?);
assert_eq!(balance(&env).await?, "60");
Ok(())
}
#[tokio::test]
async fn failing_callee_is_rolled_back() -> anyhow::Result<()> {
let env = setup().await?;
env.ft.call("set_fail").args_json(json!({ "fail": true })).transact().await?.into_result()?;
let res = withdraw(&env, "40", 100).await?;
dump(&res);
// The transaction SUCCEEDS: its result is the callback's return value...
assert!(res.is_success());
// ...but one receipt inside it failed: ft_transfer on the mock token.
let failures = res.receipt_failures();
assert_eq!(failures.len(), 1);
assert_eq!(&failures[0].executor_id, env.ft.id());
assert!(res.logs().iter().any(|l| l.contains("refunded")));
// Guard the rollback path's gas: it must stay well under its 10 Tgas reserve.
let callback = res
.receipt_outcomes()
.iter()
.find(|o| o.logs.iter().any(|l| l.contains("refunded")))
.expect("callback outcome");
assert!(callback.gas_burnt.as_tgas() < 7, "rollback path is too close to its reserve");
assert!(!res.json::<bool>()?);
assert_eq!(balance(&env).await?, "100");
Ok(())
}
#[tokio::test]
async fn too_little_gas_is_refused_up_front() -> anyhow::Result<()> {
let env = setup().await?;
let res = withdraw(&env, "40", 15).await?;
assert!(res.is_failure());
assert_eq!(balance(&env).await?, "100"); // nothing was debited
Ok(())
}Reading outcomes#
| API | Gives you |
|---|---|
is_success() / is_failure() | The final status of the transaction (the end of the return chain) |
json::<T>(), borsh::<T>(), raw_bytes() | The final return value, decoded. Consumes the result, so call it last |
receipt_outcomes() | One ExecutionOutcome per receipt: executor_id, gas_burnt, logs, receipt_ids, is_failure() |
receipt_failures() | Only the receipts that failed |
logs() | Logs from the transaction and every receipt, in one list |
total_gas_burnt | Gas burnt by the whole tree: the number to budget against |
ExecutionFinalResult you will use most- Name receipts by executor. In a call/callback flow, filter outcomes by
executor_idand by a log line you emit on purpose. - Turn measurements into assertions. The
gas_burnt < 7check above makes a heavier rollback path fail CI before it can fail on mainnet. See Measure gas. - Test interleavings explicitly. To simulate another transaction landing between call and callback, make the mock callee call back into the vault, or send a second transaction with
transact_async()before awaiting the first.
Testing yield timeouts#
A yielded transaction does not finish until it is resumed or 200 blocks pass. Send it with transact_async() and poll its status. As the near-sdk repository’s own MPC example notes, the status can report the transaction as ready before the callback has run, so wait for the callback’s log too.
use std::task::Poll;
use std::time::Duration;
use near_workspaces::types::{Gas, NearToken};
use serde_json::json;
#[tokio::test]
#[ignore = "waits for the 200-block yield timeout"]
async fn unanswered_request_times_out() -> anyhow::Result<()> {
let worker = near_workspaces::sandbox().await?;
let wasm = near_workspaces::compile_project("./contracts/oracle").await?;
let oracle = worker.dev_deploy(&wasm).await?;
let answerer = worker.dev_create_account().await?;
let alice = worker.dev_create_account().await?;
oracle
.call("new")
.args_json(json!({ "worker": answerer.id() }))
.transact()
.await?
.into_result()?;
let pending = alice
.call(oracle.id(), "ask")
.args_json(json!({ "question": "2+2?" }))
.deposit(NearToken::from_millinear(10))
.gas(Gas::from_tgas(100))
.transact_async()
.await?;
let start = worker.view_block().await?.height();
loop {
tokio::time::sleep(Duration::from_secs(2)).await;
if let Poll::Ready(res) = pending.status().await? {
if res.logs().iter().any(|l| l.contains("timed out")) {
assert_eq!(res.json::<Option<String>>()?, None);
break;
}
}
let waited = worker.view_block().await?.height() - start;
anyhow::ensure!(waited < 260, "callback did not run after {} blocks", waited);
}
Ok(())
}Check yourself
3 questions · progress saved in this browser