参考
测试
测试 go-sequent 应用:testkit 的聚合 Given/When/Expect、内存 store 集成测试、OCC 与幂等用例、用 GO_SEQUENT_PG_DSN 跑 Postgres 测试,以及基准测试。
测试
go-sequent 的设计让大部分测试不需要数据库。聚合是纯逻辑;内存 store 足以支撑整条写路径的集成测试。
测试层次
| 层次 | 做法 |
|---|---|
| 聚合单元测试 | testkit Given/When/Expect |
| 应用与集成 | 内存 store + 真实的 CommandService,如 bank.NewDemoApp |
| AuthZ、Idempotency、多租户 | 针对内存 store 的集成测试(仓库里的 TestIT_Memory_*) |
| OCC(用 ExpectedVersion 挡住并发写冲突) | 用过期的 ExpectedVersion 追加 |
| Postgres | 设置了 GO_SEQUENT_PG_DSN 时运行,否则跳过 |
go test ./... 必须在没有 DSN 的情况下通过。
用 testkit 测聚合
testkit.For(id, factory) 从事件重建聚合,执行一个动作,并捕获结果。
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)
}
}
带历史:
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)
}
}
| 方法 | 含义 |
|---|---|
Given(events...) |
已有历史。自动填写聚合 id 与序号。 |
When(fn) |
在重建后的聚合上执行的动作。 |
Run() |
执行并捕获产生的事件与错误。 |
ExpectEvents(want...) |
事件类型相同、顺序一致、载荷相等,忽略信封。 |
ExpectNoEvents() |
没有错误,也没有事件。 |
ExpectError(substr) |
When 返回的错误包含 substr。 |
Produced()、Err() |
直接读取结果。 |
每条业务规则都这样在聚合上测,Handler 就能保持精简。
用内存 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
// 注册 handler、aggregate、projector ...
return app, proj, store
}
也可以用带 StoreFactory 的 bank.NewDemoApp。建议覆盖这些场景:
- 命令 → 事件 → SyncInTxn(写事务内同步更新读模型),
Execute之后立刻读余额。 - 从 store 重新加载聚合并检查状态。
- Idempotency(同一业务键重复提交只生效一次)重试:同一个
(tenant, key)执行两次只产生一次效果;也测一下Strict。 - AuthZ 拒绝:
errors.Is(err, security.ErrDenied)。 - 唯一键冲突:同一租户下第二个同名 owner 返回
ErrUniqueKeyViolation;跨租户则成功。 - OCC:用过期的
ExpectedVersion追加,期望ErrOptimisticConcurrency。 - 快照:设较低阈值,跑 snapshotter,然后重新加载。
- hash chain(按聚合串联事件哈希,用来发现篡改):干净的事件流上
VerifyChain通过。
Money 检查
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)
}
}
请在自己的仓库里为每个命令和事件类型加上这样的断言。见 Money。
Postgres 测试
名为 TestIT_Postgres_* 与 TestIT_BankPG 的测试只在设置了 GO_SEQUENT_PG_DSN 时运行,否则跳过。
# 始终可跑:内存与单元测试;没有 DSN 时 Postgres 测试跳过
go test ./...
go test ./examples/bank/ ./examples/bankpg/ -count=1
# 带 Postgres
GO_SEQUENT_PG_DSN='postgres://user:pass@localhost:5432/go_sequent?sslmode=disable' \
go test ./examples/bankpg/ -count=1 -run TestIT_BankPG
请使用一次性的数据库;测试会自行迁移 schema。
基准测试
bench 包测量追加吞吐与 OCC 冲突风暴:
go test ./bench/ -bench=. -benchmem
GO_SEQUENT_PG_DSN='postgres://…' go test ./bench/ -bench=BenchmarkPostgres -benchmem
| 基准 | 目的 |
|---|---|
BenchmarkMemoryAppendThroughput |
顺序追加,每次一个新流 |
BenchmarkMemoryAppendSameStream |
热点聚合顺序追加 |
BenchmarkMemoryOptimisticConflicts |
并行使用过期 ExpectedVersion 产生冲突 |
BenchmarkMemoryConflictStormWithProgress |
竞争下的正确推进加冲突计数 |
BenchmarkPostgresAppendThroughput |
PG 顺序追加(没有 DSN 时跳过) |
BenchmarkPostgresOptimisticConflicts |
PG 冲突风暴(没有 DSN 时跳过) |
基准测试需要通过 -bench 显式开启;普通 go test 不会跑它们。
CI
仓库的 GitHub Actions 工作流会跑 go mod tidy 检查、go vet、golangci-lint、不带 Postgres 的 go test -race ./...,以及针对事件库与两个 bank 示例的 Postgres 16 集成测试。不需要任何 secret。匹配 v* 的 tag 会跑同样的检查,然后用 GoReleaser 构建 CLI 的发布二进制。
相关
文档随 go-sequent 源码更新;若与 API 不一致,以源码为准。