vCluster e2e 测试规范实战指南:基于 Ginkgo v2、Gomega 与 Kind 的端到端测试约定

原创2026-09-22 18:01:35800 阅读
文章标签:云原生集群管理虚拟化多集群

vCluster e2e 测试规范实战指南:基于 Ginkgo v2、Gomega 与 Kind 的端到端测试约定

本指南系统梳理 vCluster 开源仓库 e2e/ 目录下的端到端测试编写规范,涵盖测试结构约定、Setup 辅助库、vCluster 惰性创建模式、标签过滤体系、并发安全与清理策略等核心内容。读者读完本指南后,能够遵循仓库既有约定在 e2e/test_* 下编写出可并行、可复用的 Ginkgo v2 测试套件,并理解 vCluster 独有的背景代理(Background Proxy)限制与重连方案。

测试框架与运行环境

vCluster 的 e2e 测试统一基于 Ginkgo v2 + Gomega 编写,运行目标为 Kind 集群上的 vCluster 实例。整套约定以 .claude/rules/e2e-conventions.md 为规范基准,并在 e2e/ 目录下落地为可运行的测试代码。

从源码结构看,e2e/ 遵循"一个功能一个 test_* 目录 + 一个 suite_*_test.go"的组织方式:

  • e2e/test_core/:核心功能测试(coredns、export_kubeconfig、lifecycle、metrics、sync 等);
  • e2e/test_security/:证书轮换、隔离、rootless、webhook 等安全相关测试;
  • e2e/test_storage/:snapshot 与 volumesnapshot 同步测试;
  • e2e/test_gatewayapi/、e2e/test_modes/、e2e/test_integration/、e2e/test_deploy/:Gateway API、节点同步/调度模式、插件集成、Helm 与 init manifests 部署等;
  • e2e/setup/:测试环境的惰性创建与模板渲染辅助库;
  • e2e/labels/:Ginkgo 标签常量定义;
  • e2e/constants/:集群名、镜像、超时等全局常量;
  • e2e/clusters/:共享集群依赖定义。

规范明确要求:在编写新测试之前,先阅读 e2e/test_*/ 下已有的测试以了解既有模式。

八大核心约定

1. 随机后缀避免并行冲突

所有可能发生资源名冲突的标识符都必须使用随机后缀,而不是固定名称。规范推荐两种方式:

  • 使用 objectmeta.GenerateName(Kubernetes 原生生成的随机名称);
  • 使用 random.RandomString(6) 生成 6 位随机字符串。

关键要点:

  • 尽量在单个测试 spec 内共享同一个后缀,使该测试创建的所有资源使用一致的后缀,便于关联与排查;
  • 该约定适用于所有可能冲突的标识符,而不仅仅是 Kubernetes 对象名——Helm release 名称、数据库名称等同样适用;
  • 如果某个标识符是包级 const,在并行运行时会冲突,就必须改为动态生成。

2. 上下文传播(Context Propagation)

Setup 辅助函数将对象存储在 context.Context 中,测试内通过 <resource>.From(ctx, name) 取回。这一模式避免了全局变量在并行测试间的状态污染,也是 e2e 框架 setup.All(...) 链式传递上下文的基础(参见 e2e/e2e_config_test.go 中 SynchronizedBeforeSuite 的上下文导出/导入机制)。

3. 异步检查必须使用 Eventually

vCluster 的操作是**最终一致(eventually consistent)**的。所有 API 检查都必须包裹在 Eventually 中,并使用 constants.* 定义的时间常量作为超时,绝不硬编码时长。

仓库中实际定义的时间常量位于 e2e/constants/timeouts.go:

常量 值 适用场景
PollingInterval 2s 轮询间隔
PollingTimeoutVeryShort 5s 极短等待
PollingTimeoutShort 20s 短等待
PollingTimeout 60s 常规等待
PollingTimeoutLong 120s 较长等待
PollingTimeoutVeryLong 300s 长时间等待(如资源密集场景)

4. 标签过滤(Labels for Filtering)

  • 如果 Describe 中的所有 spec 都应参与 PR 门禁,则在 Describe 上打 labels.PR;
  • 只有当 Describe 中部分 spec 不应参与 PR 门禁时,才在单个 It 上打 labels.PR;
  • 切勿在 It 上重复其外层 Describe/Context 已有的标签——Ginkgo 会继承外层标签,重复只会造成冗余噪音。

5. Ordered 上下文要克制

默认使用 BeforeEach,只有存在真正的顺序依赖时才使用 Ordered。详见下文"Ordered vs BeforeEach 决策表"。

6. 包注册(Package Registration)

每个测试包通过 var _ = Describe(...) 在包内自注册;e2e_suite_test.go 以空导入(blank import)方式引入所有测试包。新增 test_* 包时,必须记得在 e2e_suite_test.go 中追加空导入。

7. 错误断言风格

优先使用:

ginkgo.Expect(...).To(ginkgo.Succeed())

而不是:

ginkgo.Expect(...).NotTo(ginkgo.HaveOccurred())

8. gstruct 的使用边界

  • 简单字段断言不要用 gstruct,直接 Expect(obj.Field).To(Equal(...)) 即可;
  • 集合元素断言(slice、map 中的元素)才使用 gstruct 配合 ContainElement 等 matcher,避免手写循环。

Setup Helpers:测试环境构建

vCluster 的 e2e/ 目前还没有像 loft-enterprise 那样丰富的 setup/ 构建器库。在迁移与演进期间,遵循以下分层约定:

  • 集群定义:使用 e2e/clusters/registry.go 中已有的集群定义。从源码可见,该包只维护两个导出项:
    • HostCluster:Kind 宿主集群,是唯一在 SynchronizedBeforeSuite 中急切(eagerly)创建的共享依赖;
    • DefaultVClusterOptions:共享的 Helm/provider 选项集,供 lazyvcluster 包调用 cluster.Create 时消费,其中通过 providervcluster.WithBackgroundProxyImage(constants.GetVClusterImage()) 设置了背景代理镜像。
  • 模板渲染:使用 e2e/setup/template 包(template.MustRender())渲染带镜像覆盖参数的 vcluster YAML。其底层实现(e2e/setup/template/template.go)基于 Go 标准库 text/template,渲染结果写入临时文件并返回清理函数。
  • 共享 Setup 辅助:当某个 setup 模式在多个测试中重复出现时,应将其抽象为共享 helper,并作为 [infra] 子问题处理,放置在 e2e/setup/ 下,遵循函数式选项(functional-options)模式。
  • 内联 Setup:目前大多数测试直接用 Kubernetes client 内联完成环境搭建(创建 namespace、创建资源、DeferCleanup)。

作用域原则:常量与 Helper 就近存放

只被单个 test_* 包使用的测试专用常量、helper 函数和选项构建器,应放在该包内部,而不是提升到共享的 constants 或 setup 包。只有出现第二个使用者时,才将其提升到共享包。

外部服务供给

如果迁移的旧测试需要安装 Helm chart 或外部服务(通过 values.yaml 的 controlPlane.helmRelease、宿主资源或 CI 工作流步骤实现),应在迁移时将其标记为 [infra] 子问题。外部服务必须在测试内自包含地供给:在 BeforeAll 中通过 setuphelm.Upgrade() 安装,或通过集群依赖系统(cluster dependency system)供给。

vCluster 配置模式

部署模型:宿主急切、vCluster 惰性

每个 vCluster 配置与其使用它的 suite_*_test.go 放在一起。HostCluster(Kind 宿主)是 SynchronizedBeforeSuite 中唯一急切供给的依赖;每个测试专用 vCluster 则在套件自己的 BeforeAll 中通过 e2e/setup/lazyvcluster/lazyvcluster.go 的 LazyVCluster 惰性创建——它是 e2e-framework 中 vcluster.Create 的薄封装。

从 lazyvcluster.go 源码可见其核心行为:

  • 渲染 YAML 模板时注入三个默认变量:Repository、Tag、HostClusterName(取值来自 constants.GetRepository()、constants.GetTag()、constants.GetHostClusterName(),见 e2e/constants/image.go 与 e2e/constants/cluster.go);
  • 自动注册 DeferCleanup(cleanupTmpl) 清理渲染出的临时文件;
  • 提供三个可选项:
    • WithPreSetup(fn):在 vCluster 创建前运行,用于宿主侧前置条件(CRD、PVC 等 syncer 启动时需要的资源);
    • WithExtraClusterOpts(opts...):在默认 provider 选项之上追加选项;
    • WithExtraTemplateVars(vars):追加 YAML 模板变量,键值会覆盖默认的 Repository、Tag、HostClusterName。

嵌入式 YAML 模板

将 vcluster.yaml 内嵌在套件文件中(通过 //go:embed)。模板支持以下占位符,由惰性 helper 在 BeforeAll 时渲染并自动注册临时文件清理:

  • {{.Repository}} —— vCluster 镜像仓库;
  • {{.Tag}} —— vCluster 镜像标签;
  • {{.HostClusterName}} —— Kind 宿主集群名。

定义一个新的 vCluster 套件

规范给出四步流程:

步骤 1:创建 e2e/vcluster-myfeature.yaml,写入 vcluster 配置。

步骤 2:创建 e2e/suite_myfeature_test.go:

//go:embed vcluster-myfeature.yaml
var myFeatureYAML string

const myFeatureName = "myfeature-vcluster"

func init() { suiteMyFeature() }

func suiteMyFeature() {
    Describe("myfeature-vcluster", labels.MyFeature, Ordered,
        cluster.Use(clusters.HostCluster),
        func() {
            BeforeAll(func(ctx context.Context) context.Context {
                return lazyvcluster.LazyVCluster(ctx, myFeatureName, myFeatureYAML)
            })
            // spec functions...
        },
    )
}

步骤 3:如果需要新的过滤标签,将其添加到 e2e/labels/labels.go。labels.PR 只放在外层 suite 上(绝不放 spec 上)。

步骤 4:如果 vCluster 需要宿主侧前置条件(CRD、PVC、Helm 安装),传入 lazyvcluster.WithPreSetup(fn)。可复用的 helper 放在 e2e/setup/ 中(如 setup.SnapshotPreSetup、setup.MetricsServerPreSetup)。

注意:不要向 e2e/clusters/ 添加条目——该包只承载 HostCluster 和 DefaultVClusterOptions。

仓库中的真实范例可参考 e2e/suite_lifecycle_test.go:它内嵌 vcluster-cli.yaml,在 init() 中通过 suiteCLIVCluster() 注册 Describe("cli-vcluster", labels.CLI, Ordered, ...),并在 BeforeAll 中调用 lazyvcluster.LazyVCluster;由于 PauseResumeSpec 具有破坏性(会杀死 vcluster pod 与背景代理),因此显式使用 Ordered 并让 ConnectSpec 先执行。

Client Accessors:客户端获取方式

规范定义了三种标准客户端访问器:

访问器 类型 用途
cluster.KubeClientFrom(ctx, clusterName) kubernetes.Interface 宿主集群类型化客户端
cluster.CurrentKubeClientFrom(ctx) kubernetes.Interface 当前 vcluster 类型化客户端
cluster.CurrentClusterClientFrom(ctx) client.Client(controller-runtime) 当前 cluster CR 客户端

背景代理(Background Proxy)的限制与重连

套件级背景代理由 vcluster connect 在 SynchronizedBeforeSuite 期间启动,是一个一次性进程。以下情况会导致其失效:

  • vcluster pod 被暂停/恢复(如 certs rotate、certs rotate-ca、helm upgrade);
  • vcluster 的 CA 证书发生变化;
  • vcluster pod 因任何原因重启。

上述任一情况发生后,cluster.CurrentKubeClientFrom(ctx) 与 cluster.CurrentClusterFrom(ctx).KubernetesRestConfig() 返回的都是指向已死亡代理的连接。

如果你的测试包含破坏性 vcluster 操作(证书轮换、重启、配置变更),必须使用 connectVCluster() 模式建立全新连接——该模式在 .claude/references/e2e-old-to-new-mapping.md 的 "Reconnecting After Destructive Operations" 一节中有完整说明。

常见陷阱:构建 ConnectOptions 时,BackgroundProxyImage 必须设置为 constants.GetVClusterImage()——绝不使用 DefaultBackgroundProxyImage(upgrade.GetVersion())。在 dev 构建中,后者会产生无效的 Docker 镜像引用(空 tag),Docker 会拒绝它,代码随后会静默回退到进程内端口转发,从而挂死测试进程。这一点与 e2e/clusters/registry.go 中 DefaultVClusterOptions 使用 constants.GetVClusterImage() 的做法完全一致。

Cleanup 清理模式

非 Ordered 上下文中返回富化上下文

BeforeEach(func(ctx context.Context) context.Context {
    vClusterClient = cluster.CurrentKubeClientFrom(ctx)
    Expect(vClusterClient).NotTo(BeNil())
    return ctx
})

It 块内创建后立即注册清理

It("tests something", func(ctx context.Context) {
    _, err := vClusterClient.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{
        ObjectMeta: metav1.ObjectMeta{Name: nsName},
    }, metav1.CreateOptions{})
    Expect(err).NotTo(HaveOccurred())
    DeferCleanup(func(ctx context.Context) {
        err := vClusterClient.CoreV1().Namespaces().Delete(ctx, nsName, metav1.DeleteOptions{})
        Expect(clientpkg.IgnoreNotFound(err)).To(Succeed())
    })

    // ... test logic
})

注意清理时使用 clientpkg.IgnoreNotFound(err) 容忍资源已被删除的情况,避免清理阶段因 NotFound 而失败。

Labels 标签体系

标签可附加在 Describe、Context 或 It 上。完整列表见 e2e/labels/labels.go,源码中实际定义了以下分组:

PR 门禁:labels.PR(pr)——控制每个 PR 上运行的测试。

功能域标签:labels.Core(core)、labels.Sync(sync)、labels.Integration(integration)、labels.Deploy(deploy)、labels.Storage(storage)、labels.Security(security)。

资源级标签(用于 sync 测试内的定向过滤):PriorityClasses、RuntimeClasses、StorageClasses、IngressClasses、GatewayAPI、GatewayClasses、ConfigMaps、Secrets、NetworkPolicies、Pods、PVCs、Events、CoreDNS、Webhooks、Snapshots、VolumeSnapshots、Metrics。

套件主标签(每个 opt-in 套件一个):Scheduler、MetricsProxy、Certs、Rootless、Isolation、NodeSync、Plugin、CLI、ExportKubeConfig、Migration。

运行时可使用 --label-filter="..." 选择要执行的套件。

Ordered vs BeforeEach:决策表

默认使用 BeforeEach——套件会被并行化,每一个不必要的 Ordered 都是并行瓶颈。

信号 模式 原因
Spec 相互独立,仅共享 setup BeforeEach(不加 Ordered) 可并行;无级联失败
Spec 构成生命周期序列(create → mutate → verify → delete) Ordered + BeforeAll Spec 依赖先前 spec 的副作用
Setup 昂贵(数据库、部署了服务的 vcluster) BeforeEach —— 每个 spec 支付一次成本 并行执行可赢回墙钟时间;隔离防止误报失败
某个 spec 会删除或修改共享资源 BeforeEach + 每 spec 独立资源 其他 spec 无法依赖该资源仍然存在
Spec 必须以固定顺序运行,但不共享可变状态 重新思考 —— 很可能是独立 spec 没有真实依赖的 Ordered 是并行瓶颈

硬性规则:如果使用 Ordered,必须添加注释说明哪个 spec 依赖哪个先前 spec 的副作用。如果说不出来,就去掉 Ordered。

禁止在代码中遗留计划痕迹

代码注释、By() 文本或测试描述中不得包含迁移计划标识符(如 SP-0、SP-1、[migrate]、[infra]、[cleanup]、[consolidate])。这些是内部规划产物——代码应当读起来像从未存在过任何计划:

// FAIL — plan artifact leaked into comment
// SP-2: verify configmap is synced to host
By("SP-2: Checking configmap sync", func() { /* ... */ })

// PASS — describes intent without plan references
// Verify the configmap is synced to the host cluster
By("Checking that the configmap is synced to the host cluster", func() { /* ... */ })

新测试编写 Checklist

  1. 将文件放置在合适的 e2e/test_* 目录中,包名必须与同目录文件一致;
  2. 如果是新建 test_* 包,必须在 e2e_suite_test.go 中添加空导入;
  3. 如果测试新的资源类型,在 e2e/labels/labels.go 中添加对应的标签常量;
  4. 以同一包中的现有测试文件作为起始模板。

深入阅读

登录后查看全文
vcluster