登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go testing.T Cleanup 怎么安排资源释放:注册顺序、子测试与失败路径

来源:17golang原创

时间:2026-08-26 00:21:06 461浏览 收藏

CI 里最难受的一类 Go 单测失败,不是业务断言错了,而是前一个测试留下的临时目录、环境变量或共享连接,让后一个测试偶尔读到旧状态。单个测试在本机能过,换成 `t.Parallel()` 或跑完整包就开始抖,通常说明资源的生命周期没有跟测试作用域绑定。

要点速览
  • 函数内的 defer 适合清理当前函数创建的资源,t.Cleanup 更适合把释放动作注册给测试作用域。
  • 同一个测试中多次注册的清理函数按后进先出执行,子测试的清理不会越过父测试边界。
  • 清理函数要能处理断言失败、提前返回和子测试失败;不要把关键回收动作藏在“成功路径”里。

先看一个会在完整测试里抖动的场景

假设测试需要一个临时配置目录。开发者先写了一个辅助函数,把目录建好后返回路径;调用方在测试末尾手动删除。只要中间新增一个断言,或者某个分支提前 return,目录就可能留在机器上。更隐蔽的是,下一次测试为了省事复用了同一个环境变量。

func TestLoadConfig(t *testing.T) {
    dir := t.TempDir()
    t.Setenv("APP_CONFIG_DIR", dir)

    if err := writeConfig(dir); err != nil {
        t.Fatal(err)
    }
    // 这里失败时,后续手动清理代码根本不会执行。
    got := loadConfig()
    if got.Port != 8080 {
        t.Fatalf("port = %d, want 8080", got.Port)
    }
}

这段代码本身并不一定有错:t.TempDirt.Setenv 都会由测试框架回收。问题在于,很多项目里的自定义资源夹具仍然沿用“返回资源,调用者记得清理”的约定。清理动作越靠近创建动作,越不容易被新增分支漏掉。

defer、Cleanup 和框架托管资源怎么选

三种方式不是互相替代。我的判断规则是:资源只在当前函数里使用,就用 defer;资源由测试辅助函数创建,并且应该在当前测试结束时释放,就在创建函数里注册 t.Cleanup;临时目录、环境变量这类标准能力优先交给 testing 包。

场景优先方式验收重点
当前函数打开的文件defer file.Close()打开成功后立即注册
测试夹具创建的监听器或连接t.Cleanup夹具失败也不能遗留进程
临时目录和环境变量t.TempDirt.Setenv不要在并行测试中手动改全局状态

Cleanup 的价值不在于“比 defer 更晚”,而在于它把清理动作挂到了 *testing.T 的生命周期上。辅助函数可以创建资源、注册清理,然后把一个已经可用的句柄交回测试主体。

把创建和清理绑在一个测试夹具里

下面这个夹具创建一个带关闭动作的临时存储。注册清理要紧跟在创建成功之后,避免中间又插入一段可能失败的初始化。

type testStore struct {
    root string
    db   *sql.DB
}

func newTestStore(t *testing.T) *testStore {
    t.Helper()

    root := t.TempDir()
    db, err := sql.Open("sqlite", filepath.Join(root, "test.db"))
    if err != nil {
        t.Fatalf("open test db: %v", err)
    }
    t.Cleanup(func() {
        if err := db.Close(); err != nil {
            t.Errorf("close test db: %v", err)
        }
    })

    store := &testStore{root: root, db: db}
    if err := migrate(store.db); err != nil {
        t.Fatalf("migrate: %v", err)
    }
    return store
}

这里有两个细节。第一,t.Helper() 让失败位置落在测试调用处,排查时不必穿过夹具内部。第二,关闭数据库的动作在迁移之前就已经注册,所以迁移失败时依然会执行关闭。清理函数里用 t.Errorf 记录关闭错误即可,不要再次调用会中断流程的 t.Fatal

清理注册顺序决定了依赖资源的关闭顺序

多个清理动作会按后进先出执行。可以把它理解成栈:后创建的资源先释放。若 HTTP 客户端依赖一个临时服务,那么先注册服务的关闭,再创建客户端并注册客户端关闭,最终顺序就是客户端先关、服务后关。

Go testing.T.Cleanup 资源栈示意:测试夹具按创建逆序释放数据库连接与临时服务
func newFixture(t *testing.T) *fixture {
    t.Helper()

    service := startService(t)
    t.Cleanup(func() { service.Close() })

    client := newClient(service.URL())
    t.Cleanup(func() { client.CloseIdleConnections() })

    return &fixture{service: service, client: client}
}

如果把服务关闭注册在最后,测试结束时可能先关服务,再让客户端清理连接,日志里就会出现一串看似无关的连接错误。清理函数的注册顺序应该表达资源依赖,而不是按代码长度或“最后统一处理”的习惯排列。

子测试的边界:父资源能共享,子清理不能越界

父测试注册的清理会等所有子测试结束后执行;子测试注册的清理只在该子测试返回后执行。这很适合“父级建一次、子级复用”的只读夹具,但不适合让子测试修改一个必须恢复的全局变量。

func TestParser(t *testing.T) {
    store := newTestStore(t)

    t.Run("valid", func(t *testing.T) {
        seed(store, "valid")
        t.Cleanup(func() { clearRows(store) })
        checkValid(t, store)
    })
    t.Run("invalid", func(t *testing.T) {
        seed(store, "invalid")
        t.Cleanup(func() { clearRows(store) })
        checkInvalid(t, store)
    })
}

这个例子仍有一个并行风险:两个子测试如果改成 t.Parallel(),它们会同时操作同一个存储。Cleanup 只保证清理时机,不会自动提供隔离。需要并行时,把夹具创建移进每个子测试,或者为每个子测试分配独立 schema、目录和连接。

失败路径要怎么验收

不要只验证成功断言。可以故意让初始化、主体断言和子测试分别失败,然后观察临时进程、文件描述符和数据库连接是否都收尾。实际项目里,我会把检查拆成三条:

  • 夹具中途失败:创建完成的资源是否已有清理登记。
  • 测试调用 t.Fatal 或 panic:清理是否仍然执行。
  • 父子测试组合:子测试资源是否在父级资源仍可用时释放。

如果清理函数本身要调用业务代码,最好让它具备幂等性。比如关闭已经关闭的连接、删除不存在的临时文件,都不应该再制造一条会掩盖原始断言的错误。

Go 子测试失败后的清理验收:断言失败、资源回收和父测试收尾三个阶段

常见问题

Cleanup 和 defer 哪个先执行?

如果都注册在同一个测试函数里,函数返回时函数体中的 defer 会先执行,测试作用域结束时才执行 t.Cleanup。不要依赖两者之间传递仍然有效的资源,最好让每个清理动作独立完成。

Cleanup 里能不能调用 t.Fatal?

不建议。清理阶段应尽量使用 t.Errorf 或记录错误,避免在回收过程中再次中断测试。更重要的失败原因应该在主体测试阶段已经留下。

用了 t.TempDir 还需要手动删除目录吗?

不需要。t.TempDir 会把目录生命周期交给测试框架。只有额外创建的目录、外部服务或自定义缓存,才需要在夹具中补上自己的 t.Cleanup

一份可以直接放进代码审查的清单

  1. 资源创建成功后,是否立刻注册了清理动作?
  2. 清理动作是否覆盖提前返回、断言失败和初始化失败?
  3. 多个资源之间的注册顺序,是否对应“后创建先释放”?
  4. 子测试是否共享了不该共享的目录、连接或全局变量?
  5. 清理错误是否会掩盖真正的业务断言?

把生命周期写进夹具,测试主体就只需要表达“准备什么、验证什么”。这不是为了让代码看起来更整齐,而是让失败之后仍然留下可信的现场:该关的连接已经关,该删的目录已经删,下一条测试不会继承上一条测试的情绪。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>