Skip to content

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 SyncInTxn projection, then read the balance right after Execute.
  • Reload an aggregate from the store and check state.
  • An idempotent retry: same (tenant, key) twice produces one effect. Test Strict too.
  • AuthZ denial: errors.Is(err, security.ErrDenied).
  • A unique key clash: ErrUniqueKeyViolation for a second account with the same owner in one tenant, and success across tenants.
  • OCC: append with a stale ExpectedVersion and expect ErrOptimisticConcurrency.
  • Snapshots: set a low threshold, run the snapshotter, and reload.
  • Hash chain: VerifyChain passes 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.

Docs track the go-sequent library. When APIs drift, the source wins.