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.