A batteries-included testing toolkit for Soroban smart contracts.
Writing tests for Soroban contracts today means wiring up environments by hand, copying boilerplate across every repo, and hunting through Stellar docs just to assert that an event fired. crucible changes that.
It is a purpose-built Rust testing library for Soroban — analogous to what jest is for JavaScript or hardhat is for Solidity — giving you a rich set of builders, helpers, assertion macros, and fixtures so you can focus on what your contract should do, not on how to set up the harness to prove it.
- Motivation
- Features at a Glance
- Installation
- Quick Start
- Core Concepts
- API Reference
- Examples
- Crate Features
- Roadmap
- Contributing
- License
Soroban is Stellar's smart contract platform. It runs on WASM, uses Rust as its primary language, and ships with a stellar (pun intended) SDK — but its native test utilities, while functional, are intentionally low-level. That gap shows up fast in real projects:
| Problem | Without crucible | With crucible |
|---|---|---|
| Setting up a funded test account | ~20 lines of boilerplate | AccountBuilder::new().fund(1_000_000).build(&env) |
| Registering a standard token and minting | Manual contract deployment + admin calls | MockToken::xlm(&env) |
| Asserting a contract event fired | Iterate env.events().all(), match manually |
assert_emitted!(env, Transfer { from, to, amount }) |
| Measuring instruction cost | No built-in helpers | `env.measure( |
| Advancing ledger time | env.ledger().set(...) ceremony |
env.advance_time(Duration::days(7)) |
crucible wraps the official soroban-sdk test utilities and builds a fluent, ergonomic layer on top. It does not replace the SDK — it stands alongside it.
MockEnvBuilder— fluent builder for the SorobanEnvwith sensible defaults, configurable ledger state, and one-liner seeded accounts.- Pre-funded accounts — generate named accounts with arbitrary XLM and custom token balances ready to go.
- Standard mock tokens — instant
MockTokenfor XLM, USDC, or any arbitrary asset; full admin controls included. - Transaction simulation helpers — wrap contract invocations with fee estimation, auth inspection, and rollback-safe dry-runs.
assert_emitted!macro — pattern-match contract events with a concise, readable syntax.assert_not_emitted!macro — verify silence; confirm events that must not fire.- Gas & instruction counting — measure the compute cost of any invocation directly in tests.
- Ledger time control — jump forward in time, set arbitrary sequence numbers, or simulate a full epoch change with one call.
- Fixtures — re-usable test setup structs with derive support for common patterns.
- Snapshot testing — serialize contract state and diff it across test runs.
Add crucible to the [dev-dependencies] section of your contract's Cargo.toml. It should never appear in production dependencies.
[dev-dependencies]
crucible = "0.1"
# The soroban SDK itself — you likely already have this
soroban-sdk = { version = "21", features = ["testutils"] }Enable the testutils feature on soroban-sdk. crucible depends on it at compile time and will emit a clear error if it is missing.
MSRV: Rust 1.76 or later. crucible tracks the same minimum supported Rust version as
soroban-sdk.
The fastest way to see crucible in action is a single self-contained test. Suppose you have a simple counter contract:
// src/lib.rs
#![no_std]
use soroban_sdk::{contract, contractimpl, contracttype, Env, Symbol, symbol_short};
#[contracttype]
pub enum DataKey {
Counter,
}
#[contract]
pub struct CounterContract;
#[contractimpl]
impl CounterContract {
pub fn increment(env: Env) -> u32 {
let mut count: u32 = env.storage().instance().get(&DataKey::Counter).unwrap_or(0);
count += 1;
env.storage().instance().set(&DataKey::Counter, &count);
env.events().publish((symbol_short!("counter"), symbol_short!("inc")), count);
count
}
pub fn get(env: Env) -> u32 {
env.storage().instance().get(&DataKey::Counter).unwrap_or(0)
}
}A full test with crucible looks like this:
// src/test.rs
#[cfg(test)]
mod tests {
use crucible::prelude::*;
use crate::{CounterContract, CounterContractClient};
#[test]
fn test_counter_increments_and_emits_event() {
// 1. Build a mock environment with a registered contract
let env = MockEnv::builder()
.with_contract::<CounterContract>()
.build();
let contract_id = env.contract_id::<CounterContract>();
let client = CounterContractClient::new(&env.inner(), &contract_id);
// 2. Call the contract
let result = client.increment();
assert_eq!(result, 1);
// 3. Assert the event fired
assert_emitted!(
env,
topics: ("counter", "inc"),
data: 1_u32
);
// 4. Verify idempotency
let result = client.increment();
assert_eq!(result, 2);
assert_eq!(client.get(), 2);
}
}No manual ledger configuration. No Env::default() + register_contract(...) ceremonies. Just your contract and your assertions.
MockEnv is the entry point for every crucible test. It wraps Soroban's Env object and provides a fluent builder interface to configure the test environment before any contract is called.
let env = MockEnv::builder()
// Set the ledger sequence number and timestamp
.at_sequence(1_000)
.at_timestamp(1_700_000_000)
// Register contracts ahead of time
.with_contract::<MyContract>()
.with_contract::<OtherDependencyContract>()
// Seed named accounts with XLM
.with_account("alice", Stroops::xlm(500))
.with_account("bob", Stroops::xlm(100))
// Attach a mock SAC token (Stellar Asset Contract)
.with_token("USDC", 6)
// Enable detailed instruction tracking for cost assertions
.track_costs()
.build();After calling .build() you get a MockEnv handle. Use .inner() to get the underlying soroban_sdk::Env when you need to pass it to auto-generated clients:
let client = MyContractClient::new(&env.inner(), &env.contract_id::<MyContract>());The MockEnv handle also provides all the assertion and time-travel helpers described below.
In vanilla Soroban tests, creating an account that can sign transactions requires multiple steps: generate a keypair, construct an Address, call env.ledger().set(...) to fund it, and store references everywhere. crucible collapses this into one call.
// During env construction
let env = MockEnv::builder()
.with_account("alice", Stroops::xlm(10_000))
.with_account("bob", Stroops::xlm(500))
.build();
// Fetch a typed account handle anywhere in the test
let alice = env.account("alice");
let bob = env.account("bob");
// Use the address wherever Soroban expects one
client.transfer(&alice.address(), &bob.address(), &100_i128);
// Sign authorization for a call
env.set_auths(&[alice.auth()]);
client.some_protected_call(&alice.address());The AccountHandle type gives you:
| Method | Returns | Description |
|---|---|---|
.address() |
Address |
The Soroban address for this account |
.auth() |
InvokerContractAuthEntry |
Authorization entry for use with set_auths |
.xlm_balance() |
i128 |
Current XLM balance in stroops |
.token_balance(&token) |
i128 |
Balance in a given MockToken |
.sign(payload) |
Vec<u8> |
Sign an arbitrary payload with the account keypair |
Soroban uses the Stellar Asset Contract (SAC) interface for fungible tokens. Setting one up manually in a test involves deploying a WASM blob, calling initialize, and minting. crucible provides MockToken to do all of that in one line.
let env = MockEnv::builder()
.with_account("alice", Stroops::xlm(1_000))
.build();
// Create a mock XLM SAC token
let xlm = MockToken::xlm(&env);
// Create a custom 6-decimal asset
let usdc = MockToken::new(&env, "USDC", 6);
// Mint tokens to an account
xlm.mint(&env.account("alice").address(), 50_000_000); // 5 XLM in stroops
// Read balances
let balance = usdc.balance(&env.account("alice").address());
// Admin operations
usdc.set_admin(&new_admin_address);
usdc.clawback(&target_address, &amount);MockToken implements the full SEP-41 / SAC interface, so you can pass its Address directly into any contract that expects a token contract address.
Before committing a call you may want to inspect what a transaction would do — how much it costs, what authorizations it requires, or whether it would succeed. SimulatedTx wraps a contract call in a dry-run context.
let sim = env.simulate(|| {
client.complex_operation(&alice.address(), &amount)
});
// The call did not actually execute — inspect the results
println!("Estimated fee: {} stroops", sim.fee());
println!("Instruction count: {}", sim.instructions());
println!("Required auths: {:?}", sim.required_auths());
println!("Would succeed: {}", sim.would_succeed());
// Commit if you're happy with the results
if sim.would_succeed() {
sim.commit();
}This is particularly valuable in CI when you want to catch unexpectedly expensive code paths or missing authorization requirements, without writing a separate integration test for each scenario.
Soroban contracts publish events via env.events().publish(...). Asserting those events fired correctly is one of the most common testing needs and one of the most verbose without helpers.
crucible ships assert_emitted! and assert_not_emitted!:
// Assert a specific event was emitted by topics + data
assert_emitted!(
env,
topics: ("transfer", "v1"),
data: TransferData { from: alice.address(), to: bob.address(), amount: 100_i128 }
);
// Assert an event from a specific contract
assert_emitted!(
env,
contract: &token_address,
topics: ("mint",),
data: 1_000_000_i128
);
// Assert at least N events matching the pattern
assert_emitted!(
env,
topics: ("approval",),
count: 3
);
// Assert the _nth_ matching event has specific data
assert_emitted!(
env,
topics: ("swap",),
at_index: 0,
data: SwapEvent { token_in: xlm.address(), token_out: usdc.address() }
);// Confirm no transfer event was emitted (useful for failure-path tests)
assert_not_emitted!(
env,
topics: ("transfer", "v1")
);If you want to inspect events programmatically rather than with macros:
let events = env.events_matching(("transfer",));
assert_eq!(events.len(), 2);
let first: TransferData = events[0].data();
assert_eq!(first.amount, 500_i128);crucible exposes the Soroban host instruction meter directly so you can write regression tests against compute cost:
let env = MockEnv::builder()
.with_contract::<MyContract>()
.track_costs() // required for cost tracking
.build();
let cost = env.measure(|| {
client.heavy_computation(&large_input)
});
// Hard limits — fail the test if the contract gets more expensive
assert!(cost.instructions() < 5_000_000, "contract is too expensive: {}", cost.instructions());
assert!(cost.memory_bytes() < 1_024 * 100, "contract uses too much memory");
// Print a human-readable cost summary in CI output
println!("{}", cost.report());The CostReport returned by env.measure() contains:
| Field | Type | Description |
|---|---|---|
instructions() |
u64 |
Total CPU instructions consumed |
memory_bytes() |
u64 |
Peak memory allocation in bytes |
fee_stroops() |
i64 |
Estimated network fee in stroops |
report() |
String |
Pretty-printed summary table |
You can also store a cost snapshot and assert it does not regress across commits:
// Write the snapshot on first run; compare on subsequent runs
cost.assert_snapshot("heavy_computation_cost");For complex contracts with many dependencies, test setup code tends to balloon. crucible lets you define Fixture structs that encapsulate a fully configured environment so every test starts from a clean, consistent state.
use crucible::fixture;
#[fixture]
pub struct AmmFixture {
pub env: MockEnv,
pub pool: Address,
pub xlm: MockToken,
pub usdc: MockToken,
pub alice: AccountHandle,
pub bob: AccountHandle,
}
impl AmmFixture {
pub fn setup() -> Self {
let env = MockEnv::builder()
.with_contract::<AmmPool>()
.with_account("alice", Stroops::xlm(100_000))
.with_account("bob", Stroops::xlm(100_000))
.build();
let xlm = MockToken::xlm(&env);
let usdc = MockToken::new(&env, "USDC", 6);
let alice = env.account("alice");
let bob = env.account("bob");
// Seed the pool with initial liquidity
let pool_client = AmmPoolClient::new(&env.inner(), &env.contract_id::<AmmPool>());
xlm.mint(&alice.address(), 10_000_000);
usdc.mint(&alice.address(), 10_000_000);
env.set_auths(&[alice.auth()]);
pool_client.add_liquidity(&xlm.address(), &usdc.address(), &10_000_000_i128, &10_000_000_i128);
Self { env, pool: env.contract_id::<AmmPool>(), xlm, usdc, alice, bob }
}
}
// Now every test is one line of setup
#[test]
fn test_swap_changes_price() {
let f = AmmFixture::setup();
// ... test logic only
}
#[test]
fn test_insufficient_liquidity_reverts() {
let f = AmmFixture::setup();
// ... test logic only
}The #[fixture] attribute macro adds a reset() method that re-runs setup() and replaces self, letting you reset mid-test without reconstructing everything from scratch.
MockEnv::builder()
// Ledger configuration
.at_sequence(seq: u32) -> Self
.at_timestamp(unix_ts: u64) -> Self
.with_protocol_version(version: u32) -> Self
// Contract registration
.with_contract<C: Contract>() -> Self
.with_contract_at<C: Contract>(id: &Address) -> Self
.with_wasm(wasm: &[u8]) -> Self
// Account seeding
.with_account(name: &str, balance: Stroops) -> Self
// Token setup
.with_token(symbol: &str, decimals: u32) -> Self
// Diagnostics
.track_costs() -> Self
.capture_logs() -> Self
.build() -> MockEnvFor programmatic account creation outside of the MockEnvBuilder:
let account = AccountBuilder::new(&env)
.name("charlie")
.fund_xlm(Stroops::xlm(1_000))
.fund_token(&usdc, 5_000_000)
.build();let sim: SimulatedTx<T> = env.simulate(|| client.some_call());
sim.fee() -> i64 // estimated fee in stroops
sim.instructions() -> u64 // instruction count
sim.required_auths() -> Vec<...> // required auth entries
sim.would_succeed() -> bool // whether the call succeeds
sim.result() -> Option<T> // the return value, if successful
sim.commit() -> T // actually execute the call// Assert event was emitted
assert_emitted!(env, topics: (...), data: value);
assert_emitted!(env, contract: &addr, topics: (...), data: value);
assert_emitted!(env, topics: (...), count: n);
assert_emitted!(env, topics: (...), at_index: n, data: value);
// Assert event was NOT emitted
assert_not_emitted!(env, topics: (...));
assert_not_emitted!(env, contract: &addr, topics: (...));
// Assert a call reverts with a specific error
assert_reverts!(client.call(), ContractError::Unauthorized);
// Assert a call reverts with any error
assert_reverts!(client.call());
// Assert approximate equality (useful for fee/reward calculations with rounding)
assert_approx_eq!(actual, expected, tolerance);// Measure cost of a closure
let cost: CostReport = env.measure(|| client.call());
cost.instructions() -> u64
cost.memory_bytes() -> u64
cost.fee_stroops() -> i64
cost.report() -> String // formatted table
// Snapshot-based regression testing
cost.assert_snapshot("snapshot_name"); // fails if cost increased > 5%
cost.assert_snapshot_with_tolerance("name", 0.1); // custom 10% tolerance// Advance the ledger timestamp by a duration
env.advance_time(Duration::days(30));
env.advance_time(Duration::seconds(3600));
// Set absolute ledger time
env.set_timestamp(unix_ts: u64);
// Advance the ledger sequence number
env.advance_sequence(n: u32);
// Jump to a specific sequence
env.set_sequence(n: u32);#[cfg(test)]
mod token_tests {
use crucible::prelude::*;
use crate::{MyTokenContract, MyTokenContractClient};
struct TokenFixture {
env: MockEnv,
client: MyTokenContractClient,
admin: AccountHandle,
alice: AccountHandle,
bob: AccountHandle,
}
impl TokenFixture {
fn setup() -> Self {
let env = MockEnv::builder()
.with_contract::<MyTokenContract>()
.with_account("admin", Stroops::xlm(10_000))
.with_account("alice", Stroops::xlm(10_000))
.with_account("bob", Stroops::xlm(10_000))
.build();
let admin = env.account("admin");
let alice = env.account("alice");
let bob = env.account("bob");
let client = MyTokenContractClient::new(&env.inner(), &env.contract_id::<MyTokenContract>());
env.set_auths(&[admin.auth()]);
client.initialize(&admin.address(), &7_u32, &"My Token".into(), &"MTK".into());
Self { env, client, admin, alice, bob }
}
}
#[test]
fn test_mint_emits_event_and_updates_balance() {
let f = TokenFixture::setup();
f.env.set_auths(&[f.admin.auth()]);
f.client.mint(&f.alice.address(), &1_000_i128);
assert_eq!(f.client.balance(&f.alice.address()), 1_000_i128);
assert_emitted!(
f.env,
topics: ("mint",),
data: MintEvent { to: f.alice.address(), amount: 1_000_i128 }
);
}
#[test]
fn test_transfer_moves_balance_between_accounts() {
let f = TokenFixture::setup();
f.env.set_auths(&[f.admin.auth()]);
f.client.mint(&f.alice.address(), &500_i128);
f.env.set_auths(&[f.alice.auth()]);
f.client.transfer(&f.alice.address(), &f.bob.address(), &200_i128);
assert_eq!(f.client.balance(&f.alice.address()), 300_i128);
assert_eq!(f.client.balance(&f.bob.address()), 200_i128);
assert_emitted!(
f.env,
topics: ("transfer",),
data: TransferEvent {
from: f.alice.address(),
to: f.bob.address(),
amount: 200_i128,
}
);
}
#[test]
fn test_transfer_without_auth_reverts() {
let f = TokenFixture::setup();
f.env.set_auths(&[f.admin.auth()]);
f.client.mint(&f.alice.address(), &500_i128);
// No auth set — should revert
assert_reverts!(
f.client.transfer(&f.alice.address(), &f.bob.address(), &200_i128)
);
// Balances must be unchanged
assert_eq!(f.client.balance(&f.alice.address()), 500_i128);
assert_eq!(f.client.balance(&f.bob.address()), 0_i128);
assert_not_emitted!(f.env, topics: ("transfer",));
}
}#[test]
fn test_escrow_full_lifecycle() {
let env = MockEnv::builder()
.with_contract::<EscrowContract>()
.with_account("buyer", Stroops::xlm(50_000))
.with_account("seller", Stroops::xlm(1_000))
.with_account("arbiter", Stroops::xlm(1_000))
.build();
let xlm = MockToken::xlm(&env);
let buyer = env.account("buyer");
let seller = env.account("seller");
let arbiter = env.account("arbiter");
let client = EscrowContractClient::new(&env.inner(), &env.contract_id::<EscrowContract>());
// 1. Buyer creates escrow
xlm.mint(&buyer.address(), 10_000_i128);
env.set_auths(&[buyer.auth()]);
let escrow_id = client.create(
&buyer.address(),
&seller.address(),
&arbiter.address(),
&xlm.address(),
&10_000_i128,
);
assert_emitted!(env, topics: ("escrow", "created"), data: escrow_id);
// 2. Advance time past lock period
env.advance_time(Duration::days(3));
// 3. Seller claims — arbiter approves
env.set_auths(&[arbiter.auth()]);
client.approve(&escrow_id);
env.set_auths(&[seller.auth()]);
client.claim(&escrow_id, &seller.address());
assert_eq!(xlm.balance(&seller.address()), 10_000_i128);
assert_eq!(xlm.balance(&buyer.address()), 0_i128);
assert_emitted!(env, topics: ("escrow", "claimed"), data: escrow_id);
}#[test]
fn test_vesting_cliff_is_enforced() {
let env = MockEnv::builder()
.with_contract::<VestingContract>()
.with_account("beneficiary", Stroops::xlm(1_000))
.at_timestamp(1_700_000_000)
.build();
let xlm = MockToken::xlm(&env);
let beneficiary = env.account("beneficiary");
let client = VestingContractClient::new(&env.inner(), &env.contract_id::<VestingContract>());
let cliff_seconds: u64 = 90 * 24 * 3600; // 90 days
xlm.mint(&env.contract_id::<VestingContract>(), 100_000_i128);
client.initialize(&beneficiary.address(), &cliff_seconds, &100_000_i128);
// Attempt to claim before cliff — must fail
env.set_auths(&[beneficiary.auth()]);
assert_reverts!(client.claim());
// Advance to just before cliff
env.advance_time(Duration::days(89));
assert_reverts!(client.claim());
// Advance past cliff
env.advance_time(Duration::days(2)); // total: 91 days
client.claim(); // should succeed now
let balance = xlm.balance(&beneficiary.address());
assert!(balance > 0, "beneficiary should have received vested tokens");
}#[test]
fn test_aggregator_calls_multiple_pools() {
let env = MockEnv::builder()
.with_contract::<Aggregator>()
.with_contract::<PoolA>()
.with_contract::<PoolB>()
.with_account("trader", Stroops::xlm(10_000))
.build();
let xlm = MockToken::xlm(&env);
let usdc = MockToken::new(&env, "USDC", 6);
let trader = env.account("trader");
// Seed both pools
xlm.mint(&env.contract_id::<PoolA>(), 500_000_i128);
usdc.mint(&env.contract_id::<PoolA>(), 500_000_i128);
xlm.mint(&env.contract_id::<PoolB>(), 200_000_i128);
usdc.mint(&env.contract_id::<PoolB>(), 200_000_i128);
// Give trader tokens to swap
xlm.mint(&trader.address(), 1_000_i128);
let agg_client = AggregatorClient::new(&env.inner(), &env.contract_id::<Aggregator>());
env.set_auths(&[trader.auth()]);
let out_amount = agg_client.best_swap(
&xlm.address(),
&usdc.address(),
&1_000_i128,
&trader.address(),
);
assert!(out_amount > 0);
assert_eq!(xlm.balance(&trader.address()), 0_i128);
assert_eq!(usdc.balance(&trader.address()), out_amount);
// Verify the aggregator routed through exactly one pool
assert_emitted!(env, topics: ("swap",), count: 1);
}| Feature | Default | Description |
|---|---|---|
std |
No | Enable std support (required for snapshot testing) |
snapshots |
No | Snapshot-based cost regression testing |
derive |
Yes | Enable #[fixture] and related derive macros |
token-mocks |
Yes | Include the MockToken / SAC helpers |
serde |
No | Serialize/deserialize fixtures and cost reports |
Enable optional features in Cargo.toml:
[dev-dependencies]
crucible = { version = "0.1", features = ["snapshots", "serde"] }-
MockEnvBuilderwith ledger configuration - Pre-funded account helpers (
AccountBuilder,AccountHandle) -
MockToken(SAC interface) -
assert_emitted!/assert_not_emitted!macros -
assert_reverts!macro
-
env.measure()instruction tracking -
CostReportwith human-readable output - Snapshot-based regression testing
-
SimulatedTxdry-run API
-
#[fixture]derive macro -
env.advance_time()/env.advance_sequence() - Named event captures
- CLI report output for CI integration
- Pre-built mocks for common Soroban contracts (DEX, lending, multisig)
- Integration with
soroban-clitest runner output format - VSCode extension for inline cost annotations
Contributions are very welcome. crucible is designed to be contributor-friendly with well-scoped, independently shippable issues.
- Add a mock for the Soroban token contract's
allowanceflow - Add
assert_approx_eq!macro with configurable tolerance - Write docs and usage examples for
SimulatedTx - Add
env.events_matching()ergonomics for programmatic event inspection - Set up GitHub Actions CI with
cargo testandcargo clippy
git clone https://github.com/your-org/crucible
cd crucible
cargo testAll tests should pass with a standard Rust toolchain and no additional dependencies. The library uses soroban-sdk in test mode only, so no WASM toolchain is required to work on the library itself.
- Run
cargo clippy -- -D warningsbefore opening a PR. - Run
cargo fmtbefore opening a PR. - Every public API should have a doc comment with at least one example.
- New macros need both a positive test and a negative test.
MIT — see LICENSE.
"Gold is tested by fire, character by temptation — and contracts by crucible."