Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Stellar · Soroban · Test tooling

Soroban Testkit

Soroban Testkit is an open-source testing and debugging framework for Soroban smart contracts. It layers ergonomic fixtures, assertions, budget introspection and property-testing strategies on top of the official SDK, so contract tests are shorter, faster to write, and far closer to how they behave on-chain.

Soroban SDK v28 Rust 1.91+ 4 workspace crates Stellar Wave funded

Why Testkit

The Soroban SDK ships solid primitives — Env::default(), testutils, event inspection — but day-to-day contract testing still has rough edges that every team re-solves on its own.

Verbose setup Every test manually creates an environment, registers contracts, generates addresses and seeds state. Solved by soroban-testkit-fixtures — a Foundry-style `setUp` context builder.
Raw assertions Matching events and authorizations means destructuring tuples by hand with no domain helpers. Solved by soroban-testkit-assert — chainable event and auth matchers.
Hidden resource costs Tests pass locally yet fail on-chain once the CPU or memory budget is exhausted. Solved by soroban-testkit-core — budget snapshots and diffs around any call.
Heavyweight mocking Stubbing a single dependency forces you to write and register an entire mock contract. Solved by soroban-testkit-core — error decoding and storage inspection helpers.
No property-testing support Soroban-aware generators have to be rebuilt from scratch in every project. Solved by soroban-testkit-generators — `proptest` and `arbitrary` strategies for SDK types.

The Crates

Each crate is independent — adopt one, or combine all four for a complete testing stack.

soroban-testkit-core

Inspect & measure

Budget snapshots with call-level diffs, on-chain error decoding, and storage inspection across instance, persistent and temporary tiers.

BudgetSnapshotDecodedError
soroban-testkit-assert

Assert fluently

Readable matchers for contract events and authorizations, filterable by contract id and topic.

EventMatcherAuthMatcher
soroban-testkit-fixtures

Set up once

A reusable test context plus a builder for environments, admin accounts and generated users.

TestContextTestContextBuilder
soroban-testkit-generators

Generate wildly

Property-testing strategies for token amounts, ledger sequences and timestamps that respect Soroban limits.

proptestarbitrary

Architecture

soroban-testkit/                    Cargo workspace, resolver 2
├── crates/soroban-testkit-core             budget snapshots · error decoding · storage
├── crates/soroban-testkit-assert           event matchers · authorization matchers
├── crates/soroban-testkit-fixtures         test context · context builder
└── crates/soroban-testkit-generators       proptest strategies · arbitrary impls

A First Look

Six lines to a real test

Build a context, register the contract, invoke a call, then assert on events and budget in the same breath.

#![allow(unused)]
fn main() {
use soroban_testkit_assert::events::EventMatcher;
use soroban_testkit_fixtures::TestContext;

#[test]
fn transfer_emits_event() {
    let ctx = TestContext::new();
    // arrange: register your contract against ctx.env
    // act: invoke the contract client

    EventMatcher::new(&ctx.env).assert_emitted();
}
}

Full walkthroughs live in Quick Start, Testing Patterns and Budget-Aware Testing.

Stellar Wave

This project is funded through the Stellar Wave program on Drips Network. Every issue carries a complexity label — Trivial 100 pts Medium 150 pts High 200 pts — and rewards land once your PR is merged.

Pick up an issue

Built and maintained by Stellar Crucible · Apache-2.0 licensed · contributions welcome.

Installation

Add only the crates your test suite needs. All four are published on crates.io as soroban-testkit-core, soroban-testkit-assert, soroban-testkit-fixtures and soroban-testkit-generators. If you would rather pin the source, every release is also tagged — a tag points at a commit whose CI (tests, clippy and the cargo deny supply-chain gate) is green.

Rust1.91 or newer
Soroban SDK28.x
Edition2021
LicenseApache-2.0

Add the dev-dependencies

[dev-dependencies]
soroban-testkit-core = "0.3.0"
soroban-testkit-assert = "0.3.0"
soroban-testkit-fixtures = "0.3.0"
soroban-testkit-generators = { version = "0.3.0", features = ["proptest"] }

The same set pinned to the release tag:

[dev-dependencies]
soroban-testkit-core = { git = "https://github.com/stellar-crucible/soroban-testkit", tag = "v0.3.0" }
soroban-testkit-assert = { git = "https://github.com/stellar-crucible/soroban-testkit", tag = "v0.3.0" }
soroban-testkit-fixtures = { git = "https://github.com/stellar-crucible/soroban-testkit", tag = "v0.3.0" }
soroban-testkit-generators = { git = "https://github.com/stellar-crucible/soroban-testkit", tag = "v0.3.0", features = ["proptest"] }

Install steps

  1. Check your toolchain

    Soroban SDK v28 needs a recent Cargo. Run rustc --version and upgrade with rustup update stable if it reports anything below 1.91.

  2. Pin the SDK your contract already uses

    Testkit is built against SDK v28. Match the soroban-sdk version in your contract crate so the testutils features resolve to one copy.

  3. Enable testutils in tests

    Address generation, event inspection and auth recording are gated behind the SDK's testutils feature — the dev-dependency above turns it on for the test profile only.

  4. Verify the wiring

    Run cargo test. A green compile means the crates are linked; the Quick Start gives you a first test to paste in.

Feature flags

soroban-testkit-generators

FeatureDescription
proptest (default)Enable proptest strategies
arbitraryEnable arbitrary trait implementations
Versioning

Testkit is pre-1.0. A 0.x release may break the API, so pin an exact version or a release tag once your suite is green — cargo update can otherwise pull breaking changes from a newer 0.x.

Quick Start

Write a complete Soroban test in about a minute: build a context, register the contract, invoke it, then assert on events and resource cost in the same test.

Time~5 minutes
Crates usedfixtures · assert · core
PrerequisiteInstallation done

Imports

#![allow(unused)]
fn main() {
use soroban_sdk::{Env, Address};
use soroban_testkit_fixtures::builder::TestContextBuilder;
use soroban_testkit_assert::events::EventMatcher;
use soroban_testkit_core::budget::BudgetSnapshot;
}

Write a test

  1. Build the context

    TestContextBuilder hands you a mocked Env plus ready-made addresses.

  2. Register the contract

    Bind your contract to the context environment and create its client.

  3. Invoke the contract

    Call the function under test, then read the metering that call left behind.

  4. Assert on events and cost

    Match the events the call emitted and check its instruction count against a ceiling.

#![allow(unused)]
fn main() {
#[test]
fn test_token_transfer() {
    // 1. Create a test context with pre-generated users
    let ctx = TestContextBuilder::new()
        .with_users(2)
        .build();

    let sender = &ctx.users[0];
    let receiver = &ctx.users[1];

    // 2. Register your contract
    let contract_id = ctx.env.register(MyContract, ());
    let client = MyContractClient::new(&ctx.env, &contract_id);

    // 3. Invoke the function under test
    client.transfer(sender, receiver, &1000);

    // 4. Read the metering that invocation left behind
    let cost = BudgetSnapshot::last_invocation(&ctx.env);

    // 5. Assert events were emitted, and what they carried
    EventMatcher::new(&ctx.env)
        .from_contract(&contract_id)
        .with_topic("Transfer")
        .assert_emitted();
    EventMatcher::new(&ctx.env)
        .with_topic("Transfer")
        .assert_data_matches(|data| data.deserialize::<i128>() == Some(1_000));

    // 6. Check resource consumption
    assert!(cost.cpu_insns < 500_000);
}
}
Expected output

The test passes with no extra scaffolding — no manual Env construction, no tuple destructuring over env.events().all(), no separate budget harness.

What’s happening

TestContextBuilder

Setup, once

Eliminates manual Env creation, address generation and auth mocking. Opt out of mocked auth when a test needs explicit authorization.

EventMatcher

Readable assertions

Replaces raw tuple iteration over emitted events with a chainable filter by contract and topic.

BudgetSnapshot

Costs made visible

Surfaces the per-call CPU-instruction and memory costs that standard Soroban tests never show.

Composable

Adopt piecemeal

Each crate stands alone. Start with fixtures, add assertions when your tests grow, keep budget checks for CI.

Next steps

Testing patterns

Layouts for unit, integration and contract-interaction tests.

Budget-aware testing

Turn resource regressions into ordinary failing assertions.

Property testing

Generate Soroban-aware inputs with proptest strategies.

soroban-testkit-core

Budget tracking, error decoding and storage inspection — the primitives the rest of Testkit is built on.

Packagesoroban-testkit-core
Modulesbudget · error · storage
Dependenciessoroban-sdk · soroban-env-host · serde · serde_json

API at a glance

TypePurpose
BudgetSnapshotCPU instructions and memory bytes — last_invocation() for the call that just ran
BudgetReadTrait implemented by any source capture() can read: invocation resources, the SDK budget, the host Budget
BudgetGuardCeilings and a baseline tolerance for one named operation
BudgetViolation, ViolationKindThe limit a cost broke, rendered as one parseable line
BudgetBaselineRecorded costs per case, loaded from and saved to a JSON file
budget_guard!Macro form of the guard around a single invocation
DecodedErrorA Soroban error code split into category, meaning and context
ErrorRegistryYour #[contracterror] codes mapped to the words they mean
ClientOutcome, unwrap_decodedUnwrap a v28 try_* client call, panicking with the decoded error
StorageEntry, StorageTierA live storage key, its rendered value, tier and expiry ledger
StorageSnapshotEvery live entry of one contract, captured at a point in time
StorageDiff, StorageChangeKeys added, removed and rewritten between two snapshots

Budget snapshots

The SDK meters one top-level invocation at a time, and the reading a test wants is usually the call that just finished:

#![allow(unused)]
fn main() {
use soroban_testkit_core::budget::BudgetSnapshot;

client.increment(&caller, &1);
let cost = BudgetSnapshot::last_invocation(&env);

println!("CPU instructions: {}", cost.cpu_insns);
println!("Memory bytes: {}", cost.mem_bytes);
}

capture() takes anything implementing BudgetRead — those invocation resources, the cumulative env.cost_estimate().budget(), and a soroban_env_host::budget::Budget you charge by hand. diff() saturates at zero, so a later snapshot that reads smaller never underflows an assertion.

SDK v28 note

env.budget() is deprecated; go through env.cost_estimate(). And read the numbers as a comparison between builds rather than as a fee quote: a contract registered as a native test contract is never metered through the VM, so wasm instantiation, execution and rent reads are absent from the reading.

Guards: turning a measurement into a limit

A number on its own passes whatever it is. BudgetGuard states the two things a test can actually act on — an absolute ceiling, and how far the cost may have grown relative to the last time it was recorded.

#![allow(unused)]
fn main() {
use soroban_testkit_core::budget::{BudgetGuard, BudgetSnapshot};

let cost = BudgetSnapshot { cpu_insns: 1_500_000, mem_bytes: 200_000 };

BudgetGuard::new("transfer")          // the name every failure line carries
    .cpu_ceiling(2_000_000)
    .mem_ceiling(500_000)
    .baseline(Some(previous_cost))    // `None` for a case not recorded yet
    .tolerance_percent(10)            // 0 means "not one instruction more"
    .assert_within(&cost);
}

run() makes the call, reads the metering that call left behind, and returns whatever the invocation returned:

#![allow(unused)]
fn main() {
let total = BudgetGuard::new("increment")
    .cpu_ceiling(20_000_000)
    .run(&env, || client.increment(&caller, &1));
}

The budget_guard! macro is the same call written around the invocation, with the limits in a block:

#![allow(unused)]
fn main() {
use soroban_testkit_core::budget_guard;

budget_guard!(&env, "increment", { cpu_max: 20_000_000, mem_max: 20_000_000 }, || {
    client.increment(&caller, &1)
});
}
Every breach, not just the first

violations() returns all limits one cost breaks — ceilings first, then growth — and assert_within() panics with one line per breach. A change that costs both CPU and memory is found in one run, and the lines are key=value pairs, so CI can grep them.

BUDGET kind=growth case=transfer metric=cpu_insns actual=1500000 limit=1100000 baseline=1000000 tolerance_percent=10

Baselines: recording cost so a regression is visible

BudgetBaseline is a map of case name to recorded cost that reads and writes a JSON file, so the numbers a suite enforces live in the repository rather than in a test’s memory.

#![allow(unused)]
fn main() {
use soroban_testkit_core::budget::BudgetBaseline;

let baseline = BudgetBaseline::load(std::path::Path::new("tests/budget.json"))?;

for (case, cost) in baseline.cases() {
    baseline.guard(case).tolerance_percent(5).run(&env, || invoke(case));
}
}

The file format is one version field and one map of costs:

{
  "version": 1,
  "cases": {
    "increment": { "cpu_insns": 32669, "mem_bytes": 5252 }
  }
}

Cases are keyed by name and iterated in name order, so a rewritten file stays a readable diff. A file whose version is newer than the loader is refused with BaselineError::UnsupportedVersion rather than silently read with missing cases.

Recording a baseline

record() and save() write the file; what refreshes it is a policy choice. Committing the file and failing on drift keeps the numbers honest, so most projects regenerate it in one deliberate commit rather than on every test run — Budget-Aware Testing walks through both. The pattern is applied in this repository: examples/counter/budget.json holds the counter's two hot paths, two #[ignore]d tests record and check it, and the Budget baseline workflow runs the check on one pinned runner and reports the numbers on the pull request.

Error decoding

Soroban reports failures as a packed integer. DecodedError::from_error splits it into the category the host raised it in and the code’s known meaning:

#![allow(unused)]
fn main() {
use soroban_testkit_core::error::DecodedError;

// A v28 client's `try_*` method reports the contract's own error as `Err(Ok(error))`.
let error = client.try_withdraw(&amount).unwrap_err().unwrap();

println!("{}", DecodedError::from_error(&error));
// Soroban Error [storage/3]: MissingValue — a required value was not provided (errors accessing host storage)
}

The ten categories the protocol defines (contract, wasm_vm, context, storage, object, crypto, events, budget, value, auth) and the ten standard codes (ArithDomain, IndexBounds, InvalidInput, MissingValue, ExistingValue, ExceededLimit, InvalidAction, InternalError, UnexpectedType, UnexpectedSize) are all described. An unrecognised number falls back to a sentence naming it rather than a panic.

Your own error codes

A #[contracterror] enum hands the host an integer with no words attached, so the vocabulary is yours to supply. ErrorRegistry is that mapping:

#![allow(unused)]
fn main() {
use soroban_testkit_core::error::{ErrorRegistry, DecodedError};

let registry = ErrorRegistry::new()
    .register(101, "Insufficient balance")
    .register(102, "Contract is paused");

let decoded = DecodedError::from_error_with(&error, &registry);
assert_eq!(decoded.to_string(), "Soroban Error [101]: Insufficient balance");
}

Without a registry a contract code still decodes — it just says the code is unregistered, which beats reading Error(3) and going hunting.

In test output

unwrap_decoded unwraps a v28 try_* client call and panics with the decoded sentence, so a failing call reads as a diagnosis in the test log rather than as nested Result debug output:

#![allow(unused)]
fn main() {
use soroban_testkit_core::error::unwrap_decoded;

let total: u32 = unwrap_decoded(client.try_withdraw(&amount));
// panicked at 'Soroban Error [budget/5]: ExceededLimit — a gas or size limit was hit (errors relating to budget limits)'
}

The nesting you see at that call site — Err(Ok(error)) — is the SDK’s own shape for a client method whose contract function returns Result; ClientOutcome<T> is the alias Testkit names it with. unwrap_decoded_with is the same call against an ErrorRegistry, so your own codes come out as words.

DecodedError implements Display, so it also reads well inside assert! messages:

#![allow(unused)]
fn main() {
use soroban_testkit_core::error::DecodedError;

let err = DecodedError {
    code: 12,
    category: "contract",
    message: "Insufficient balance".to_string(),
    context: Some("transfer".to_string()),
};
assert!(format!("{}", err).contains("Insufficient balance"));
}

Storage snapshots and diffs

Capture everything a contract holds, then compare two captures around an operation. Keys and values are rendered to stable strings (u32 becomes "42", a map becomes "{a: 1, b: 2}"), so a diff reports what changed rather than two opaque blobs. Instance storage is unfolded entry by entry, and each entry carries the ledger sequence it expires at.

#![allow(unused)]
fn main() {
use soroban_testkit_core::storage::{inspect_storage, StorageSnapshot, StorageTier};

let entries = inspect_storage(&env, &contract);
for entry in &entries {
    println!("{} = {} ({})", entry.qualified(), entry.value, entry.key);
}

let before = StorageSnapshot::capture(&env, &contract);
client.bump();
let after = StorageSnapshot::capture(&env, &contract);

let diff = before.diff(&after);
assert!(diff.added().is_empty() && diff.removed().is_empty());
assert_eq!(diff.modified()[0].after, "43");

// Or assert directly; failures list what actually happened.
before.assert_entry_removed(&after, "pending");
after.assert_unchanged(&StorageSnapshot::capture(&env, &contract));
assert_eq!(after.in_tier(StorageTier::Temporary).len(), 1);
}
Scope

A snapshot covers one contract only: other contracts' data, the WASM ContractCode entry and ledger entries the contract does not own are never included. Account addresses own no contract data, so they capture as empty instead of failing.


Next: soroban-testkit-assert — turn these measurements into readable assertions.

soroban-testkit-assert

Fluent matchers for contract events and authorizations, so assertions read like the behaviour you expect.

Packagesoroban-testkit-assert
Modulesevents · auth
Stylechainable builder

API at a glance

MethodAsserts
EventMatcher::assert_emitted()At least one matching event exists
EventMatcher::assert_not_emitted()No matching event exists
EventMatcher::assert_none_match(pred)No matching event satisfies a predicate
EventMatcher::assert_count(n)Exactly n events were emitted
EventMatcher::assert_data_matches(pred)One event’s payload satisfies a predicate
EventMatcher::from_contract(addr)Restricts matches to one contract address
EventMatcher::with_topic(t)Restricts matches to events carrying that topic symbol
EventLog::collect()Gathers one invocation’s events into a set spanning calls
EventLog::matcher()A matcher over everything that log collected
AuthMatcher::assert_no_auth_required()The call needed no authorizations
AuthMatcher::assert_auth_count(n)Exactly n authorizations were recorded

Event matching

Replace raw tuple iteration with a fluent API:

#![allow(unused)]
fn main() {
use soroban_testkit_assert::events::EventMatcher;

// Assert at least one event was emitted
EventMatcher::new(&env).assert_emitted();

// Assert exact count
EventMatcher::new(&env).assert_count(3);

// Filter by contract and topic
EventMatcher::new(&env)
    .from_contract(&contract_id)
    .with_topic("Transfer")
    .assert_emitted();
}
Scope: the invocation that just ran

EventMatcher::new(&env) reads env.events().all(), and in SDK v28 that returns the events of the most recent contract invocation. Assert right after each call, as the examples here do. To judge several calls as one set, collect them into an EventLog — same filters, same assertions, wider scope.

Reading events in v28

env.events().all() now returns ContractEvents. Call .events() on it to get the slice, and import soroban_sdk::testutils::Events as _ — the method is feature-gated behind testutils. Testkit applies the contract and topic filters for you.

Asserting across several invocations

A multi-step test usually wants a sentence about a whole sequence: “these three calls emitted exactly two Transfer events, and nothing was ever refunded”. EventLog gathers the events of each call the test means to judge, and hands the set to the same matcher:

#![allow(unused)]
fn main() {
use soroban_testkit_assert::events::{EventLog, EventMatcher};

let mut log = EventLog::new(&env);

client.transfer(&from, &to, &100);
log.collect();
client.close_offer(&from);
log.collect();

// Every filter and assertion reads the sequence as one set.
log.matcher().assert_count(2);
log.matcher().with_topic("Transfer").assert_count(1);
log.matcher().from_contract(&contract_id).assert_emitted();
log.matcher().with_topic("Refund").assert_not_emitted();

// The latest call alone still works, unchanged.
EventMatcher::new(&env).with_topic("CloseOffer").assert_count(1);
}

Collection is explicit because the SDK gives a test no per-invocation hook: a call nobody collected from adds nothing to the log, and a call that published nothing adds nothing either. That is what lets an assertion over a log mean “not anywhere in this test” rather than “not in the last call”.

MethodGives
EventLog::new(&env)An empty log
log.collect()Appends the events of the invocation that just ran
log.matcher()An EventMatcher over everything collected so far
log.topics()Vec<Vec<String>> — one entry per event, its topic symbols
log.events()The collected ContractEvent values
log.len() / log.is_empty()How much has been gathered

Events stay in call order — one contiguous run per collected invocation, in the order the contract published them — so topics() is what a sequence assertion reads:

#![allow(unused)]
fn main() {
let sequence = log
    .topics()
    .iter()
    .map(|topics| topics.join("."))
    .collect::<Vec<_>>()
    .join("|");
assert_eq!(sequence, "Transfer|CloseOffer");
}

A failure over a log reports the aggregate, which is the count of calls the assertion actually saw:

assertion `left == right` failed: Expected 3 events, found 2
panicked at 'Expected an event whose data matches, checked 2 event(s) with data [1, 2]'

Negative assertions

Proving something did not happen is the other half of event testing — a rejected transfer should emit no Transfer event, a closed offer should publish nothing at all:

#![allow(unused)]
fn main() {
use soroban_testkit_assert::events::EventMatcher;

// Nothing in scope was published
EventMatcher::new(&env).assert_not_emitted();

// A specific topic stayed silent
EventMatcher::new(&env)
    .from_contract(&contract_id)
    .with_topic("Transfer")
    .assert_not_emitted();

// Anything the topic filter cannot express
EventMatcher::new(&env).assert_none_match(|event| {
    matches!(event.type_, soroban_sdk::xdr::ContractEventType::Diagnostic)
});
}

assert_none_match hands you the raw ContractEvent, so a predicate can inspect the event type or the data payload rather than only the topics.

Both failures quote the event they were not supposed to find:

panicked at 'Expected no events to be emitted, found 1 — unexpected event: topics [Transfer], type Contract'
A negative assertion reads its matcher's scope

EventMatcher::new(&env).assert_not_emitted() proves the most recent invocation published nothing matching. Proving a topic never appeared across a test is the EventLog case: collect each call, then log.matcher().with_topic("Refund").assert_not_emitted().

Asserting on payload data

Topics say which event fired; the data says what happened. assert_data_matches hands the predicate one EventData per event in scope, after the contract and topic filters:

#![allow(unused)]
fn main() {
use soroban_testkit_assert::events::EventMatcher;

// A single-value payload, read as the type it is
EventMatcher::new(&env)
    .with_topic("transfer")
    .assert_data_matches(|data| data.deserialize::<i128>() == Some(1_000));

// A struct published with `contractevent` arrives as a map keyed by field name
EventMatcher::new(&env)
    .from_contract(&contract_id)
    .with_topic("incremented")
    .assert_data_matches(|data| {
        data.field("new_count")
            .and_then(|value| value.deserialize::<u32>())
            == Some(12)
    });

// A `contracttype` struct deserializes whole, when a test wants all of it
EventMatcher::new(&env)
    .with_topic("moved")
    .assert_data_matches(|data| data.deserialize::<Moved>() == Some(expected));

// Anything the typed views cannot express: the payload as the ledger stored it
EventMatcher::new(&env)
    .with_topic("flag")
    .assert_data_matches(|data| matches!(data.raw(), soroban_sdk::xdr::ScVal::Bool(true)));
}
ViewReturnsUse it when
deserialize::<T>()Option<T>the payload is one value of a known type — a number, Address, Vec/Map, or a contracttype struct
field("name")Option<EventData>the payload is a map, which is what a contractevent struct becomes, and one field is the point
raw()&ScValneither fits, or the test is about the shape itself

deserialize yields None rather than panicking, so one predicate can probe a payload without assuming it. field composes with it: data.field("amount").and_then(|value| value.deserialize::<i128>()).

A contractevent struct is not a contracttype struct

The struct declared with contractevent gets an Event implementation, not ledger conversions, so deserialize::<Incremented>() will not compile against it. Read its fields with field. Note too that the SDK publishes event maps sparse by default: a field whose value is None is absent from the map rather than stored as void, so field returning None covers both "no such field" and "field left empty".

A failed data assertion prints the payloads it was handed, which is the difference between a message you can read and a re-run:

panicked at 'Expected an event whose data matches, checked 4 event(s) with data [7, 9000, true, "widget"]'
panicked at 'Expected an event whose data matches, checked 1 event(s) with data [{"amount": 500, "to": Contract(CAAAAAA...FCT4)}]'

Scalars print their values, maps and vectors print their entries, an address prints as its strkey (abbreviated above, printed whole in a real message), and a shape too rare to spell out falls back to its XDR debug form, so the message never hides the value it rejected. When no event is in scope the message says so instead of claiming the data was wrong.

Authorization matching

Verify who had to sign for a call:

#![allow(unused)]
fn main() {
use soroban_testkit_assert::auth::AuthMatcher;

// Assert no auth was required
AuthMatcher::new(&env).assert_no_auth_required();

// Assert specific auth count
AuthMatcher::new(&env).assert_auth_count(2);
}

Failure messages

Assertions fail with the counts they observed, which is what makes them worth using over hand-rolled loops:

assertion `left == right` failed: Expected 3 events, found 1
Next step

Filtering by contract

from_contract narrows a matcher to one contract id — essential once a test wires several contracts together.

Guide

Testing patterns

See Testing Patterns for event and auth assertions in multi-contract suites.

soroban-testkit-fixtures

A reusable test context inspired by Foundry’s setUp(): one place to build the environment, admin and user addresses every test needs.

Packagesoroban-testkit-fixtures
Moduleslib · builder
Defaultmock_all_auths on

What TestContext gives you

FieldValue
ctx.envA fresh Env, auth mocked unless you opt out
ctx.adminA generated Address to act as deployer / owner
ctx.usersA Vec<Address> you extend with add_user()
ctx.mock_authsThe auth policy the context was built with, re-applied by reset()

Basic usage

#![allow(unused)]
fn main() {
use soroban_testkit_fixtures::TestContext;

let ctx = TestContext::new();
// ctx.env — pre-configured Env with mock_all_auths
// ctx.admin — generated admin address
// ctx.users — empty vec, add users as needed
}

Builder pattern

Describe the context you want instead of assembling it:

#![allow(unused)]
fn main() {
use soroban_testkit_fixtures::builder::TestContextBuilder;

let ctx = TestContextBuilder::new()
    .with_users(5)            // Pre-generate 5 user addresses
    .without_mock_auths()     // Disable automatic auth mocking
    .build();
}
When to opt out of mocked auth

Keep mock_all_auths for behaviour tests. Call without_mock_auths() as soon as a test asserts who had to sign — that is AuthMatcher territory.

Moving the ledger clock

Time-dependent contracts — vesting, auctions, lockups — need the ledger to advance between two calls. TestContext exposes the four operations that cover almost all of that without building a LedgerInfo by hand:

MethodEffect
ctx.timestamp()The unix timestamp the ledger reports
ctx.sequence()The ledger sequence number the ledger reports
ctx.set_timestamp(t)Jump to absolute time t
ctx.advance_time(s)Add s seconds, return the new timestamp
ctx.advance_ledger(n)Add n to the sequence, return the new height
#![allow(unused)]
fn main() {
use soroban_testkit_fixtures::TestContext;

let mut ctx = TestContext::new();
ctx.set_timestamp(1_700_000_000);
// ... assert the position is locked

let unlocked_at = ctx.advance_time(86_400 * 30); // 30 days later
assert_eq!(unlocked_at, 1_702_592_000);
// ... assert the position is claimable
}
Time and height are separate

Advance only what you assert

advance_time leaves the sequence alone and advance_ledger leaves the clock alone. A real ledger close moves both, so a test that wants the full picture calls each — and a test that only cares about TTL expiry does not silently move the clock.

A fresh env opens at zero

Set before you advance

The SDK test env starts at timestamp 0 and sequence 0, so advance_time(3_600) lands on 3_600 rather than on wall-clock time. Pin an absolute moment with set_timestamp first when the test reads better as a date.

Both helpers saturate instead of wrapping: advancing past u64::MAX or u32::MAX returns the maximum rather than panicking or rolling the clock back to a date in 1970.

Resetting the chain underneath a test

Some scenarios are the same actors on a clean chain twice: a phase-one initialize, then a phase-two that must not see phase-one’s storage. reset gives you that without rebuilding the fixture:

MethodEffect
ctx.reset()Fresh env — ledger, events, auths and contract storage all cleared — with the same admin and users
ctx.reset_full()Fresh env and new identities: a new admin and a new users list of the same length
#![allow(unused)]
fn main() {
let mut ctx = TestContextBuilder::new().with_users(2).build();

let env = ctx.env.clone();
let (vault, client) = register_vault(&env);
client.deposit(&ctx.users[0], &1_000);

ctx.reset();
// ctx.admin and ctx.users are the same actors; the chain remembers nothing.
// `vault` named a contract in the env that is gone, so register again:
let env = ctx.env.clone();
let (vault, client) = register_vault(&env);
assert_eq!(client.balance(&vault, &ctx.users[0]), 0);
}
Identities are carried, registrations are not

Re-register after resetting

An Address is a handle into the env that made it, so reset rebuilds each one from its ScAddress form and the strkey survives intact. A registered contract cannot travel that way — its code and storage live in the old env — so the id you held before the reset points at nothing. Reach for a second TestContext instead whenever the two phases want different fixtures, user counts or auth policies.

Authorisation policy

Reset keeps the policy, not by magic

TestContext remembers whether it mocks authorizations, so an unmocked context stays unmocked through a reset. An Env does not report its own policy, which means with_env has to assume the mocked default — set ctx.mock_auths = false when it is not what you configured.

reset_full exists because SDK v28 mints test addresses from a counter that restarts at 1 in every Env. Generating straight away would hand back exactly the addresses you replaced, so it first walks the new env’s counter past the range the old identities occupied.

Extending fixtures

Compose TestContext into project-specific fixtures so setup is written once per repo:

#![allow(unused)]
fn main() {
struct TokenTestEnv {
    ctx: TestContext,
    token_id: BytesN<32>,
}

impl TokenTestEnv {
    fn setup() -> Self {
        let ctx = TestContextBuilder::new().with_users(3).build();
        let token_id = ctx.env.register(TokenContract, ());
        Self { ctx, token_id }
    }
}
}
Address generation

Behind `testutils`

Address::generate is a test-only SDK API. SDK v28 requires use soroban_sdk::testutils::Address as _; plus the testutils feature.

Roadmap

Seeded state

Registering contracts and seeding their state from the builder would turn the setup above into one line. Tracked as issue #6.

soroban-testkit-generators

Soroban-aware property-testing strategies for proptest and arbitrary, so generated inputs respect the ranges real contracts accept.

Packagesoroban-testkit-generators
Featuresproptest (default) · arbitrary
Modulestrategies

Available strategies

StrategyTypeRange
token_amount()i1280 to 1e15
ledger_sequence()u321 to 10,000,000
timestamp()u64Sep 2020 to May 2033

Usage with proptest

#![allow(unused)]
fn main() {
use proptest::prelude::*;
use soroban_testkit_generators::strategies;

proptest! {
    #[test]
    fn transfer_preserves_total_supply(
        amount in strategies::token_amount(),
        seq in strategies::ledger_sequence(),
    ) {
        // amount is always a valid token balance
        // seq is always a realistic ledger sequence number
    }
}
}

Custom strategies

Compose the built-ins with proptest combinators:

#![allow(unused)]
fn main() {
use proptest::prelude::*;
use soroban_testkit_generators::strategies;

fn transfer_args() -> impl Strategy<Value = (i128, i128)> {
    (strategies::token_amount(), strategies::token_amount())
        .prop_filter("sender must have sufficient balance", |(a, b)| a >= b)
}
}
Keep cases bounded

Soroban tests run on the host and pay for every instruction. Start proptest! cases at the default 256, and shrink ranges before raising the count — Property Testing covers the trade-offs.

Feature flags

Opt in per backend

Enable proptest, arbitrary, or both. Unused backends stay out of your dependency graph.

Good first issue

More strategies

Address, symbol and BytesN<32> generators are labelled Medium 150 pts on the issue tracker.

Testing Patterns

Common patterns for testing Soroban contracts with Testkit: a shared setup(), multi-contract interactions, and deterministic ledger time.

AudienceContract authors
Cratesfixtures · assert
Read time4 minutes

Unit test structure

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use soroban_testkit_fixtures::builder::TestContextBuilder;

    fn setup() -> TestContext {
        TestContextBuilder::new()
            .with_users(3)
            .build()
    }

    #[test]
    fn test_happy_path() {
        let ctx = setup();
        // ...
    }

    #[test]
    #[should_panic(expected = "InsufficientBalance")]
    fn test_insufficient_balance() {
        let ctx = setup();
        // ...
    }
}
}

Cross-contract testing

Register multiple contracts and test interactions:

#![allow(unused)]
fn main() {
#[test]
fn test_contract_interaction() {
    let ctx = TestContextBuilder::new().with_users(2).build();

    let token_id = ctx.env.register(TokenContract, ());
    let vault_id = ctx.env.register(VaultContract, (&token_id,));

    let vault_client = VaultClient::new(&ctx.env, &vault_id);
    vault_client.deposit(&ctx.users[0], &1000);

    // Assert both contracts behaved correctly
}
}

Time-dependent tests

Manipulate ledger state for time-sensitive logic:

#![allow(unused)]
fn main() {
#[test]
fn test_vesting_unlock() {
    let ctx = TestContextBuilder::new().with_users(1).build();

    ctx.env.ledger().set(LedgerInfo {
        timestamp: 1_700_000_000,
        ..Default::default()
    });

    // ... assert locked

    ctx.env.ledger().set(LedgerInfo {
        timestamp: 1_800_000_000,
        ..Default::default()
    });

    // ... assert unlocked
}
}
Reset between cases

Each TestContext owns its own Env, so ledger writes and storage never leak between tests. Share a setup() helper instead of a mutable global fixture.

Where to go next

Budget-aware testing

Pin these patterns down with CPU and memory regression assertions.

Property testing

Replace hand-picked inputs with Soroban-aware generators.

soroban-testkit-assert

The full matcher API for events and authorizations.

Worked Example: Counter

A complete contract and its test suite, line by line. Everything here is the real code in examples/counter, which is a workspace member you can run.

Sourceexamples/counter
Cratesfixtures · assert · core
Tests8 passing
Runcargo test -p soroban-testkit-example-counter

The contract

A counter that stores one u32 in instance storage, charges an authorisation, and emits one event per increment.

#![allow(unused)]
fn main() {
#[contract]
pub struct CounterContract;

#[contractevent(topics = ["counter", "incremented"])]
#[derive(Clone)]
pub struct Incremented {
    pub caller: Address,
    pub new_count: u32,
}

#[contractimpl]
impl CounterContract {
    pub fn get(env: Env) -> u32 {
        let key = Symbol::new(&env, "count");
        env.storage().instance().get(&key).unwrap_or(0)
    }

    pub fn increment(env: Env, caller: Address, by: u32) -> u32 {
        caller.require_auth();

        let key = Symbol::new(&env, "count");
        let current: u32 = env.storage().instance().get(&key).unwrap_or(0);
        let next = current.checked_add(by).expect("counter overflow");
        env.storage().instance().set(&key, &next);

        Incremented { caller, new_count: next }.publish(&env);

        next
    }
}
}

Step 1 — build the environment

TestContextBuilder hands back an Env, an admin and pre-generated user addresses. Nothing else is set up by hand in this suite.

#![allow(unused)]
fn main() {
use soroban_testkit_fixtures::builder::TestContextBuilder;
use soroban_testkit_fixtures::TestContext;

fn context(users: usize) -> TestContext {
    TestContextBuilder::new().with_users(users).build()
}
}

Auth is mocked by default, so caller.require_auth() passes without wiring a signature per test.

Step 2 — register and call

Registering returns the contract’s Address; the generated client borrows the environment. Two helpers keep the borrow checker happy, because a client cannot hold a reference to the context it is stored next to.

#![allow(unused)]
fn main() {
use soroban_sdk::Address;

fn client_for(ctx: &TestContext) -> (Address, CounterContractClient<'_>) {
    let contract_id = ctx.env.register(CounterContract, ());
    (
        contract_id.clone(),
        CounterContractClient::new(&ctx.env, &contract_id),
    )
}

#[test]
fn increment_accumulates_and_returns_the_new_value() {
    let ctx = context(1);
    let (_id, client) = client_for(&ctx);
    let caller: Address = ctx.users[0].clone();

    assert_eq!(client.increment(&caller, &5), 5);
    assert_eq!(client.increment(&caller, &7), 12);
    assert_eq!(client.get(), 12);
}
}

Step 3 — assert on events

EventMatcher reads the events of the most recent invocation, which is what env.events().all() exposes in SDK v28 — so a per-call assertion goes right after that call. Judging several calls together is EventLog’s job, shown at the end of this step.

#![allow(unused)]
fn main() {
use soroban_testkit_assert::events::EventMatcher;

#[test]
fn events_can_be_filtered_to_the_contract_that_emitted_them() {
    let ctx = context(1);
    let (contract_id, client) = client_for(&ctx);
    let caller = ctx.users[0].clone();

    client.increment(&caller, &1);

    EventMatcher::new(&ctx.env)
        .from_contract(&contract_id)
        .with_topic("counter")
        .assert_emitted();
}
}

A read-only call emits nothing, which is worth its own test:

#![allow(unused)]
fn main() {
#[test]
fn read_only_calls_emit_nothing() {
    let ctx = context(1);
    let (_id, client) = client_for(&ctx);

    client.get();

    EventMatcher::new(&ctx.env).assert_not_emitted();
}
}

assert_not_emitted is the negative half of the matcher and respects the same filters, so a topic the contract never publishes is provable too:

#![allow(unused)]
fn main() {
EventMatcher::new(&ctx.env)
    .from_contract(&contract_id)
    .with_topic("decremented")
    .assert_not_emitted();
}

It reads the latest invocation, so the topic is proven absent from the call under test; EventLog below widens that to a whole sequence of calls.

The counter publishes its event with #[contractevent], so the payload arrives as a map keyed by field name and assert_data_matches reads the field the test is about:

#![allow(unused)]
fn main() {
#[test]
fn the_increment_event_carries_the_caller_and_the_new_count() {
    let ctx = context(1);
    let (contract_id, client) = client_for(&ctx);
    let caller = ctx.users[0].clone();

    client.increment(&caller, &12);

    EventMatcher::new(&ctx.env)
        .from_contract(&contract_id)
        .with_topic("incremented")
        .assert_data_matches(|data| {
            data.field("new_count")
                .and_then(|value| value.deserialize::<u32>())
                == Some(12)
        });
}
}

A failed assertion reports the payload it was handed instead of only saying the predicate was wrong — this is the counter’s event after an increment the predicate did not expect:

Expected an event whose data matches, checked 1 event(s) with data [{"caller": Contract(CAAAAAA...FCT4), "new_count": 7}]

The account key is abbreviated here; a real message prints the whole strkey, because a hash of the public key is not what a test author compares against.

See soroban-testkit-assert for deserialize, field and raw.

Several calls as one set

Counting the events of a whole run is the one thing a single-call matcher cannot say: “these increments emitted two events, and the first carried 5”. EventLog collects after each call the test means to judge, and its matcher takes the same filters and assertions:

#![allow(unused)]
fn main() {
use soroban_testkit_assert::events::EventLog;

#[test]
fn a_sequence_of_calls_can_be_asserted_over_as_one_set() {
    let ctx = context(1);
    let (contract_id, client) = client_for(&ctx);
    let caller = ctx.users[0].clone();

    let mut log = EventLog::new(&ctx.env);
    client.increment(&caller, &5);
    log.collect();
    client.get();
    log.collect();
    client.increment(&caller, &7);
    log.collect();

    log.matcher()
        .from_contract(&contract_id)
        .with_topic("incremented")
        .assert_count(2);

    log.matcher()
        .with_topic("incremented")
        .assert_data_matches(|data| {
            data.field("new_count")
                .and_then(|value| value.deserialize::<u32>())
                == Some(5)
        });
}
}

client.get() publishes nothing, so it adds nothing to the log — that is what makes a log-wide assert_not_emitted() mean “never in these calls”. Collection stays explicit because SDK v28 gives a test no per-invocation hook to drain: a call nobody collected from is simply not in scope. Events keep their call order, and log.topics() reads that order back.

Step 4 — bound the cost

Wrap the call in a guard instead of reading the meter by hand. The guard runs the closure and judges the metering that call left behind, so there is no warm-up call and no delta to compute.

#![allow(unused)]
fn main() {
use soroban_testkit_core::budget_guard;

#[test]
fn increment_stays_inside_its_cpu_ceiling() {
    let ctx = context(1);
    let (_id, client) = client_for(&ctx);
    let caller = ctx.users[0].clone();

    budget_guard!(&ctx.env, "increment", { cpu_max: 20_000_000, mem_max: 20_000_000 }, || {
        client.increment(&caller, &1)
    });
}
}

A ceiling is transaction headroom, not the measured cost. A contract registered with register() runs as native host code, so the VM’s own instantiation and execution costs never appear in the reading — the call above meters at tens of thousands of instructions here while a network would bill more.

The macro expands to the builder, so a test that loops over several calls can hold its own guard:

#![allow(unused)]
fn main() {
use soroban_testkit_core::budget::BudgetGuard;

BudgetGuard::new("increment").cpu_ceiling(20_000_000).run(&ctx.env, || client.increment(&caller, &1));
}

The other half is a baseline. Record the cost of a call once, and the next run of the same call is judged against the build that produced it:

#![allow(unused)]
fn main() {
use soroban_testkit_core::budget::{BudgetBaseline, BudgetSnapshot};

client.increment(&caller, &1);
let measured = BudgetSnapshot::last_invocation(&ctx.env);

let mut baseline = BudgetBaseline::new();
baseline.record("increment", measured);

baseline.guard("increment").tolerance_percent(25).run(&ctx.env, || {
    client.increment(&caller, &1);
});
}
Why a ceiling and a baseline

A ceiling says the call is still affordable; a baseline says it is still the call you measured. The first catches a rewrite that got loose, the second catches a drift no reviewer noticed. One guard checks both, prints every breach as a key=value line, and leaves the numbers in the pull request diff instead of in a deploy log.

This crate carries its own baseline — examples/counter/budget.json — and two #[ignore]d tests that record and enforce it. They are ignored because an SDK bump moves every number at once and the recording test rewrites a file, neither of which belongs in cargo test; the Budget baseline workflow runs them on a pinned runner instead, so the numbers are still judged somewhere. See Budget-aware testing for the commands and the failure output.

Step 5 — prove the authorisation is real

Everything above mocks auth. The last test turns mocking off and expects the call to panic, which is the only proof that require_auth() was not decorative.

#![allow(unused)]
fn main() {
#[test]
#[should_panic]
fn increment_requires_authorization_when_auths_are_not_mocked() {
    let ctx = TestContextBuilder::new()
        .with_users(1)
        .without_mock_auths()
        .build();
    let (_id, client) = client_for(&ctx);
    let caller = ctx.users[0].clone();

    client.increment(&caller, &1);
}
}

Running it

cargo test -p soroban-testkit-example-counter
running 9 tests
test test::counter_starts_at_zero ... ok
test test::a_topic_the_contract_never_publishes_stays_silent ... ok
test test::each_invocation_is_asserted_on_its_own ... ok
test test::events_can_be_filtered_to_the_contract_that_emitted_them ... ok
test test::increment_accumulates_and_returns_the_new_value ... ok
test test::increment_emits_exactly_one_event ... ok
test test::increment_consumes_a_predictable_amount_of_cpu ... ok
test test::read_only_calls_emit_nothing ... ok
test test::increment_requires_authorization_when_auths_are_not_mocked - should panic ... ok
Pattern

One assertion per invocation

Assert right after the call you care about. The matcher cannot see events from earlier invocations, so an end-of-test count is always wrong.

Pattern

Construct per test

Each test builds its own context. Sharing one environment across tests would leak budget counters and auth records between them.

Pattern

Warm up before measuring

The first cost estimate on a fresh Env can read zero. A throwaway call makes the snapshot meaningful.

Pattern

Test the failure path

A #[should_panic] test with without_mock_auths() is what turns an auth check from a line of code into a guarantee.

Where to go next

Guide

Testing patterns

Multi-contract suites and deterministic ledger time.

Crate

soroban-testkit-assert

Event and auth matchers and their filter semantics.

Crate

soroban-testkit-core

BudgetGuard and BudgetBaseline, plus the BudgetRead trait.

Budget-Aware Testing

Soroban contracts execute within strict CPU and memory budgets. Tests that pass locally can fail on-chain once resource consumption exceeds the transaction limits — Testkit makes those costs visible in ordinary assertions.

Cratesoroban-testkit-core
APIBudgetSnapshot · BudgetGuard · BudgetBaseline · budget_guard!
Runs incargo test / CI

Why budget matters

The Soroban runtime enforces resource budgets per transaction. The SDK’s test environment does not enforce those limits by default, so a contract can pass every unit test and still fail during deployment or execution on testnet and mainnet.

Symptom Green test suite, HOST_VALUE_SIZE / budget exhausted error on-chain. Fix: assert on the CPU and memory cost of each call, not just its result.

Basic budget tracking

The SDK meters one call at a time, and the metering of the call that just finished is what a test should read:

#![allow(unused)]
fn main() {
use soroban_testkit_core::budget::BudgetSnapshot;

#[test]
fn test_budget_regression() {
    let env = Env::default();
    env.mock_all_auths();

    // ... invoke function
    client.increment(&caller, &1);

    let cost = BudgetSnapshot::last_invocation(&env);
    assert!(cost.cpu_insns < 1_000_000, "CPU: {}", cost.cpu_insns);
    assert!(cost.mem_bytes < 100_000, "Memory: {}", cost.mem_bytes);
}
}
Where the numbers come from

SDK v28 meters each top-level invocation separately: env.cost_estimate().resources() returns what the call that just ran consumed, and env.cost_estimate().budget() returns the same metering as a running total for that call. The older env.budget() accessor is deprecated and should not appear in new tests.

What a native test contract hides

A contract registered with env.register(...) runs as host code, so VM instantiation, wasm execution and rent reads are never metered — the reading is the storage and host-work half of the real cost, not all of it. Treat these numbers as a comparison between builds of the same contract rather than as a fee quote, and use a deployed wasm contract when you need the full figure.

Reading a snapshot

FieldMeaningSuggested use
cpu_insnsInstructions metered for one invocationRegression guard in CI
mem_bytesMemory metered for one invocationKeeps entries under ledger limits
last_invocation()Snapshot of the call that just ranThe reading a test asserts on
diff()Saturating delta of two snapshotsComparing two readings of one running budget

Asserting a limit instead of a number

BudgetGuard carries the two limits a reviewer can argue about: a ceiling this call may not cross, and a growth allowance against the cost recorded the last time the case was measured.

  1. Name the case

    BudgetGuard::new("transfer"). The name is what every failure line carries, so a CI log points at an operation rather than at a test file.

  2. Set the ceilings

    .cpu_ceiling(2_000_000).mem_ceiling(500_000) — absolute limits, independent of history.

  3. Add the baseline

    .baseline(Some(recorded)).tolerance_percent(10) rejects a call that grew more than 10% past the recorded cost. A tolerance of 0 means not one instruction more.

  4. Measure the call

    .run(&env, || client.transfer(&from, &to, &1000)) makes the call, reads the metering that call left behind, asserts against it, and returns the invocation's value.

#![allow(unused)]
fn main() {
use soroban_testkit_core::budget::BudgetGuard;

#[test]
fn increment_stays_inside_its_budget() {
    let ctx = TestContextBuilder::new().with_users(1).build();
    let (id, client) = register(&ctx);
    let caller = ctx.users[0].clone();

    BudgetGuard::new("increment")
        .cpu_ceiling(20_000_000)
        .mem_ceiling(20_000_000)
        .run(&ctx.env, || client.increment(&caller, &1));
}
}

The budget_guard! macro is the same assertion written around the call:

#![allow(unused)]
fn main() {
use soroban_testkit_core::budget_guard;

budget_guard!(&env, "transfer", {
    cpu_max: 2_000_000,
    mem_max: 500_000,
    baseline: recorded,        // Option<BudgetSnapshot>
    tolerance: 10,             // percent
}, || client.transfer(&sender, &receiver, &1000));
}
Both readings, once

A guard reports every limit a cost breaks — ceilings first, then growth — instead of stopping at the first. One run tells you whether a change moved CPU, memory, or both.

Committing the numbers: baseline files

A baseline is a JSON file mapping case names to the cost recorded for them, so the limits live in the repository and a pull request shows a cost change as a diff.

{
  "version": 1,
  "cases": {
    "get": { "cpu_insns": 7861, "mem_bytes": 1510 },
    "increment": { "cpu_insns": 32669, "mem_bytes": 5252 }
  }
}
#![allow(unused)]
fn main() {
use soroban_testkit_core::budget::BudgetBaseline;

let mut baseline = BudgetBaseline::load(std::path::Path::new("tests/budget.json")).unwrap();

baseline
    .guard("increment")            // carries the recorded cost for the case
    .tolerance_percent(5)
    .run(&env, || client.increment(&caller, &1));

// Re-record and commit the file when a cost change is intended:
baseline.record("increment", measured_cost);
baseline.save(std::path::Path::new("tests/budget.json")).unwrap();
}

guard() on a case the file does not know returns a guard with no baseline, so a new operation is only bounded by whatever ceilings the test adds — the missing case never fails a suite by itself. A file written by a newer Testkit is refused rather than half-read.

Symptom A baseline that regenerates on every run accepts whatever the last run cost. Fix: commit the file, and rewrite it only in a deliberate "record budgets" change.

Machine-readable output

Every breach renders as one line of key=value pairs, which is what lets a CI step grep, annotate, or trend them:

BUDGET kind=ceiling case=transfer metric=cpu_insns actual=2500000 limit=2000000
BUDGET kind=growth case=transfer metric=mem_bytes actual=610000 limit=550000 baseline=500000 tolerance_percent=10
FieldMeaning
kindceiling (absolute limit) or growth (past a baseline)
caseThe name the guard was built with
metriccpu_insns or mem_bytes
actual / limitMeasured cost and the largest value still accepted
baseline / tolerance_percentOnly on growth, so the ratio is recoverable from the line

Running the check in CI

The counter example ships the whole loop: examples/counter/budget.json records the cost of its two hot paths, and two #[ignore]d tests in examples/counter/src/test.rs drive it.

# record the costs where they will be enforced
cargo test -p soroban-testkit-example-counter -- --ignored --nocapture \
    budget_baseline_records_current_costs

# compare a run against the committed file
TESTKIT_BUDGET_TOLERANCE=5 cargo test -p soroban-testkit-example-counter -- --ignored --nocapture \
    budget_baseline_rejects_drifted_costs

Both are ignored on purpose. Soroban metering is deterministic, so the reading is a property of the contract and the pinned SDK rather than of the machine that ran it — the file recorded on Windows measured identical on ubuntu-latest, drift=+0.00% on every metric. What that means is that an SDK or env-host bump moves every number at once, and the recording test rewrites a file in the repository, so neither belongs in cargo test --workspace. The Budget baseline workflow runs them where Cargo.lock and the runner are fixed: one pinned ubuntu-latest, the numbers printed in the job summary, a comment on the pull request, and a failure when a case grows past the tolerance — 10% by default, tolerance on a manual run, fail_on_drift off when you want the report without the verdict.

BUDGET_SUMMARY case=get metric=cpu_insns actual=7861 baseline=7861 drift=+0.00%
BUDGET_SUMMARY case=increment metric=cpu_insns actual=32669 baseline=32669 drift=+0.00%
FieldMeaning
caseThe key in the committed budget.json
actualWhat the call cost on this runner
baselineWhat the committed file says it cost
driftactual against baseline, signed percent

A breach then follows as the usual BUDGET kind=growth … line, so the same grep covers both the check and an in-test guard.

  1. An unintended rise

    The job fails and the comment says which case, which metric and by how much. Nothing to record — the code is what has to change.

  2. An intended rise

    Run the workflow with Record checked: it rewrites budget.json on the same runner that enforces it and uploads the file as an artifact. Commit that file on the branch, and the pull request diff reads as a reviewable statement — increment: 32669 → 41000 — rather than a red check someone retried.

One guard, one call

The metering the SDK reports belongs to the top-level invocation that has just finished, so a closure that makes two calls is judged on the second one. Give each call its own guard, and name the case after the call you care about.


Related: Property testing for inputs, soroban-testkit-core for the full budget API.

Property Testing

Property-based testing checks invariants across hundreds of generated inputs, catching the edge cases example-based tests miss. Testkit’s generators keep those inputs inside ranges Soroban actually accepts.

Cratesoroban-testkit-generators
Backendproptest 1.x
Cases256 by default

Setup

Enable the proptest feature:

[dev-dependencies]
soroban-testkit-generators = { version = "0.3.0", features = ["proptest"] }
proptest = "1"

Basic property test

#![allow(unused)]
fn main() {
use proptest::prelude::*;
use soroban_testkit_generators::strategies;

proptest! {
    #[test]
    fn balance_never_negative(amount in strategies::token_amount()) {
        // All generated amounts are >= 0 and within realistic bounds
        assert!(amount >= 0);
    }
}
}

Combining with fixtures

#![allow(unused)]
fn main() {
proptest! {
    #[test]
    fn transfer_preserves_supply(
        amount_a in strategies::token_amount(),
        amount_b in strategies::token_amount(),
    ) {
        let ctx = TestContextBuilder::new().with_users(2).build();
        // ... initialize balances, perform transfers
        // ... assert total supply unchanged
    }
}
}
Mind the host

Every generated case builds a real Env and executes on the host. If the suite slows down, reduce cases with #![proptest_config(ProptestConfig::with_cases(64))] before you narrow the strategy ranges.

When to use property testing

Invariants

Arithmetic

Supply conservation, no overflow, and rounding that stays consistent across amounts.

Invariants

State machines

A valid state remains reachable after any sequence of operations, not just the one you scripted.

Security

Access control

Unauthorized callers fail regardless of the parameters they present.

Edges

Boundaries

Zero amounts, maximum values and empty collections are generated rather than remembered.


Strategy reference: soroban-testkit-generators.

Contributing

Contributions of every size are welcome. The repository guide — CONTRIBUTING.md — is the authoritative version; this page summarises the flow and the rewards.

ToolchainRust 1.91+ (stable)
Checksfmt · clippy · test · docs · cargo deny · budget baseline
Reviewtwo-way, within 14 days

Contribution flow

  1. Fork and clone

    Create your fork of stellar-crucible/soroban-testkit, then cargo test --workspace --locked to confirm a green baseline (153 tests run locally; two more are #[ignore]d and belong to CI).

  2. Claim an issue

    Every funded task carries both Stellar Wave and a complexity:* label. Comment on the issue before starting so two contributors do not collide — Wave issues are first-come, first-served.

  3. Branch, commit, check

    Run cargo fmt --all --check and cargo clippy --workspace --all-targets --locked -- -D warnings locally. main is protected: check, Docs build and Supply chain audit must all be green before a pull request can merge. A fourth job, Budget baseline, runs on any pull request that touches the counter example or the budget code — it re-measures the hot paths and comments the numbers on the pull request.

  4. Open a pull request

    Reference the issue, describe what changed and why, and include the test evidence for behaviour changes.

Stellar Wave rewards

This project participates in the Stellar Wave program on Drips Network. Every issue carries a complexity label, and rewards land when the pull request merges.

complexity:trivial

100 points

Docs, small type fixes, one-line helpers. Expected within a day or two.

complexity:medium

150 points

A new matcher, a generator family, or an API that touches one crate.

complexity:high

200 points

Cross-crate features, storage enumeration, or anything needing design discussion first.

Unresolved issues roll over

If an issue is not merged inside the monthly cycle it carries over with its label intact — picking it up late still pays.

Ready to start? The tracker lists every open task with its complexity label and acceptance criteria.

Browse issues

Roadmap

Where Soroban Testkit is going, and which parts are open for contributors. Anything listed here is a proposal until an issue exists for it — the tracker is the source of truth.

Currentv0.3.0
CadenceMonthly Wave cycle
TrackingGitHub Issues

Shipped in v0.1.0

soroban-testkit-fixtures

Test context + builder

One call to get a mocked Env, an admin address and pre-generated users.

soroban-testkit-assert

Event and auth matchers

Chainable assert_emitted, assert_count, assert_no_auth_required, with contract and topic filtering.

soroban-testkit-core

Budget snapshots

Read the CPU and memory one call metered, and diff two readings of a running budget.

soroban-testkit-generators

proptest strategies

Token amounts, ledger sequences and timestamps bounded to realistic ranges.

Quality

Test suite for the toolkit

Unit tests in all four crates, plus cargo fmt, clippy -D warnings, docs build and a cargo deny supply-chain gate in CI.

examples/counter

Worked integration example

A real Soroban contract tested with fixtures, event matchers, budget assertions and authorization failures.

Landed since v0.1.0

soroban-testkit-core

Storage snapshots and diffs

StorageSnapshot::capture enumerates a contract's live instance, persistent and temporary entries from the ledger snapshot, and diff() reports added, removed and rewritten keys — #4, closed.

soroban-testkit-assert

Negative event assertions

assert_not_emitted() and assert_none_match() prove a topic stayed silent, quoting the unexpected event when they fail — #11, closed.

soroban-testkit-assert

Event payload assertions

assert_data_matches() reads what an event carried — a typed deserialize, one field of the map a #[contractevent] struct publishes, or the raw ScVal — and a failure prints every payload it rejected — #18, closed.

soroban-testkit-assert

Events aggregated across calls

EventLog gathers each invocation's events as a test runs, and matcher() hands the sequence to the same filters and assertions — so assert_count, with_topic and assert_not_emitted speak about several calls at once, and topics() reads the order back — #19, closed.

soroban-testkit-core

Error code decoder

DecodedError::from_error names the host category and meaning behind a packed error code, and ErrorRegistry adds words for your own #[contracterror] codes — #5, closed.

soroban-testkit-fixtures

Ledger time helpers

advance_time(), set_timestamp() and advance_ledger() move the clock or the height on their own, so a vesting or TTL test reads as a span instead of as a hand-built LedgerInfo — #12, closed.

soroban-testkit-fixtures

Context reset

reset() swaps in a fresh env — ledger, events, auths and storage gone — and carries every address across, so a two-phase test keeps its actors instead of rebuilding its fixture — #17, closed.

soroban-testkit-core

Budget regression detection

BudgetGuard and budget_guard! hold a call to a CPU and memory ceiling plus a growth allowance against a recorded cost, and BudgetBaseline keeps those costs in a committed JSON file — every breach printed as one parseable line — #3, closed.

Tooling

Budgets diffed in CI

examples/counter/budget.json commits the counter's hot-path costs, one ignored test records them and another enforces them, and the Budget baseline workflow runs the check on a pinned runner and reports every case on the pull request — #14, closed.

Distribution

Published on crates.io

All four crates are live on the registry as soroban-testkit-core, -assert, -fixtures and -generators, with 0.3.0 as the current stable release, so a consumer adds a version requirement instead of a Git pin. The release tag stays available for anyone who wants to pin the source commit its CI went green on.

Next cycle

AreaWorkComplexity
Assertionsassert_event_emitted! macro with topic and data matching (#1)complexity:high
Assertionsassert_auth_matches! macro (#2)complexity:medium
FixturesContract registration in the builder (#6)complexity:medium
GeneratorsAddress strategies (#7) and XDR-compatible values (#8)complexity:medium / high
GeneratorsArbitrary implementations for Soroban types (#16)complexity:medium
ExamplesToken example exercising every crate (#9)complexity:medium

Later

Mocking Lightweight mock contract generation from trait definitions, so stubbing a dependency stops requiring a whole crate. Tracked as issue #10 · High 200 pts

Non-goals

What Testkit will not do

It will not replace the official SDK's testing primitives, will not become a network simulator or transaction builder, and will not fork soroban-env-host. Testkit stays a thin, composable layer on top of the SDK.

Shaping the roadmap

Roadmap items start life as issues. If you want a feature that is not here, open a discussion in the issue tracker; if it is accepted and labelled with a complexity tag, it enters the next Wave cycle.

Everything above is claimable once labelled. Pick something that matches the time you have.

Open the tracker