vCluster e2e 测试规范实战指南:基于 Ginkgo v2、Gomega 与 Kind 的端到端测试约定
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
- 将文件放置在合适的
e2e/test_*目录中,包名必须与同目录文件一致; - 如果是新建
test_*包,必须在e2e_suite_test.go中添加空导入; - 如果测试新的资源类型,在
e2e/labels/labels.go中添加对应的标签常量; - 以同一包中的现有测试文件作为起始模板。
深入阅读
- 规范原文:.claude/rules/e2e-conventions.md
- 带注释的实战示例(DeferCleanup 放置、
Eventually与g Gomega、OrderedvsBeforeEach并排对比、集群客户端用法):.claude/references/e2e-examples.md - 破坏性操作后的重连模式:.claude/references/e2e-old-to-new-mapping.md
- 标签常量完整定义:e2e/labels/labels.go
- 时间常量定义:e2e/constants/timeouts.go
- 惰性 vCluster 创建实现:e2e/setup/lazyvcluster/lazyvcluster.go
- 模板渲染实现:e2e/setup/template/template.go
- 套件入口与标志位解析:e2e/e2e_config_test.go