A Go testing toolkit for Stellar and Soroban: assert on XDR, manage golden fixtures, and capture real chain data as test data.
Part of stellar-optics — Go tools that make Stellar and Soroban data legible. Siblings: stellar-xdr-lens (decode, explain, diff) and stellar-prism (tail live data).
Testing Go code that produces Stellar XDR means comparing two base64 strings.
When they differ, assert.Equal tells you this:
Error: Not equal:
expected: "AAAAAgAAAACS05bpJAmO8a9VODCyfbOCmh2YkUIwQLxzJkMlvAwp/AAAAGQAAAAAAAAAbwAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAB4f..."
actual : "AAAAAgAAAACS05bpJAmO8a9VODCyfbOCmh2YkUIwQLxzJkMlvAwp/AAAAGQAAAAAAAAA3gAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAB4f..."
That is technically correct and completely useless. focus tells you which field changed:
--- FAIL: TestPaymentEnvelope (0.00s)
payment_test.go:29: AssertEnvelopeEqual: envelopes differ — 1 difference
(ignored: **.SeqNum)
TransactionEnvelope
~ V1.Tx.Operations[0].Body.PaymentOp.Amount
- 1000000000 100.0000000 (stroops: 1000000000)
+ 2000000000 200.0000000 (stroops: 2000000000)
want AAAAAgAAAACS05bpJAmO8a9V… (172 bytes base64)
got AAAAAgAAAACS05bpJAmO8a9V… (172 bytes base64)
payment_test.go:30: AssertFee: fee is 100 (0.0000100 XLM), want 200 (0.0000200 XLM)
payment_test.go:31: AssertOperationTypes: operations are [payment], want [payment, change_trust]
[0] payment — pay 200.0000000 XLM to GAPB7…WYXZ
That is real output from a real go test run, not a mock-up. Note what it
does: names the field path, shows the amount in XLM as well as stroops, says
which ignore rule fired, keeps the base64 truncated but available, and reports
all three failures in one run.
Diffs are rendered by stellar-xdr-lens,
so the diff in a failing test is the same one lens diff prints at the
command line.
go get github.com/stellar-optics/stellar-focusRequires Go 1.25 or later.
The library carries no assertion framework, no diff library, and no CLI
framework. Test helpers get pulled into other people's projects, so
pkg/focus depends only on the standard library, the Stellar SDK and lens.
A test enforces this. The focus command lives in a
separate package the library never imports.
func TestBuildPayment(t *testing.T) {
got := BuildPayment(dest, "100") // whatever your code returns
focus.AssertEnvelopeEqual(t, wantXDR, got, focus.IgnoreVolatile())
focus.AssertOperationTypes(t, got, "payment")
focus.AssertFee(t, got, 100)
focus.AssertMemo(t, got, "text: invoice-42")
}Every assertion accepts XDR in whatever form your code produced it — a base64
string, raw []byte, any xdr.* value, or an already-decoded *lens.Value.
focus.AssertOperationFailed(t, result, 1, "payment_underfunded") submit_test.go:31: AssertOperationFailed: operation 1 failed with
"payment_no_trust", want "payment_underfunded"
got: payment_no_trust — The destination has no trustline for this asset.
→ The destination must establish a trustline before it can receive a non-native asset.
want: payment_underfunded
Codes are the short forms Horizon returns, and every one of the protocol's 208 result codes carries lens's plain-English explanation and hint.
focus.AssertEventEmitted(t, meta, focus.Event{
ContractID: "CBGSB…",
Topics: []any{"transfer", nil}, // nil matches any topic
})
focus.AssertReturnValue(t, meta, xdr.ScVal{Type: xdr.ScValTypeScvU32, U32: &seven})
focus.AssertContractTrapped(t, result)A non-match lists the events that were emitted, so you are not left guessing.
Without this, the library is unusable on real suites: a rebuilt transaction differs in its sequence number, signatures and time bounds every run.
focus.IgnoreVolatile() // sequence + signatures + time bounds
focus.IgnoreSequence()
focus.IgnoreSignatures()
focus.IgnorePaths("**.Signatures", "operations[*].sourceaccount")
focus.NormalizePath("**.SeqNum", func(v any) any { return v.(int64) / 100 * 100 })Patterns match the dotted paths lens reports: * is one segment, ** is any
depth, and indexes accept [*]. Within a segment, *, ? and [abc]/[!abc]
classes work like globs; matching is case-insensitive and patterns are anchored
to the tail of a path too, so operations[*].sourceaccount needs no prefix.
Two deliberate behaviours:
- Ignored paths are always named in the failure output. A silently discarded difference is how an assertion library stops being trustworthy.
- An ignore pattern that matches nothing is reported as a failure, even when the values are otherwise equal. A stale pattern is a test quietly weakening, and this is the only moment anyone would notice.
import "github.com/stellar-optics/stellar-focus/pkg/focus/require"
require.EnvelopeEqual(t, want, got) // stops the test; same messageSame split, same naming as testify's assert and require.
Malformed XDR, a nil value, a wrong type — all reported as ordinary test failures. A panic inside your test binary would destroy the output of every other test in the package, so a test feeds hostile input through every exported assertion.
func TestEnvelopeGolden(t *testing.T) {
g := golden.New(t, golden.WithType("TransactionEnvelope"))
g.Assert("payment", BuildPayment(dest, "100"))
}$ go test ./...
payment_test.go:15: golden: testdata/payment.golden.json does not exist yet.
Create it by running:
go test ./... -focus.update
$ go test ./... -focus.update
ok demo 0.012sFixtures are reviewable JSON, not opaque blobs. The base64 XDR is the source of truth for comparison; the decoded form sits beside it so a pull-request diff shows which field changed:
{
"format": "focus/v1",
"type": "TransactionEnvelope",
"description": "a successful Soroban invocation",
"xdr": "AAAAAgAAAACS05bpJAmO8a9VODCyfbOCmh2Yk…",
"captured": {
"network": "testnet",
"ledger": 3966820,
"txHash": "2b2e276a…",
"at": "2026-08-05T12:00:00Z"
},
"decoded": {
"type": "TransactionEnvelope",
"value": { "V1": { "Tx": { "SourceAccount": "GCJNH…ZST6", "Fee": 58002, … } } }
}
}A golden test nobody can review is a golden test nobody updates carefully.
The flag is namespaced -focus.update so it never collides with your own
-update.
go install github.com/stellar-optics/stellar-focus/cmd/focus@latest
# Record a real transaction from testnet as fixtures
focus capture tx 2b2e276a8f99a292… --out testdata/
# Later: check they still match the chain
focus verify testdata/$ focus verify testdata/
testdata/envelope.golden.json ok
testdata/result.golden.json ok
testdata/handwritten.golden.json SKIP no capture provenance, so there is nothing to re-fetch
3 checked: 2 ok, 0 drifted, 1 skippedverify exits 2 on drift and prints the structural diff, 1 when a fixture
could not be checked at all, and 0 when everything checked still matches. A
hand-written fixture is reported as skipped rather than quietly passing a
check it was never eligible for.
Defaults to testnet; use --network mainnet or --rpc-url.
Flags, FOCUS_* environment variables and a config file are three spellings
of the same settings, resolved in that order:
focus verify testdata/ --network mainnet
FOCUS_NETWORK=mainnet focus verify testdata/
focus verify testdata/ --config focus.yaml # or .focus.yaml in the working directory# focus.yaml
network: mainnet
rpc-url: https://mainnet.sorobanrpc.com
timeout: 45s
color: never
out: testdata
prefix: nightly
force: falseAn explicit --config must exist; a missing .focus.yaml is simply ignored.
Invalid settings fail before any request is made.
Failure-message formatting is snapshotted so a layout change is a reviewable diff rather than a silent change in what users see:
focus.AssertFailureSnapshot(t, "envelope_amount_mismatch", func(tb focus.TB) {
focus.AssertEnvelopeEqual(tb, want, got)
})Create or update snapshots with FOCUS_UPDATE_SNAPSHOTS=1 go test ./.... They
live under testdata/snapshots as plain text, .snap files.
go test ./... makes no network calls. The RPC client sits behind an
interface with an in-memory fake, so the whole suite runs offline and
deterministically — including the tests of focus capture and focus verify.
examples/lifecycle.ts demonstrates the complete Stellar transaction lifecycle
on testnet using @stellar/stellar-sdk:
- FUND — Create a claimable balance with predicates
- CLAIM — Claim the balance by the designated recipient
- ATTEST — Record the claim on a Soroban contract
- SETTLE — Finalize via contract call
cd examples
npm install
export FUNDER_SECRET="S..." # testnet account with XLM
export CLAIMANT_SECRET="S..." # testnet recipient
# Optional for attest/settle:
export ATTESTOR_SECRET="S..."
export ATTESTATION_CONTRACT_ID="C..."
npm run devThe script prints transaction hashes at each step. View them on Stellar Expert to verify.
CI runs npm run typecheck in examples/ to keep the example compiling.
See examples/README.md for full setup and a sample Soroban contract for the attest/settle steps.
Early but real. v0.1 does what this README says. The library API may shift before v1.0.
- Assertions for envelopes, results, fields and Soroban events.
- Golden files with
-focus.update; capture and verify against a live network. - Failure-message snapshots, a unified flag/env/config file, documented exit codes, and an offline end-to-end suite for the command.
- Soroban values render as the XDR wire shape — there is no contract-spec decoding yet. See docs/roadmap.md.
Adding an assertion or a fixture source is designed to be a one-file change. See CONTRIBUTING.md and docs/architecture.md.
Apache-2.0. See LICENSE.