Reference
Testing
How to test go-sequent applications: testkit Given/When/Expect for aggregates, memory-store integration tests, OCC and idempotency cases, Postgres tests via GO_SEQUENT_PG_DSN, and benchmarks.
Testing
go-sequent is designed so that most of your tests do not need a database. Aggregates are pure, and an in-memory store supports full integration tests of the write path.
Test levels
| Level | Approach |
|---|---|
| Aggregate unit tests | testkit Given/When/Expect |
| App and integration | Memory store with the real CommandService, as bank.NewDemoApp does |
| AuthZ, idempotency, tenancy | Integration tests against the memory store (TestIT_Memory_* in the repo) |
| OCC | Append with a stale ExpectedVersion |
| Postgres | Run when GO_SEQUENT_PG_DSN is set, skipped otherwise |
go test ./... must pass without a DSN.
Aggregate tests with testkit
testkit.For(id, factory) rebuilds an aggregate from events, runs an action, and captures the result.
func TestKitOpenAccount(t *testing.T) {
id := uuid.New()
s := testkit.For(id, func(id uuid.UUID) aggregate.Root {
return bank.NewAccount(id)
}).Given().When(func(agg aggregate.Root) error {
return agg.(*bank.Account).OpenAccount("carol", "EUR", types.MustMoney("5.00", "EUR"))
}).Run()
if err := s.ExpectEvents(bank.NewAccountOpened("carol", "EUR", types.MustMoney("5.00", "EUR"))); err != nil {
t.Fatal(err)
}
}
With history:
func TestKitInsufficientFunds(t *testing.T) {
id := uuid.New()
s := testkit.For(id, func(id uuid.UUID) aggregate.Root {
return bank.NewAccount(id)
}).Given(
bank.NewAccountOpened("dave", "USD", types.MustMoney("10", "USD")),
).When(func(agg aggregate.Root) error {
return agg.(*bank.Account).Withdraw(types.MustMoney("20", "USD"))
}).Run()
if err := s.ExpectError("insufficient funds"); err != nil {
t.Fatal(err)
}
}
| Method | Meaning |
|---|---|
Given(events...) |
Prior history. Aggregate id and sequence numbers are filled in. |
When(fn) |
The action on the rehydrated aggregate. |
Run() |
Executes and captures produced events and any error. |
ExpectEvents(want...) |
Same event types, in order, with equal payloads. The envelope is ignored. |
ExpectNoEvents() |
No error and no events. |
ExpectError(substr) |
When returned an error that contains substr. |
Produced(), Err() |
Read the results directly. |
Test each business rule on the aggregate this way, and keep handlers thin.
Integration tests with the memory store
func newApp(t *testing.T) (*sequent.App, *bank.BalanceProjector, *memory.Store) {
app := sequent.New(sequent.Config{PublisherMode: sequent.SyncInTxn})
bank.RegisterEvents(app.Events)
store := memory.New(app.Events)
store.SetSnapshotThreshold(2)
store.SetUpcasters(app.Upcasters)
app.Repo.Store = store
app.Repo.Snapshots = store
app.Repo.Publisher = app.Publisher
app.Service.Tx = store
// register handler, aggregate, projector ...
return app, proj, store
}
Or use bank.NewDemoApp with a StoreFactory. Cover these scenarios:
- Command to events to
SyncInTxnprojection, then read the balance right afterExecute. - Reload an aggregate from the store and check state.
- An idempotent retry: same
(tenant, key)twice produces one effect. TestStricttoo. - AuthZ denial:
errors.Is(err, security.ErrDenied). - A unique key clash:
ErrUniqueKeyViolationfor a second account with the same owner in one tenant, and success across tenants. - OCC: append with a stale
ExpectedVersionand expectErrOptimisticConcurrency. - Snapshots: set a low threshold, run the snapshotter, and reload.
- Hash chain:
VerifyChainpasses on a clean stream.
Money lint
func TestBanFloat64Money(t *testing.T) {
type bad struct{ Amount float64 }
if err := testkit.BanFloat64Money(bad{Amount: 1.23}); err == nil {
t.Fatal("expected ban")
}
if err := testkit.BanFloat64Money(&bank.Deposit{Amount: types.MustMoney("1", "USD")}); err != nil {
t.Fatal(err)
}
}
Add one such assertion per command and event type in your own repo. See Money.
Postgres tests
Tests named TestIT_Postgres_* and TestIT_BankPG run only when GO_SEQUENT_PG_DSN is set. Otherwise they skip.
# Always: memory and unit tests; Postgres tests skip without a DSN
go test ./...
go test ./examples/bank/ ./examples/bankpg/ -count=1
# With Postgres
GO_SEQUENT_PG_DSN='postgres://user:pass@localhost:5432/go_sequent?sslmode=disable' \
go test ./examples/bankpg/ -count=1 -run TestIT_BankPG
Use a disposable database. Tests migrate the schema themselves.
Benchmarks
Package bench measures append throughput and OCC conflict storms:
go test ./bench/ -bench=. -benchmem
GO_SEQUENT_PG_DSN='postgres://…' go test ./bench/ -bench=BenchmarkPostgres -benchmem
| Benchmark | Intent |
|---|---|
BenchmarkMemoryAppendThroughput |
Sequential appends, new stream each op |
BenchmarkMemoryAppendSameStream |
Hot aggregate sequential append |
BenchmarkMemoryOptimisticConflicts |
Parallel stale ExpectedVersion produces conflicts |
BenchmarkMemoryConflictStormWithProgress |
Contended correct advances plus conflict count |
BenchmarkPostgresAppendThroughput |
PG sequential append (skipped without DSN) |
BenchmarkPostgresOptimisticConflicts |
PG conflict storm (skipped without DSN) |
Benchmarks are opt-in through -bench. Plain go test does not run them.
CI
The repository’s GitHub Actions workflow runs go mod tidy checks, go vet, golangci-lint, go test -race ./... without Postgres, and Postgres 16 integration tests for the event store and both bank examples. It needs no secrets. Tags matching v* run the same checks and then build release binaries of the CLI with GoReleaser.
Related
Docs track the go-sequent library. When APIs drift, the source wins.