vCluster e2e 测试错误处理规范:Cleanup 容错、精确断言与 Ginkgo/Gomega 实战指南

原创2026-09-22 17:32:09927 阅读
文章标签:云原生集群管理虚拟化多集群

vCluster e2e 测试错误处理规范:Cleanup 容错、精确断言与 Ginkgo/Gomega 实战指南

本文是 vCluster 开源仓库 .claude/rules/e2e-error-handling.md 的深度展开,聚焦 e2e 测试(Ginkgo v2 + Gomega)中的错误处理铁律:Cleanup 阶段如何只容忍 IsNotFound 而严格断言其余所有错误、何时不该使用 IgnoreNotFound、以及为什么必须断言"具体错误"而非仅仅断言"错误发生了"。结合仓库 e2e/ 目录下真实测试用例(如 test_pods.go、test_configmaps.go、test_webhook.go)的源码证据,你将掌握可复制的 Cleanup 模板、精确错误匹配的断言技巧,以及识别静默失败反模式的审查能力。

适用前提:先理解这套规范运行在哪里

这套错误处理规范是 vCluster e2e 测试套件的通用编码约定。在引用其中的任何代码片段之前,需要先明确其运行环境:

需要说明:该文件头部注释声明 "Generic core: e2e-tdd-workflow plugin references/e2e-error-handling.md (identical)",说明它是 e2e TDD 工作流插件的通用核心规则,在 vCluster 仓库内被完整保留并应用。下文引用源码时,均以 vCluster 仓库实际存在的文件为准。

一、Cleanup 的第一原则:只容忍 IsNotFound,其余错误全部断言

e2e 测试是长生命周期进程,一个泄漏的 namespace、ConfigMap 或 Deployment 会污染后续所有用例,甚至阻塞整个 suite 的 teardown(见 e2e-quality-checklist.md)。因此 Cleanup 代码的错误处理逻辑必须精确:IsNotFound 是唯一可以被静默放行的错误类型,除此之外的一切错误(连接失败、RBAC 拒绝、资源冲突等)都必须显式断言。

1.1 单操作清理(Single-operation cleanup)

直接删除资源,无需先读取它:

// GOOD
DeferCleanup(func(ctx context.Context) {
    err := client.Delete(ctx, name, metav1.DeleteOptions{})
    Expect(clientpkg.IgnoreNotFound(err)).To(Succeed())
})

这里 clientpkg.IgnoreNotFound(err) 是 k8s.io/client-go/tools/clientcmd/api 一族工具函数——确切说是 k8s.io/client-go/... 系列包中 IgnoreNotFound 的惯用写法(vCluster 的 e2e 测试中实际使用的是 kerrors.IsNotFound + 显式断言组合,见下文 1.3 节)。它的语义是:把 NotFound 错误转换成 nil,其余错误原样返回,随后被 To(Succeed()) 捕获并导致测试失败。

1.2 多步清理(get → mutate → delete)

当清理前需要先读取对象、修改后再删除时(例如移除某个注解、打上标签再删),每一步的错误都必须单独处理:

// GOOD — check IsNotFound on Get, assert all other errors at every step
DeferCleanup(func(ctx context.Context) {
    obj, err := client.Get(ctx, name, metav1.GetOptions{})
    if kerrors.IsNotFound(err) {
        return // already gone
    }
    Expect(err).NotTo(HaveOccurred())

    delete(obj.Annotations, someAnnotation)
    _, err = client.Update(ctx, obj, metav1.UpdateOptions{})
    Expect(err).NotTo(HaveOccurred())

    err = client.Delete(ctx, name, metav1.DeleteOptions{})
    Expect(clientpkg.IgnoreNotFound(err)).To(Succeed())
})

这个模板的关键点有三处,缺一不可:

  1. Get 阶段:if kerrors.IsNotFound(err) { return }——资源已被级联删除或由先前用例清理,直接安全退出;其余错误必须 Expect(err).NotTo(HaveOccurred());
  2. Update 阶段:不做任何容错,Expect(err).NotTo(HaveOccurred())——对象是我们刚 Get 到的,更新失败说明存在真实问题;
  3. Delete 阶段:再次使用 IgnoreNotFound,容忍"在读取和删除之间资源恰好被其他控制器删除"的竞态。

1.3 仓库中的真实落地形式:kerrors.IsNotFound 条件分支

在 vCluster 的 e2e 源码中,最主流的写法是在 DeferCleanup 内部用 kerrors.IsNotFound 做显式分支。以 test_configmaps.go 为例,它在 host 集群创建 ConfigMap 后立即注册清理:

DeferCleanup(func(ctx context.Context) {
    err := hostClient.CoreV1().ConfigMaps(hostNS).Delete(ctx, cmName, metav1.DeleteOptions{})
    if !kerrors.IsNotFound(err) {
        Expect(err).To(Succeed())
    }
})

e2e/test_core/sync/test_pods.go 的 createTestNamespace 辅助函数采用了完全相同的模式(test_pods.go):

DeferCleanup(func(ctx context.Context) {
    err := vClusterClient.CoreV1().Namespaces().Delete(ctx, nsName, metav1.DeleteOptions{})
    if !kerrors.IsNotFound(err) {
        Expect(err).To(Succeed())
    }
})

这种写法的信息量与 clientpkg.IgnoreNotFound(err) + To(Succeed()) 完全等价,但语义更直白:只有 NotFound 被豁免,其余任何错误都会让清理失败并暴露在测试报告中。两种写法都可以接受,但都严格遵循"只容忍 IsNotFound"这一原则。值得一提的例外:对于共享的固定 namespace(由多个 spec 复用、随 vCluster teardown 一并删除的资源),注释明确说明"不注册 DeferCleanup,将删除交给 vcluster teardown"(test_configmaps.go),这是对"只清理自己创建的资源"原则的补充(见 e2e-test-structure.md)。

二、何时不应该使用 IgnoreNotFound:同 It 内刚创建的资源要严格断言

规则文件专门用一段话划清了 IgnoreNotFound 的适用边界:

When NOT to use IgnoreNotFound: IgnoreNotFound is appropriate for resources that may have been deleted by cascade, by a prior spec, or by a controller. For resources the test just created in the same It block and expects to still exist at cleanup time, prefer a strict assertion — Expect(err).NotTo(HaveOccurred()). A NotFound in this case signals a test bug or unexpected controller behavior, not a benign race.

翻译成判断口诀:

场景 资源状态 推荐处理
资源可能被级联删除(如 namespace 删除时其子资源一并消失) 可能已不存在 IgnoreNotFound 或 kerrors.IsNotFound 分支
资源可能被前一个 spec 清理 可能已不存在 同上
资源可能被控制器回收(controller 主动删除) 可能已不存在 同上
资源刚在本 It 块内创建,清理时应仍在 应当存在 严格断言 Expect(err).NotTo(HaveOccurred())

最后一种场景中,如果出现 NotFound,说明测试自身有 bug(比如创建时名字拼写错误导致实际创建了另一个对象),或者控制器行为异常(比如 vCluster 的同步控制器提前删除了测试资源)。把它当良性竞态容忍掉,等于把真正的缺陷从测试报告里抹掉了。

2.1 严格断言在仓库中的体现

test_pods.go 的清理注释印证了这一边界。在等待 pod 运行后,代码验证了 pod 确实存在(通过 Eventually + Get 断言成功),因此清理时直接使用严格断言(test_pods.go 附近模式)。而 e2e-examples.md 中记录的另一个模式则展示了"删除 + 等待删除确认"的完整闭环——它不仅删除,还通过 Eventually 轮询确认资源确实消失:

DeferCleanup(func(ctx context.Context) {
    Expect(hostClient.CoreV1().Namespaces().Delete(ctx, fromNS, metav1.DeleteOptions{})).To(Succeed())
    Eventually(func(g Gomega) {
        _, err := hostClient.CoreV1().Namespaces().Get(ctx, fromNS, metav1.GetOptions{})
        g.Expect(kerrors.IsNotFound(err)).To(BeTrue())
    }).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed())
})

注意:这里的删除没有用 IgnoreNotFound,因为该 namespace 是当前 spec 专属创建的,清理时应当存在;删除失败即测试失败。

2.2 等待资源消失时的断言方向

当测试目标本身就是"验证资源被删除"时,IsNotFound 应该作为断言目标(期望值)出现,而不是被容忍的错误。见 e2e-examples.md 中等待同步资源消失的写法:

Eventually(func(g Gomega) {
    _, err := vClusterClient.CoreV1().Services(toNS).Get(ctx, toName, metav1.GetOptions{})
    g.Expect(kerrors.IsNotFound(err)).To(BeTrue(), "replicated service should be deleted after source is gone")
}).WithPolling(constants.PollingInterval).WithTimeout(constants.PollingTimeoutLong).Should(Succeed())

这里 IsNotFound 从"被豁免的错误"翻转成了"被期望的状态",方向感完全不同:前者容忍删除时资源已不在,后者断言资源最终必须不在。

三、禁止反模式:静默失败是 e2e 的头号敌人

规则文件明确列出两种永远不要使用的清理写法:

// NEVER
_, _ = ...
// NEVER
if err != nil { return }

3.1 _, _ = 丢弃错误

// BAD — silent failures mask regressions and leak resources
_, _ = client.Delete(ctx, name, metav1.DeleteOptions{})

两个 _ 同时丢弃了错误值和返回值。后果有两层:

  • 掩盖回归:如果 Delete 因为 RBAC 收紧、API 版本变化等原因持续失败,测试依然"绿",问题被推迟到生产环境爆发;
  • 泄漏资源:清理失败未被感知,namespace/PVC 永久滞留,污染后续用例、拖慢集群,甚至在资源配额(vCluster 仓库大量使用 ResourceQuota 测试)场景下让其他用例直接创建失败。

e2e-test-structure.md 的 Do/Don't 表里也有一行与之呼应:"Tolerate NotFound in cleanup, assert everything else" 对应 "Use _, _ = to swallow errors in DeferCleanup"——前者是 Do,后者是 Don't。

3.2 if err != nil { return } 无差别退出

// BAD — swallows connectivity/RBAC failures, not just NotFound
if err != nil {
    return
}

这种写法表面上"处理"了错误,实际上把所有错误都当成了"无所谓"。它吞掉的不是只有 NotFound,还包括:

  • 与 API Server 的连接错误(proxy 已死、网络抖动)——见下文 3.3 节关于 background proxy 的坑;
  • RBAC 拒绝(serviceaccount 权限被改);
  • 服务器端 5xx、超时。

这些恰恰是 e2e 最需要暴露的环境问题。正确的做法始终是:先判断 kerrors.IsNotFound(err),命中才返回;否则让断言接管。

3.3 补充背景:为什么"连接类错误"在 vCluster e2e 里尤其致命

规则文件本身的示例强调 IgnoreNotFound 只豁免 NotFound,而 .claude/rules/e2e-conventions.md 揭示了 vCluster e2e 特有的背景:suite 级的 background proxy 是一次性进程,在 vCluster pod 暂停/恢复、CA 证书轮换、pod 重启后会失效(e2e-conventions.md)。因此 Cleanup 中如果出现连接类错误,很可能意味着代理已经死亡,此时若用 if err != nil { return } 静默吞掉,测试会带着脏状态继续跑,产生大量虚假的连锁失败。这正是规范反复强调"只容忍 NotFound、断言其余一切"的现实动因。

四、错误断言:断言具体的错误,而不是"错误发生了"

4.1 为什么 Expect(err).To(HaveOccurred()) 是坏的

// BAD — passes on connectivity errors, RBAC failures, or any unrelated error
Expect(err).To(HaveOccurred())

这个断言只检查"err 非 nil",却不检查错误是什么。在负向测试(验证某操作应失败)中,它可能因为以下任意原因"通过":

  • 测试想要的 already exists(正确);
  • API Server 恰好不可达(错误但通过);
  • 测试 ServiceAccount 恰好无权限(错误但通过);
  • 参数写错导致的 400(错误但通过)。

更糟的是,它掩盖了行为漂移:如果产品行为变化导致该操作不再报错,测试反而会失败——这是好事;但如果操作报的是别的错误(比如从 already exists 变成 conflict),HaveOccurred() 依然通过,回归被放行。

4.2 正确姿势:MatchError + ContainSubstring

// GOOD
Expect(err).To(MatchError(ContainSubstring("already exists")))

MatchError(ContainSubstring(...)) 组合断言:错误非 nil 且错误消息包含指定子串。它同时验证了"失败确实发生"和"失败原因确实符合预期"。

4.3 仓库实战:精确断言的三种典型形态

形态一:校验拒绝原因的消息子串。test_webhook.go 在验证准入 webhook 拦截时断言了精确的拒绝消息:

Expect(err).To(MatchError(ContainSubstring("the pod contains unwanted container name")))
Expect(err).To(MatchError(ContainSubstring("the pod contains unwanted label")))

形态二:把子串与动态值拼接。test_syncer_metrics.go 在验证 syncer metrics 的认证鉴权时,把期望消息构造成包含用户名的完整断言:

g.Expect(res.err).To(MatchError(ContainSubstring(`User "system:anonymous" cannot get path "`+syncerMetricsPath+`"`)))

形态三:多行消息的跨行匹配。test_priorityclasses.go 与 test_runtimeclasses.go 针对多行错误消息断言其中关键行:

Expect(err).To(MatchError(ContainSubstring(
    // 省略中间行,断言的只是包含期望消息片段
)))

注意:Gomega 的 MatchError 匹配的是 error.Error() 的完整字符串,ContainSubstring 是子串匹配,因此多行消息中只要包含目标片段即可命中。这与 e2e-quality-checklist.md 中 "Assert specific error messages" 的要求一致。

4.4 另一种精确断言:状态原因断言

除消息子串外,规则体系还认可对 metav1.StatusReason 的断言(见 e2e-test-structure.md):kerrs.Be(metav1.StatusReasonNotFound) 这类写法比 HaveOccurred() 更精确——它基于 Kubernetes API 错误的结构化原因而非字符串,不受本地化/措辞影响。选择依据是:

  • 断言人类可读的拒绝理由(webhook 消息、校验消息)→ MatchError(ContainSubstring(...));
  • 断言API 语义状态(NotFound、Conflict、AlreadyExists)→ metav1.StatusReason 匹配器。

五、错误处理与 e2e 规范体系的联动:三条配套规则速览

错误处理不是孤立的,它与仓库内另外三份规则文件强耦合,建议配套阅读:

规则文件 与错误处理的关系 关键要求
e2e-conventions.md 定义框架、并发与命名前提 随机后缀防并行冲突(random.RandomString(6))、Eventually 配 constants.* 超时、Expect(...).To(Succeed()) 优于 NotTo(HaveOccurred())
e2e-test-structure.md 规定 DeferCleanup 注册时机 创建资源后立即注册清理,再写后续断言——断言失败会跳过清理注册导致泄漏
e2e-quality-checklist.md 10 项 pass/fail 检查清单 第 1 项"Cleanup 容忍已删除资源"、第 2 项"DeferCleanup 先于后续断言"、第 3 项"使用预定义超时常量"

其中 e2e-quality-checklist.md 的第 1 项与本篇主题直接对应,给出了 PASS/FAIL 对照:

// PASS
DeferCleanup(func(ctx context.Context) {
    err := client.CoreV1().Namespaces().Delete(ctx, nsName, metav1.DeleteOptions{})
    Expect(clientpkg.IgnoreNotFound(err)).To(Succeed())
})

// FAIL — hard-fails if resource is already gone
DeferCleanup(func(ctx context.Context) {
    Expect(client.CoreV1().Namespaces().Delete(ctx, nsName, metav1.DeleteOptions{})).To(Succeed())
})

第 2 项则强制"创建成功断言之后、任何其他断言之前"注册清理(e2e-quality-checklist.md):

// PASS — cleanup registered immediately, before any further assertions
_, err := vClusterClient.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{})
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func(ctx context.Context) { /* ... */ })
// now safe to assert further

六、把规范落到实际:一份完整的错误处理检查清单

综合规则文件与仓库源码,编写或评审 e2e 用例时可按以下清单逐项核对:

  1. 清理容忍度:Cleanup 中的每个 API 调用,是否只豁免 IsNotFound,其余错误全部显式断言?
  2. 豁免边界:被豁免的 NotFound 是否确实属于"可能被级联删除/先前 spec 清理/控制器回收"的资源?本 It 刚创建的资源是否改用了严格断言?
  3. 多步清理:get → mutate → delete 链路中,Get 的 IsNotFound 分支、Update 的严格断言、Delete 的 IgnoreNotFound 是否各就各位?
  4. 禁止静默失败:是否出现过 _, _ = 或 if err != nil { return }?
  5. 注册时机:DeferCleanup 是否紧跟创建成功断言之后、任何其他断言之前(e2e-quality-checklist.md)?
  6. 断言精度:负向用例是否使用 MatchError(ContainSubstring(...)) 或 metav1.StatusReason 匹配器,而不是裸 HaveOccurred()?
  7. 期望状态翻转:验证"资源被删除"时,是否把 kerrors.IsNotFound(err) 放在 g.Expect(...).To(BeTrue()) 的期望侧(e2e-examples.md)?
  8. 超时配套:清理中的 Eventually 是否使用 constants/timeouts.go 的预定义常量(PollingInterval = 2s、PollingTimeoutShort = 20s、PollingTimeout = 60s、PollingTimeoutLong = 120s、PollingTimeoutVeryLong = 300s),而非硬编码时长?

七、进一步阅读

一句话总结:在 vCluster 的 e2e 测试里,Cleanup 只对 IsNotFound 网开一面,其余错误必须显式断言;负向用例必须断言"具体错误"而非"错误发生了"——这两条铁律共同保证了 e2e 套件的失败永远指向真实缺陷,而不是环境噪声或静默泄漏。

登录后查看全文
vcluster