Controller 测试
写 Controller 测试最大的挑战是依赖比较重:Reconcile 函数要读写 K8s 对象,有时还要调用外部 API(云服务、数据库等)。如果每次测试都起真实集群,开发体验会很差。
解决思路是把外部依赖都换成假的——用内存 fake client 替换 K8s API Server,如果有外部 API 则用 mock 替换,然后直接调用 Reconcile 函数,验证它做了什么。这样一次测试只需几毫秒,不依赖任何外部服务。
测试分层
Controller 测试通常分为三层:
| 层次 | 测试对象 | 依赖 | 速度 |
|---|---|---|---|
| 单元测试 | 纯函数(diff、filter、build 等辅助逻辑) | 无 | 极快(毫秒) |
| 集成测试(fake client) | Reconcile 逻辑各分支 | fake K8s client + mock 外部依赖(可选) | 快(毫秒) |
| 集成测试(envtest) | Reconcile + 真实 API Server 交互 | etcd + kube-apiserver 二进制 | 慢(秒级启动) |
推荐策略:优先用 fake client 覆盖 Reconcile 各分支;只有需要验证 Webhook、CRD validation 等强依赖 API Server 行为的场景才引入 envtest。
测试工具栈
Kubebuilder 生成的项目默认引入了以下三个工具:
Ginkgo:BDD 风格测试框架
Ginkgo 提供 Describe / Context / It / BeforeEach 这套结构,把测试写成"在什么条件下,做了什么,应该得到什么结果"的形式,层次比 Go 原生 TestXxx 更清晰:
var _ = Describe("MyController", func() {
// Describe:描述被测对象
Context("资源第一次出现(没有 Finalizer)", func() {
// Context:描述前置条件
BeforeEach(func() {
// 每个 It 执行前都会跑,在这里构造初始状态、重置 mock
})
It("应该添加 Finalizer,不调用外部 API", func() {
// It:断言期望的结果
})
})
})
suite_test.go 是 Ginkgo 的入口,每个包只需要一个,kubebuilder 自动生成:
// internal/controller/suite_test.go
func TestControllers(t *testing.T) {
RegisterFailHandler(Fail) // 断言失败时通知 Go 测试框架
RunSpecs(t, "Controller Suite") // 运行本包下所有 Describe 块
}
go test 找到 TestControllers(符合 Go 的 Test* 规则),再由它驱动所有 Describe 块执行。
Gomega:断言库
Gomega 配合 Ginkgo 使用,断言格式为 Expect(实际值).To(匹配器),失败时会打印清晰的差异信息:
Expect(err).NotTo(HaveOccurred()) // err 应为 nil
Expect(result.RequeueAfter).To(Equal(30 * time.Second)) // 精确相等
Expect(obj.Finalizers).To(ContainElement("mygroup.io/finalizer")) // slice 包含某元素
Expect(mock.calls).To(HaveLen(1)) // slice 长度为 1
Expect(obj.Annotations).NotTo(HaveKey("foo")) // map 不含某 key
fake client:内存 K8s
sigs.k8s.io/controller-runtime/pkg/client/fake 提供纯内存实现的 client.Client,支持 Get / List / Create / Update / Delete,行为和真实 K8s client 一致,但数据只存在内存里:
fakeClient := fake.NewClientBuilder().
WithScheme(scheme). // 注册资源类型
WithObjects(existingObj1, obj2). // 预置初始对象,模拟集群里已有的资源
Build()
Reconciler 通过 client.Client 接口与 K8s 交互,fake client 实现了同一个接口,对 Reconciler 代码完全透明——Reconcile 函数不需要改任何东西。
核心思路
一个典型 Reconcile 函数的依赖关系:
Reconcile(ctx, req)
│
├── r.Get / r.List / r.Update ──► K8s API Server(必有)
│
└── cloud.Create / cloud.Delete ──► 外部系统(可选,不是所有 Controller 都有)
测试时分别替换:
K8s API Server ──► fake client(内存读写)
外部系统 ──► mock(记录调用,不真正执行) ← 仅在有外部依赖时需要
测试结束后,从两侧做断言:
- 从 fake client 读回对象 → 验证 K8s 侧状态(Finalizer 有没有加上、annotation 有没有删掉)
- 从 mock 的调用记录 → 验证外部 API 有没有被调用、参数是否正确(仅有外部依赖时)
如果 Controller 没有外部依赖,只用 fake client 就够了。
只有 K8s 依赖时的写法
对于纯 粹操作 K8s 资源的 Controller(没有外部 API),只需要 fake client:
var _ = Describe("MyReconciler", func() {
var (
ctx context.Context
fakeClient client.Client
reconciler *MyReconciler
)
BeforeEach(func() {
ctx = context.Background()
obj := &myv1.MyResource{
ObjectMeta: metav1.ObjectMeta{Name: "test-obj", Namespace: "default"},
}
fakeClient = fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(obj).
Build()
reconciler = &MyReconciler{Client: fakeClient}
})
It("应该创建 ConfigMap", func() {
req := reconcile.Request{
NamespacedName: types.NamespacedName{Namespace: "default", Name: "test-obj"},
}
_, err := reconciler.Reconcile(ctx, req)
Expect(err).NotTo(HaveOccurred())
// 从 fake client 读回,验证 Reconcile 是否创建了期望的 ConfigMap
cm := &corev1.ConfigMap{}
Expect(fakeClient.Get(ctx, types.NamespacedName{
Namespace: "default", Name: "test-obj-config",
}, cm)).To(Succeed())
Expect(cm.Data["key"]).To(Equal("value"))
})
})
有外部依赖时:接口隔离 + mock
如果 Reconciler 还需要调用外部系统,需要额外做两步:用接口持有外部依赖、写 mock 实现。
第一步:接口隔离
外部系统的调用必须通过接口,不能是具体类型:
// 定义接口,描述 Reconciler 需要外部系统提供哪些能力
type CloudClient interface {
CreateResource(ctx context.Context, id string) error
DeleteResource(ctx context.Context, id string) error
}
// Reconciler 持有接口
type MyReconciler struct {
client.Client
Cloud CloudClient // ← 接口,不是 *realcloud.Client
}
生产环境注入真实客户端,测试注入 mock,Reconcile 函数本身不需要修改:
// main.go
&MyReconciler{Client: mgr.GetClient(), Cloud: realcloud.NewClient(cfg)}
// 测试
&MyReconciler{Client: fakeClient, Cloud: &mockCloud{}}
第二步:写 mock
mock 只需实现接口方法,把调用入参记录下来:
type mockCloud struct {
createCalls []string // 每次 CreateResource 传的 id
deleteCalls []string
createErr error // 预设后下次调用会返回该错误,用来测试错误处理路径
}
func (m *mockCloud) CreateResource(_ context.Context, id string) error {
m.createCalls = append(m.createCalls, id)
return m.createErr
}
func (m *mockCloud) DeleteResource(_ context.Context, id string) error {
m.deleteCalls = append(m.deleteCalls, id)
return nil
}
测试里通过 mock 的记录做断言:
Expect(mock.createCalls).To(HaveLen(1)) // 被调用了一次
Expect(mock.createCalls[0]).To(Equal("ext-001")) // 参数正确
Expect(mock.deleteCalls).To(BeEmpty()) // 没有触发删除
完整示例
var _ = Describe("MyReconciler", func() {
var (
ctx context.Context
mock *mockCloud
fakeClient client.Client
reconciler *MyReconciler
)
// BeforeEach 在每个 It 前执行,每次都从干净状态开始,用例间互不影响
BeforeEach(func() {
ctx = context.Background()
mock = &mockCloud{} // 新建 mock,上一个用例的调用记录不会带入
obj := &myv1.MyResource{
ObjectMeta: metav1.ObjectMeta{Name: "test-obj", Namespace: "default"},
Spec: myv1.MyResourceSpec{ExternalID: "ext-001"},
}
fakeClient = fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(obj).
Build()
reconciler = &MyReconciler{Client: fakeClient, Cloud: mock}
})
Context("正常对账", func() {
It("应调用 CreateResource 并设置 Finalizer", func() {
req := reconcile.Request{
NamespacedName: types.NamespacedName{Namespace: "default", Name: "test-obj"},
}
result, err := reconciler.Reconcile(ctx, req)
Expect(err).NotTo(HaveOccurred())
Expect(result.RequeueAfter).To(Equal(30 * time.Second))
// 验证外部 API 被正确调用
Expect(mock.createCalls).To(HaveLen(1))
Expect(mock.createCalls[0]).To(Equal("ext-001"))
// 从 fake client 读回,验证 K8s 侧状态
// Reconcile 内部调用了 r.Update,fake client 的内存已同步更新
updated := &myv1.MyResource{}
Expect(fakeClient.Get(ctx, req.NamespacedName, updated)).To(Succeed())
Expect(updated.Finalizers).To(ContainElement("mygroup.io/finalizer"))
})
})
Context("资源被删除(DeletionTimestamp 已设)", func() {
It("应调用 DeleteResource 并移除 Finalizer", func() {
// 先给对象加上 Finalizer,这样 Delete 才会设置 DeletionTimestamp 而非直接删除
obj := &myv1.MyResource{}
nn := types.NamespacedName{Namespace: "default", Name: "test-obj"}
Expect(fakeClient.Get(ctx, nn, obj)).To(Succeed())
controllerutil.AddFinalizer(obj, "mygroup.io/finalizer")
Expect(fakeClient.Update(ctx, obj)).To(Succeed())
// fake client 和真实 K8s 行为一致:
// 有 Finalizer → Delete 设置 DeletionTimestamp,对象继续存在
// 无 Finalizer → Delete 直接删除对象
Expect(fakeClient.Delete(ctx, obj)).To(Succeed())
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: nn})
Expect(err).NotTo(HaveOccurred())
Expect(mock.deleteCalls).To(HaveLen(1))
// Finalizer 清除后,fake client 模拟 K8s GC,对象可能已被自动删除
// 无论是对象消失还是 Finalizer 为空,都是符合预期的结果
updated := &myv1.MyResource{}
getErr := fakeClient.Get(ctx, nn, updated)
if getErr == nil {
Expect(updated.Finalizers).NotTo(ContainElement("mygroup.io/finalizer"))
} else {
Expect(client.IgnoreNotFound(getErr)).To(Succeed())
}
})
})
})
应该覆盖哪些场景
Reconcile 是个状态机,每个分支都应该有对应的测试:
| 场景 | 初始状态 | 期望行为 |
|---|---|---|
| 资源不存在 | 空 store | 直接返回,不调用任何 API |
| 首次对账 | 有配置,无 Finalizer | 添加 Finalizer,不做业务操作(下次再同步) |
| 正常同步 | Finalizer 已就绪 | 执行业务逻辑,返回 RequeueAfter |
| 外部 API 出错 | mock 预设 error | 返回 error,等待重试 |
| 资源被删除 | DeletionTimestamp 已设 | 清理外部资源,移除 Finalizer |
| 用户关闭同步 | 某 annotation 标记暂停 | 跳过业 务逻辑,不 requeue |
envtest:需要真实 API Server 时
大多数 Reconcile 逻辑用 fake client 就够了。以下场景 fake client 覆盖不了,需要 envtest 启动真实的 etcd + kube-apiserver:
- Webhook 验证(MutatingWebhook / ValidatingWebhook)
- CRD 字段校验(
+kubebuilder:validationmarker 的实际效果) - Server-Side Apply 的字段合并行为
envtest 的配置方式(kubebuilder 已生成骨架):
// suite_test.go
var testEnv *envtest.Environment
var k8sClient client.Client
var _ = BeforeSuite(func() {
testEnv = &envtest.Environment{
// 指向 config/crd/bases,envtest 启动时会自动安装这些 CRD
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
ErrorIfCRDPathMissing: true,
}
cfg, err := testEnv.Start() // 启动 etcd + kube-apiserver
Expect(err).NotTo(HaveOccurred())
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme})
Expect(err).NotTo(HaveOccurred())
})
var _ = AfterSuite(func() {
Expect(testEnv.Stop()).To(Succeed())
})
envtest 需要本地有对应版本的 etcd 和 kube-apiserver 二进制,用 setup-envtest 工具下载:
go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest
setup-envtest use 1.30.0 --bin-dir ./bin/k8s -p path
kubebuilder 生成的 Makefile 已经封装好了,直接 make test 即可。
运行测试
# 跑全部测试
go test ./internal/... -v
# 只跑某个包
go test ./internal/controller/... -v
# 按场景名过滤(--ginkgo.focus 接受正则,匹配 Describe/Context/It 任意层级的标题)
go test ./internal/controller/... -v --ginkgo.focus "资源被删除"
go test ./internal/controller/... -v --ginkgo.focus "正常对账"
# 关闭 Ginkgo 彩色输出(CI 环境)
go test ./internal/... --ginkgo.no-color