Near Learn

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#

tests/crowdfund.rs (part 1) — setup and small helpers
Rust
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 test always 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#

tests/crowdfund.rs (part 2) — the tests
Rust
#[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(())
}
Run them
Shell
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 have
HintExercise: 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

  1. 1.What does worker.fast_forward(100) do in the sandbox?
  2. 2.Why are the payout tests written with near-workspaces rather than mocked unit tests?
  3. 3.A refund test asserts only outcome.is_success(). What can it miss?