真实测试:别让错误越走越远 划分测试边界,消除伪造 Mock 的逻辑幻觉
在人机协同开发中,最需要警惕的汇报之一是:
“代码已完成,测试已通过。”
审查测试代码时,常会发现明显的工程隐患:
当 Service 依赖数据库时,模型生成了一个返回 nil 的 FakeRepository,使每个调用都直接跳过底层读写; 测试仅断言无错误返回,未验证数据是否真实持久化或事务是否正确回滚; 并发逻辑中硬编码 time.Sleep(100 * time.Millisecond),在本地容易通过,而在持续集成流水线中因调度延迟产生随机 Flaky 报错。 这类测试的风险在于提供虚假的通过信号:模型基于自创的 Mock 验证了自身生成的逻辑,而真实的 SQL 语法、约束与并发竞争从未被运行时检验。
谨防 Fake Mock 构筑的虚假安全感
测试的核心目标不是为代码提供形式上的通过背书,而是提供不可伪造的运行时事实。纯算法缺陷交由单测捕获,数据持久化缺陷由真实容器拦截,并发时序缺陷由 synctest 与竞态检测器共同收敛。
无状态纯计算 ──→ 表驱动单元测试: 确定性输入映射确定性输出 (毫秒级响应)
业务状态流转 ──→ 真实容器集成测试: 加载真实数据库引擎, 执行原生迁移文件 (引擎直接裁决)
并发与时钟 ──→ testing/synctest: 虚拟时钟 bubble 与受控协程调度 (消除 Flaky) 无状态逻辑:表驱动单元测试 若目标函数无外部 I/O 依赖(无数据库、网络交互、全局状态及系统时钟变更),输出严格取决于入参,则最适合采用表驱动单测。
例如帖子热度评分计算:
func Score ( likes , comments int , age time . Duration ) int {
penalty := int ( age . Hours () / 24 )
return max ( likes + comments * 2 - penalty , 0 )
} 使用表驱动测试全面覆盖边界状态:
func TestScore ( t * testing . T ) {
tests := [] struct {
name string
likes , comments int
age time . Duration
want int
}{
{ name : "新帖子" , likes : 10 , comments : 3 , want : 16 },
{ name : "一天后衰减" , likes : 10 , comments : 3 , age : 24 * time . Hour , want : 15 },
{ name : "最低为零" , age : 30 * 24 * time . Hour , want : 0 },
}
for _ , tt := range tests {
t . Run ( tt . name , func ( t * testing . T ) {
if got := Score ( tt . likes , tt . comments , tt . age ); got != tt . want {
t . Fatalf ( "Score() = %d, want %d" , got , tt . want )
}
})
}
} 单测执行耗时在毫秒级,模型可在单轮内快速确认逻辑完备性。
业务状态流转:拒绝过度使用 Fake 假设实现"重复点赞具备幂等性"的业务逻辑。模型极易生成伪通过的 Fake 测试:
虚假 Mock 测试 (隐藏真实隐患)
type fakeLikeRepository struct {}
func ( fakeLikeRepository ) Add ( ctx context . Context , userID , postID int64 ) error {
return nil // 盲目返回 nil, 无法验证数据库唯一约束与事务
}
func TestLikePost ( t * testing . T ) {
service := NewLikeService ( fakeLikeRepository {})
if err := service . Like ( t . Context (), 1 , 100 ); err != nil {
t . Fatal ( err )
}
} 真实容器集成测试 (引擎直接裁决)
func TestLikePostIsIdempotent ( t * testing . T ) {
db := testutil . Postgres ( t )
ctx := t . Context ()
// 真实插入前置种子数据
db . Exec ( ctx , `INSERT INTO users (id, name) VALUES ($1, $2)` , 1 , "sanbei" )
db . Exec ( ctx , `INSERT INTO posts (id, user_id, content) VALUES ($1, $2, $3)` , 100 , 1 , "hello" )
service := NewLikeService ( NewPostgresLikeRepository ( db ))
// 验证首次点赞成功,二次点赞幂等消化,数据库唯一索引真实生效
if err := service . Like ( ctx , 1 , 100 ); err != nil {
t . Fatal ( err )
}
if err := service . Like ( ctx , 1 , 100 ); err != nil {
t . Fatalf ( "second Like() should be idempotent: %v" , err )
}
} Fake 只能反映编写者预设的乐观行为。一旦模型在第一轮对业务模型的理解产生偏差,后续生成的 Fake 和断言将沿袭该偏差,造成"单测全绿但上线崩溃"的局面。
真实业务集成测试通过 Testcontainers 启动与生产版本一致的临时数据库,由数据库引擎直接裁决约束。
基于 testcontainers-go 构建真实数据库环境 在 internal/testutil/helper.go 中建立统一的环境入口:
package testutil
import (
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"your/project/internal/migrations"
)
func Postgres ( t * testing . T ) * pgxpool . Pool {
t . Helper ()
if testing . Short () {
t . Skip ( "skipping integration test in short mode" )
}
ctx := t . Context ()
container , err := postgres . Run ( ctx ,
"postgres:18-alpine" ,
postgres . WithDatabase ( "app_test" ),
postgres . WithUsername ( "app" ),
postgres . WithPassword ( "app" ),
postgres . BasicWaitStrategies (),
)
if err != nil {
t . Fatalf ( "start postgres: %v" , err )
}
testcontainers . CleanupContainer ( t , container )
dsn , err := container . ConnectionString ( ctx , "sslmode=disable" )
if err != nil {
t . Fatalf ( "postgres connection string: %v" , err )
}
db , err := pgxpool . New ( ctx , dsn )
if err != nil {
t . Fatalf ( "connect postgres: %v" , err )
}
t . Cleanup ( db . Close )
if err := migrations . Up ( ctx , db ); err != nil {
t . Fatalf ( "run migrations: %v" , err )
}
return db
} 这里关键在于直接执行项目原生的 migration 脚本 ,避免在测试中单独维护一份建表语句导致与生产环境偏离。
通过该工具函数,业务测试可专注于状态验证:
func TestLikePostIsIdempotent ( t * testing . T ) {
db := testutil . Postgres ( t )
ctx := t . Context ()
if _ , err := db . Exec ( ctx ,
`INSERT INTO users (id, name) VALUES ($1, $2)` , 1 , "sanbei" ,
); err != nil {
t . Fatal ( err )
}
if _ , err := db . Exec ( ctx ,
`INSERT INTO posts (id, user_id, content) VALUES ($1, $2, $3)` , 100 , 1 , "hello" ,
); err != nil {
t . Fatal ( err )
}
service := NewLikeService ( NewPostgresLikeRepository ( db ))
if err := service . Like ( ctx , 1 , 100 ); err != nil {
t . Fatal ( err )
}
if err := service . Like ( ctx , 1 , 100 ); err != nil {
t . Fatalf ( "second Like() should be idempotent: %v" , err )
}
var likes int
if err := db . QueryRow ( ctx ,
`SELECT count(*) FROM post_likes WHERE user_id = $1 AND post_id = $2` , 1 , 100 ,
). Scan ( & likes ); err != nil {
t . Fatal ( err )
}
if likes != 1 {
t . Fatalf ( "like rows = %d, want 1" , likes )
}
} 该测试覆盖了数据迁移、SQL 执行、唯一约束冲突处理与数据层映射。出现任何字段不匹配或事务异常,数据库引擎将直接抛出明确错误。
环境遵循按需引入原则:仅依赖 PostgreSQL 的模块只启动对应容器;涉及缓存或流式消息时再按需扩充对应组件。
testing/synctest:纳秒级虚拟时间与并发确定性调度在测试并发与定时任务时,传统方案常通过调整时间间隔并配合 time.Sleep 验证。这种方式测试执行缓慢,且极易受 CI 调度抖动影响产生随机 Flaky 失败。
Go 1.25 正式引入标准库 testing/synctest :
synctest.Test 构建隔离的并发 bubble,接管其内部启动的全部 Goroutine;bubble 内的 time.Sleep、time.Timer 与 time.Ticker 接入虚拟时钟系统。当内部 Goroutine 全部处于阻塞状态时,时钟直接跳跃至下一个就绪事件; synctest.Wait 等待其他 Goroutine 进入稳定阻塞状态,无需硬编码等待时间;未退出的泄露协程在测试结束时将被明确捕获。 虚拟时间 bubble 消除调度抖动
在并发超时测试中,严禁用 time.Sleep(100ms) 碰运气。synctest.Test 在虚拟时钟中纳秒级跳跃调度,在数毫秒内即可完成生产级 3 秒超时边界的严密断言,杜绝 CI 调度延迟引发的偶发报错。
以异步审核超时的时序控制为例:用户发布内容后由异步 Worker 处理,系统设定 3 秒超时限制;超时未完成则拒绝发布:
var ErrModerationTimeout = errors . New ( "moderation timeout" )
type ModerationResult struct {
Approved bool
Reason string
}
func AwaitModeration ( ctx context . Context , result <- chan ModerationResult ) ( ModerationResult , error ) {
timer := time . NewTimer ( 3 * time . Second )
defer timer . Stop ()
select {
case <- ctx . Done ():
return ModerationResult {}, ctx . Err ()
case r := <- result :
return r , nil
case <- timer . C :
return ModerationResult {}, ErrModerationTimeout
}
} 使用 synctest 可以在保持生产级 3 秒超时参数的前提下,在数毫秒内完成纳秒级边界断言:
func TestAwaitModerationTimeout ( t * testing . T ) {
synctest . Test ( t , func ( t * testing . T ) {
result := make ( chan ModerationResult )
done := make ( chan error , 1 )
go func () {
_ , err := AwaitModeration ( t . Context (), result )
done <- err
}()
// 推进至距截止时间仅剩 1ns, 此时不可提前触发超时
synctest . Sleep ( 3 * time . Second - time . Nanosecond )
select {
case err := <- done :
t . Fatalf ( "returned before deadline: %v" , err )
default :
}
// 再推进 1ns 跨越边界, 此时必须触发超时
synctest . Sleep ( time . Nanosecond )
if err := <- done ; ! errors . Is ( err , ErrModerationTimeout ) {
t . Fatalf ( "AwaitModeration() error = %v, want %v" , err , ErrModerationTimeout )
}
})
} 测试在虚拟时间中走过 3 秒,但在宿主机上仅耗费数毫秒,精确验证了 3 秒阈值的前后边界。
测试套件的分层执行命令:
go test -short ./... # 快速单元测试 (毫秒级)
go test -count= 1 ./... # 包含容器集成测试的完整套件
go test -race -count= 1 ./... # 并发竞态检测门禁 Agent 提示词配置实践 在工程协同规范文件(AGENTS.md)中建立契约:
### Go 测试规范
1. 无外部 I/O、输出严格由输入决定的纯计算函数,使用标准库 `testing` 编写表驱动单元测试。
2. 涉及 Handler、Service、Repository、数据库事务或状态变更的业务,必须编写集成测试;通过 `internal/testutil/helper.go` 和 Testcontainers 启动真实依赖并应用项目迁移脚本。
3. 禁止使用空返回的 Fake Repository 替代数据库验证。Fake 仅限于稳定模拟不可控的外部依赖故障。
4. 并发、超时与定时器控制使用 `testing/synctest` 验证,严禁使用任意时长的真实 `time.Sleep` 进行调度猜测。
5. 测试用例之间必须维持数据隔离,避免相互依赖执行顺序或残留数据。
6. 提交前必须执行 `go test -count=1 ./...` ;涉及并发逻辑时执行 `go test -race -count=1 ./...` 。 全面测试分层工作流 纯计算逻辑表驱动 :针对无状态映射采用表驱动单测,毫秒级快速确认逻辑完备性。核心业务状态流转 :通过 Testcontainers 启动真实 PostgreSQL 容器并自动执行原生迁移脚本,由真实数据库引擎裁决约束。时钟与并发状态机 :接入 testing/synctest 虚拟时钟 bubble,纳秒级推进时间,消除不确定性等待。并发竞态门禁 :执行 go test -race -count=1 ./... 作为提交最终断言。