Sandbox tests with near-workspaces
Test the crowdfunding contract in a local NEAR sandbox with near-workspaces: deploy, pledge, fast_forward past the deadline, claim, refund and failure paths.
Intermediate14 min read3-question check
Unit tests with a mocked environment are handy for pure logic, but they cannot run the part of this contract that matters most: promises and callbacks across receipts. For that you need a real node. near-workspaces starts a local NEAR sandbox, deploys your wasm, creates accounts with 100 NEAR each, and lets you move time forward.
In this milestone you replace tests/test_basics.rs with tests for both endings of a campaign, including the calls that must fail.
Helpers#
use near_workspaces::network::Sandbox;
use near_workspaces::types::NearToken;
use near_workspaces::{Account, Contract, Worker};
use serde_json::{json, Value};
type TestResult = Result<(), Box<dyn std::error::Error>>;
/// Deploys and initializes a fresh contract in its own sandbox.
async fn setup() -> Result<(Worker<Sandbox>, Contract), Box<dyn std::error::Error>> {
let sandbox = near_workspaces::sandbox().await?;
let wasm = near_workspaces::compile_project("./").await?;
let contract = sandbox.dev_deploy(&wasm).await?;
let outcome = contract.call("new").transact().await?;
assert!(outcome.is_success(), "init failed: {outcome:?}");
Ok((sandbox, contract))
}
/// Creates a NEAR campaign that ends `duration_ms` from now. Returns its id.
async fn create(contract: &Contract, creator: &Account, goal_near: u128, duration_ms: u64)
-> Result<u32, Box<dyn std::error::Error>> {
let id = creator
.call(contract.id(), "create_campaign")
.args_json(json!({
"title": "Test campaign",
"goal": NearToken::from_near(goal_near).as_yoctonear().to_string(),
"duration_ms": duration_ms,
"token": null,
}))
.deposit(NearToken::from_millinear(100)) // storage; the excess comes back
.transact()
.await?
.json::<u32>()?;
Ok(id)
}
async fn pledge(contract: &Contract, backer: &Account, id: u32, near: u128) -> TestResult {
let outcome = backer
.call(contract.id(), "pledge")
.args_json(json!({ "campaign_id": id }))
.deposit(NearToken::from_near(near))
.transact()
.await?;
assert!(outcome.is_success(), "pledge failed: {outcome:?}");
Ok(())
}
/// Produces blocks until the chain's timestamp is past `deadline_ms`.
async fn advance_past(sandbox: &Worker<Sandbox>, deadline_ms: u64) -> TestResult {
while sandbox.view_block().await?.timestamp() / 1_000_000 <= deadline_ms {
sandbox.fast_forward(100).await?;
}
Ok(())
}
async fn campaign(contract: &Contract, id: u32) -> Result<Value, Box<dyn std::error::Error>> {
Ok(contract.view("get_campaign").args_json(json!({ "campaign_id": id })).await?.json()?)
}- `compile_project("./")` builds the contract’s wasm from inside the test, so
cargo testalways tests your current code. - `dev_deploy` creates a fresh account and deploys to it; `dev_create_account` creates funded test accounts.
- `advance_past` does not assume how much time a block adds. It reads the latest block’s timestamp (nanoseconds) and fast-forwards until the deadline has passed — robust against sandbox block-time settings.
- `.json::<u32>()` on a transaction outcome decodes the method’s return value, and fails the test if the call failed.
Both endings, and the calls that must fail#
#[tokio::test]
async fn successful_campaign_pays_the_creator() -> TestResult {
let (sandbox, contract) = setup().await?;
let creator = sandbox.dev_create_account().await?;
let alice = sandbox.dev_create_account().await?;
let id = create(&contract, &creator, 5, 60_000).await?;
pledge(&contract, &alice, id, 6).await?;
// claiming early must fail
let early = creator.call(contract.id(), "claim").args_json(json!({ "campaign_id": id }))
.max_gas().transact().await?;
assert!(early.is_failure());
let deadline = campaign(&contract, id).await?["deadline_ms"].as_u64().unwrap();
advance_past(&sandbox, deadline).await?;
assert_eq!(campaign(&contract, id).await?["status"], "Succeeded");
let before = creator.view_account().await?.balance;
let outcome = creator.call(contract.id(), "claim").args_json(json!({ "campaign_id": id }))
.max_gas().transact().await?;
assert!(outcome.is_success(), "claim failed: {outcome:?}");
assert!(outcome.logs().iter().any(|l| l.contains(r#""event":"claimed""#)));
let after = creator.view_account().await?.balance;
// ~6 NEAR minus storage and gas
assert!(after.as_yoctonear() > before.as_yoctonear() + NearToken::from_millinear(5_900).as_yoctonear());
Ok(())
}
#[tokio::test]
async fn failed_campaign_refunds_backers() -> TestResult {
let (sandbox, contract) = setup().await?;
let creator = sandbox.dev_create_account().await?;
let alice = sandbox.dev_create_account().await?;
let id = create(&contract, &creator, 50, 60_000).await?;
pledge(&contract, &alice, id, 3).await?;
let deadline = campaign(&contract, id).await?["deadline_ms"].as_u64().unwrap();
advance_past(&sandbox, deadline).await?;
assert_eq!(campaign(&contract, id).await?["status"], "Failed");
// pledging after the deadline is rejected
let late = alice.call(contract.id(), "pledge").args_json(json!({ "campaign_id": id }))
.deposit(NearToken::from_near(1)).transact().await?;
assert!(late.is_failure());
// the creator cannot claim a failed campaign
let claim = creator.call(contract.id(), "claim").args_json(json!({ "campaign_id": id }))
.max_gas().transact().await?;
assert!(claim.is_failure());
let before = alice.view_account().await?.balance;
let refund = alice.call(contract.id(), "refund").args_json(json!({ "campaign_id": id }))
.max_gas().transact().await?;
assert!(refund.is_success(), "refund failed: {refund:?}");
let after = alice.view_account().await?.balance;
assert!(after.as_yoctonear() > before.as_yoctonear() + NearToken::from_millinear(2_900).as_yoctonear());
// a second refund has nothing left to pay
let again = alice.call(contract.id(), "refund").args_json(json!({ "campaign_id": id }))
.max_gas().transact().await?;
assert!(again.is_failure());
Ok(())
}cargo test --test crowdfund
# the first run downloads a sandbox binary for your platform;
# set NEAR_SANDBOX_BIN_PATH to use a neard-sandbox you already haveHintExercise: test a failed payout and its callback
The realistic way to make a payout fail is a token receiver that is not registered with the token. Deploy a NEP-141 token in the sandbox (for example a build of the reference fungible-token contract), register the crowdfund contract with it, and create a token campaign that will miss its goal.
Let alice call register_backer, then pledge her whole token balance with ft_transfer_call. With a zero balance she can call storage_unregister on the token (1 yoctoNEAR attached) — now she cannot receive it. After the deadline, call refund: the transaction still succeeds, because its final result is on_refund’s return value — assert .json::<bool>() is false and that get_pledge still returns her full pledge, because on_refund restored it. Finally re-register her with storage_deposit and assert the retried refund returns true.
Check yourself
3 questions · progress saved in this browser