测试指南

wesgine 使用多层测试策略确保引擎的正确性和稳定性:单元测试、集成测试、闸门测试(audit-lock)和自研静态分析器(internal/lint/)共同构成验证体系。


验证命令

所有验证通过一个命令完成:

make verify

这个目标包含:

  1. go build ./... — 编译
  2. go vet ./... — 标准静态分析
  3. internal/lint/ — 自研 analyzer
  4. scripts/audit-lock/ — 闸门测试
  5. 设计文档验证
  6. go test ./... — 单元测试和集成测试

步骤清单只在 Makefile 的 verify 目标里定义一份——新增检查加进那里,不要另起套件。


单元测试

组织原则

运行

# 全部测试
go test ./...

# 指定包
go test ./internal/cell ./internal/agentcatalog

# 指定测试函数
go test ./internal/memory -run TestSave_TrustZeroValueBackfill

# 带 race 检测
go test -race ./internal/memory/...

# 显示详细输出
go test -v ./internal/cell/...

测试模式

行为测试

测试应该断言行为关系,而不是冻结当前值:

// ✅ 好的:断言行为关系
func TestSave_EmptyKeyGetsContentDedupKey(t *testing.T) {
    entry := MemoryEntry{Content: "test", Kind: KindFact}
    id, err := store.Save(ctx, entry)
    require.NoError(t, err)
    got, _ := store.Get(ctx, id)
    assert.NotEmpty(t, got.Key) // 行为:空 key 会被自动填充
}

// ❌ 坏的:冻结具体值
func TestSave_HasSpecificKey(t *testing.T) {
    // ...
    assert.Equal(t, "content:abc123", got.Key) // 快照测试
}

负向测试

每个新 guard 都要有能恢复目标缺陷并使其在正确原因上失败的负向测试:

func TestSave_RejectsEmptyKind(t *testing.T) {
    entry := MemoryEntry{Content: "test"} // Kind 为空
    _, err := store.Save(ctx, entry)
    assert.ErrorIs(t, err, ErrKindRequired)
}

并发测试

并发测试必须证明竞态条件存在——拆开临界区确认它变红:

func TestChannelQuota_ConcurrentAdd(t *testing.T) {
    const workers = 10
    var wg sync.WaitGroup
    var successCount atomic.Int32
    
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            err := adapter.AddChannel(ctx, channelConfig(id))
            if err == nil {
                successCount.Add(1)
            }
        }(i)
    }
    wg.Wait()
    
    // cap=5,只有 5 个成功
    assert.Equal(t, int32(5), successCount.Load())
}

测试辅助

// 创建临时 Cell 用于测试
func testCell(t *testing.T) *Cell {
    t.Helper()
    dir := t.TempDir()
    // ...
    t.Cleanup(func() { cell.Stop(ctx) })
    return cell
}

集成测试

引擎级集成测试

go test ./cmd/wesgine ./internal/cell ./internal/agentcatalog

跨 Cell 隔离测试

修改 Hypervisor/Cell 交界的 PR 必须跨 2+ Cell 验证隔离性:

func TestCrossCell_MemoryIsolation(t *testing.T) {
    hyp := testHypervisor(t)
    
    cellA, _ := hyp.Cells().Create(ctx, specA)
    cellB, _ := hyp.Cells().Create(ctx, specB)
    
    // Cell A 写入记忆
    cellA.Memory().Save(ctx, entry)
    
    // Cell B 不应该看到
    results, _ := cellB.Memory().List(ctx, ListOptions{})
    assert.Empty(t, results)
}

Live API 测试

tests/live/ 目录包含实时 API 测试,需要运行中的 wesgine 实例:

# 启动 wesgine
bin/wesgine serve --data-dir /tmp/test --addr :9091

# 运行 live 测试
go test ./tests/live/... -tags live

闸门测试(audit-lock)

闸门是 wesgine 的架构守卫——用 shell 脚本检查源码是否满足不变量。闸门集 = scripts/audit-lock/ 文件系统。

闸门列表

编号文件守护的不变量
0606-skill-tiere.shTier E 技能不含行业术语
3838-host-env-shared.sh主机环境感知进程级共享
3939-proc-single-spawn.sh子进程创建单点
4040-bounded-line-reads.sh行式读取有界
4141-hypervisor-whitelist.shHypervisor 方法白名单
4242-hypervisor-api-sync.shHypervisor API 与分析器同步
4444-layout-tree.sh仓库布局树完整性
6060-actor-single-judgement.shActor 边界单点判据
6565-degraded-secret-no-writeback.sh降级密钥不回写
6666-quota-single-writepoint.sh配额判据单写点

运行闸门

# 运行全部闸门
scripts/audit-lock/run-all.sh

# 运行单个闸门
bash scripts/audit-lock/39-proc-single-spawn.sh

闸门原则

  1. 闸门能区分代码与注释——手写 grep 不能
  2. 闸门是双向的——多一个文件和少一个文件都判负
  3. 闸门的验收标准写在脚本开头注释里

新增闸门

在 scripts/audit-lock/ 下创建编号脚本:

#!/usr/bin/env bash
# Gate 99: 描述不变量
# 守护:INV-XXX-01
set -euo pipefail

# 检查逻辑
if ...; then
    echo "PASS: gate-99"
else
    echo "FAIL: gate-99: 描述失败原因"
    exit 1
fi

自研静态分析器

internal/lint/ 包含 wesgine 自研的 Go AST 分析器:

分析器列表

分析器检查内容
hypervisornobusinessHypervisor 不暴露业务方法
其他各种架构不变量

运行

go vet ./...  # 自研分析器随 go vet 一起运行

原理

分析器通过 Go 的 analysis 框架实现,在编译期检查 AST:

var Analyzer = &analysis.Analyzer{
    Name: "hypervisornobusiness",
    Doc:  "检查 Hypervisor 不暴露业务方法",
    Run:  run,
}

func run(pass *analysis.Pass) (interface{}, error) {
    // 遍历 AST,检查 Hypervisor 类型的方法
    // ...
}

设计文档验证

验证设计文档的完整性和一致性:

# 作为 make verify 的一部分自动运行
# 检查内容:
# - 文档引用的文件是否存在
# - 不变量编号是否连续
# - 闸门编号与脚本对应

测试最佳实践

DO

  1. 测试行为,不测试实现——断言输入到输出的关系
  2. 测试要有负向用例——不只测 happy path
  3. 并发测试要能变红——拆开临界区验证
  4. 跨 Cell 隔离必测——修改边界代码时
  5. 使用 t.TempDir()——不污染真实环境
  6. 使用 t.Cleanup()——确保资源释放

DON'T

  1. 不写快照测试——冻结当前值的测试是 change-detector
  2. 不 mock 整个世界——优先用真实 SQLite 而非 mock
  3. 不忽略 -race——并发 bug 只有 race detector 能发现
  4. 不在测试中 time.Sleep——使用 channel 或 condition 同步
  5. 不跳过 make verify——它是最终门禁

CI 集成

最小验证

# 每个 PR 必须通过
make verify

完整验证

# 发布前
make verify
go test -race -count=3 ./...

特定包深度测试

# 记忆子系统
go test -race -count=3 ./internal/memory/...

# Cell 生命周期
go test -race -count=3 ./internal/cell/...

# 治理
go test -race -count=3 ./internal/govern/...

故障排查

症状可能原因排查方法
闸门失败源码不满足不变量读闸门脚本的注释了解预期
race 检测报错并发访问无保护查看 race 报告的 goroutine 栈
测试超时死锁或无限循环go test -timeout 30s 缩短超时
测试在 CI 失败本地通过环境差异或时序依赖检查 TZ、LANG、并发度