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.
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.
The Crates
Each crate is independent — adopt one, or combine all four for a complete testing stack.
Inspect & measure
Budget snapshots with call-level diffs, on-chain error decoding, and storage inspection across instance, persistent and temporary tiers.
Assert fluently
Readable matchers for contract events and authorizations, filterable by contract id and topic.
Set up once
A reusable test context plus a builder for environments, admin accounts and generated users.
Generate wildly
Property-testing strategies for token amounts, ledger sequences and timestamps that respect Soroban limits.
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
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 issueBuilt 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.
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
-
Check your toolchain
Soroban SDK v28 needs a recent Cargo. Run
rustc --versionand upgrade withrustup update stableif it reports anything below 1.91. -
Pin the SDK your contract already uses
Testkit is built against SDK v28. Match the
soroban-sdkversion in your contract crate so thetestutilsfeatures resolve to one copy. -
Enable
testutilsin testsAddress generation, event inspection and auth recording are gated behind the SDK's
testutilsfeature — the dev-dependency above turns it on for the test profile only. -
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
| Feature | Description |
|---|---|
proptest (default) | Enable proptest strategies |
arbitrary | Enable arbitrary trait implementations |
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.
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
-
Build the context
TestContextBuilderhands you a mockedEnvplus ready-made addresses. -
Register the contract
Bind your contract to the context environment and create its client.
-
Invoke the contract
Call the function under test, then read the metering that call left behind.
-
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);
}
}
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
Setup, once
Eliminates manual Env creation, address generation and auth mocking. Opt out of mocked auth when a test needs explicit authorization.
Readable assertions
Replaces raw tuple iteration over emitted events with a chainable filter by contract and topic.
Costs made visible
Surfaces the per-call CPU-instruction and memory costs that standard Soroban tests never show.
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.
API at a glance
| Type | Purpose |
|---|---|
BudgetSnapshot | CPU instructions and memory bytes — last_invocation() for the call that just ran |
BudgetRead | Trait implemented by any source capture() can read: invocation resources, the SDK budget, the host Budget |
BudgetGuard | Ceilings and a baseline tolerance for one named operation |
BudgetViolation, ViolationKind | The limit a cost broke, rendered as one parseable line |
BudgetBaseline | Recorded costs per case, loaded from and saved to a JSON file |
budget_guard! | Macro form of the guard around a single invocation |
DecodedError | A Soroban error code split into category, meaning and context |
ErrorRegistry | Your #[contracterror] codes mapped to the words they mean |
ClientOutcome, unwrap_decoded | Unwrap a v28 try_* client call, panicking with the decoded error |
StorageEntry, StorageTier | A live storage key, its rendered value, tier and expiry ledger |
StorageSnapshot | Every live entry of one contract, captured at a point in time |
StorageDiff, StorageChange | Keys 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.
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)
});
}
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.
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, ®istry);
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);
}
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.
API at a glance
| Method | Asserts |
|---|---|
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();
}
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.
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”.
| Method | Gives |
|---|---|
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'
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)));
}
| View | Returns | Use 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() | &ScVal | neither 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>()).
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
Filtering by contract
from_contract narrows a matcher to one contract id — essential once a test wires several contracts together.
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.
What TestContext gives you
| Field | Value |
|---|---|
ctx.env | A fresh Env, auth mocked unless you opt out |
ctx.admin | A generated Address to act as deployer / owner |
ctx.users | A Vec<Address> you extend with add_user() |
ctx.mock_auths | The 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();
}
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:
| Method | Effect |
|---|---|
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
}
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.
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:
| Method | Effect |
|---|---|
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);
}
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.
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 }
}
}
}
Behind `testutils`
Address::generate is a test-only SDK API. SDK v28 requires use soroban_sdk::testutils::Address as _; plus the testutils feature.
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.
Available strategies
| Strategy | Type | Range |
|---|---|---|
token_amount() | i128 | 0 to 1e15 |
ledger_sequence() | u32 | 1 to 10,000,000 |
timestamp() | u64 | Sep 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)
}
}
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.
Opt in per backend
Enable proptest, arbitrary, or both. Unused backends stay out of your dependency graph.
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.
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
}
}
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.
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);
});
}
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
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.
Construct per test
Each test builds its own context. Sharing one environment across tests would leak budget counters and auth records between them.
Warm up before measuring
The first cost estimate on a fresh Env can read zero. A throwaway call makes the snapshot meaningful.
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
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.
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.
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);
}
}
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.
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
| Field | Meaning | Suggested use |
|---|---|---|
cpu_insns | Instructions metered for one invocation | Regression guard in CI |
mem_bytes | Memory metered for one invocation | Keeps entries under ledger limits |
last_invocation() | Snapshot of the call that just ran | The reading a test asserts on |
diff() | Saturating delta of two snapshots | Comparing 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.
-
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. -
Set the ceilings
.cpu_ceiling(2_000_000).mem_ceiling(500_000)— absolute limits, independent of history. -
Add the baseline
.baseline(Some(recorded)).tolerance_percent(10)rejects a call that grew more than 10% past the recorded cost. A tolerance of0means not one instruction more. -
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));
}
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.
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
| Field | Meaning |
|---|---|
kind | ceiling (absolute limit) or growth (past a baseline) |
case | The name the guard was built with |
metric | cpu_insns or mem_bytes |
actual / limit | Measured cost and the largest value still accepted |
baseline / tolerance_percent | Only 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%
| Field | Meaning |
|---|---|
case | The key in the committed budget.json |
actual | What the call cost on this runner |
baseline | What the committed file says it cost |
drift | actual 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.
-
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.
-
An intended rise
Run the workflow with Record checked: it rewrites
budget.jsonon 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.
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.
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
}
}
}
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
Arithmetic
Supply conservation, no overflow, and rounding that stays consistent across amounts.
State machines
A valid state remains reachable after any sequence of operations, not just the one you scripted.
Access control
Unauthorized callers fail regardless of the parameters they present.
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.
Contribution flow
-
Fork and clone
Create your fork of
stellar-crucible/soroban-testkit, thencargo test --workspace --lockedto confirm a green baseline (153 tests run locally; two more are#[ignore]d and belong to CI). -
Claim an issue
Every funded task carries both
Stellar Waveand acomplexity:*label. Comment on the issue before starting so two contributors do not collide — Wave issues are first-come, first-served. -
Branch, commit, check
Run
cargo fmt --all --checkandcargo clippy --workspace --all-targets --locked -- -D warningslocally.mainis protected:check,Docs buildandSupply chain auditmust 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. -
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.
100 points
Docs, small type fixes, one-line helpers. Expected within a day or two.
150 points
A new matcher, a generator family, or an API that touches one crate.
200 points
Cross-crate features, storage enumeration, or anything needing design discussion first.
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 issuesRoadmap
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.
Shipped in v0.1.0
Test context + builder
One call to get a mocked Env, an admin address and pre-generated users.
Event and auth matchers
Chainable assert_emitted, assert_count, assert_no_auth_required, with contract and topic filtering.
Budget snapshots
Read the CPU and memory one call metered, and diff two readings of a running budget.
proptest strategies
Token amounts, ledger sequences and timestamps bounded to realistic ranges.
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.
Worked integration example
A real Soroban contract tested with fixtures, event matchers, budget assertions and authorization failures.
Landed since v0.1.0
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.
Negative event assertions
assert_not_emitted() and assert_none_match() prove a topic stayed silent, quoting the unexpected event when they fail — #11, closed.
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.
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.
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.
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.
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.
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.
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.
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
| Area | Work | Complexity |
|---|---|---|
| Assertions | assert_event_emitted! macro with topic and data matching (#1) | complexity:high |
| Assertions | assert_auth_matches! macro (#2) | complexity:medium |
| Fixtures | Contract registration in the builder (#6) | complexity:medium |
| Generators | Address strategies (#7) and XDR-compatible values (#8) | complexity:medium / high |
| Generators | Arbitrary implementations for Soroban types (#16) | complexity:medium |
| Examples | Token example exercising every crate (#9) | complexity:medium |
Later
Non-goals
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