更名说明 :本项目原名 KubeSentry ,自 2026-11-03 起更名 KubeStasis (避免与既有项目重名);CRD group 也于 11-04 由
apps.sentry.io改为kubestasis.io。前几篇文章里的旧名指的都是同一个项目,代码仓库现为 github.com/weirong-ui/...。
番外篇说明:系列正文八篇在上一篇收尾了。这篇是补的番外,回答正片里一直没展开的问题,这些东西到底是怎么测出来的。可以单独读,不必按顺序。
前八篇里我反复写"实测""跑过一遍",但一直没交代这些数字是怎么来的。这个问题不太好答,因为我一开始也没打算认真测。
真实的过程是:0 个用例 → 31 → 57 → 103 → 113 → 137 → 139。这些数字的每一次跳变,基本都跟着一次修复或一个新能力走,很少有"先把覆盖率补上去"的那种批量加。测试不是先设计好再写的,是被事故一条一条打出来的。
这篇讲我最后划的三条线,以及每条线为什么不能再往外扩一寸。
零、先给结论:三层各管什么
| 层级 | 工具 | 验什么 | 不验什么 | 现状 |
|---|---|---|---|---|
| 纯函数 | go test,无 K8s 对象 |
决策表、状态机迁移、校验规则、序列化 | 任何写操作 | 85 个(scaler 61 + healer 24) |
| fake client | client/fake |
单次 Reconcile 的写路径、补丁构造、冲突重试 | watch、真实 apiserver 校验、默认值 | controller 16 里的写路径部分 |
| envtest | 进程内 etcd + kube-apiserver | watch → enqueue → Reconcile → 写回 的闭环 | kubelet、调度、GC、Deployment 控制器 | 4 条链路,CI 独立 job |
| kind e2e | 真集群 | 上面所有"不验"的部分,加镜像和 webhook | 快速反馈(分钟级) | P0/P2/P3 三条 |
这张表是结论。下面讲每一层为什么只能停在那里。
一、覆盖率对这个项目没什么用
先说一个不好听的事实:controller-runtime 项目的行覆盖率很容易刷到 80% 以上,同时什么也没测到。
原因在于 Reconcile 的形态。一个 Reconcile 函数里塞着十来次 client.Get / Create / Patch,你把它们全跑一遍,覆盖率自然很高。但真正会出事的地方是这些调用的顺序 和失败分支:status 写在前还是 spec 写在前、conflict 之后重试几次、拿不到对象时是返回 nil 还是返回 err。这些都不是行数能反映的。
所以我后来放弃看覆盖率,改成问三个问题:
- 这条用例失败的时候,报错信息能不能直接告诉我是哪个分支错了?
- 如果我把被测代码故意改坏一行,有几条用例会红?
- 这条用例是锁住一个已经踩过的坑,还是我猜将来可能有坑?
第三个问题最关键。猜出来的用例会写成"happy path 加一个想当然的边界",锁不住任何东西。前八篇里写到的那些坑,凡是能在这一层锁住的,最后都变成了一条名字里带着故障现象的用例。
二、第一层:纯函数,最便宜的回归
internal/scaler 里 61 个用例,internal/healer 里 24 个,加起来占了 139 的六成。这一层单包跑完 1 到 3 秒,是唯一能做到"改一行就跑一遍"的层级。
把时钟做成参数,是这一层能成立的前提。
Decide 早期长这样,Engine 里挂着一个 nowFunc 字段:
go
type Engine struct {
source MetricsSource
nowFunc func() time.Time
}
看起来无害,实际有两个后果。一是测试不能并行,因为改 nowFunc 是改共享状态;二是用例之间会互相污染,一个用例设了固定时钟,下一个忘了恢复就飘了。11-05 那次重构把它删掉了,时钟改成 Decide 的第三个参数:
go
func (e *Engine) Decide(ctx context.Context, app *appsv1alpha1.App,
now func() time.Time) Decision
改完立刻能写这两条:
go
func TestDecide_DoesNotMutateInput(t *testing.T) // 决策不改传入对象
func TestDecide_ConcurrentSafe(t *testing.T) // 并发调用无竞态
第二条和 CI 的 -race 门是配套的:一个在代码层面约束"不许改输入",一个在运行时抓并发写。光靠自觉守不住纯函数这个承诺。
同样重要的是"没数据"和"数据是 0"要分开。
TestPrometheusSource_NoDataIsNotZero 这个名字就是结论。Prometheus 查不到序列时返回空结果,如果你把空结果当 0,ErrorRate=0 会让自愈引擎认为"一切正常",QPS=0 会让伸缩器认为"可以缩容"。这两种误判都很难在日志里看出来,因为代码走的都是同一条路径。
处理办法是让信号携带"是否有效":
go
type Signal struct {
Type appsv1alpha1.MetricType
Value float64
Valid bool
}
Valid=false 的信号不参与 max 计算,也不触发动作。用例要同时覆盖三条分支:有数据、无数据、数据恰好是 0。
最值钱的一条用例,来自一个让所有 App 都起不来的 bug。
Scaling / Health 原来是值类型加 omitempty:
go
Scaling ScalingPolicy `json:"scaling,omitempty"`
omitempty 对结构体不生效。控制器回写对象时,比如加 finalizer,会把省略的 health 序列化成 {"strategy":""},而 strategy 的 CRD 校验是 enum,空字符串直接被拒。结果就是:任何省略 spec.health 的 App,第一轮 Reconcile 就无限报错。
样例 CR 恰好把字段写全了,所以 P0 阶段完全没暴露。修法是把两个字段改成指针,然后补一条锁住序列化行为的用例:
go
func TestAppSpec_OmitsNilPolicies(t *testing.T)
这条用例不测任何业务逻辑,只测 JSON 里有没有多出零值对象。它比任何覆盖率数字都有价值。
三、第二层:fake client,验一层写路径
纯函数测不了"我到底往 apiserver 写了什么"。这一层的职责就是抓补丁。
第一个坑:status 子资源要显式声明。
go
c := fake.NewClientBuilder().
WithScheme(testScheme(t)).
WithStatusSubresource(&appsv1alpha1.App{}).
WithObjects(app).
Build()
少了 WithStatusSubresource,Status().Update() 会被当成普通更新处理,status 和 spec 的写入语义就混在一起了。测试照样绿,但它验的东西和真集群不是一回事。
这一层最典型的一条用例,锁的是"status 取哪个字段"。
go
func TestUpdateStatus_ReadsRealReplicas(t *testing.T) {
// Deployment 只有 2 个就绪,spec 想要 3
deploy := &appsv1.Deployment{Status: appsv1.DeploymentStatus{
ReadyReplicas: 2,
UpdatedReplicas: 2,
}}
// ...
if got.Status.CurrentReplicas != 2 {
t.Errorf("currentReplicas = %d, want 2 (real ready)", got.Status.CurrentReplicas)
}
}
为什么值得单独一条?因为写错成 spec.replicas 的时候,kubectl get app 在 Pod 全挂的情况下仍然显示"满编 3/3"。这个错误在真集群里要等到出事那一刻才会被发现,而在 fake client 里三行就能锁死。
并发冲突怎么测?用拦截器注入一次失败。
真实场景是 11-03 那次:L3 回滚时 status 回写刚改过 resourceVersion,spec 写入立刻撞 the object has been modified,错误冒泡成了 KubeStasisError 事件。修法是 retry.RetryOnConflict,但怎么证明重试真的生效?
fake client 的 interceptor.Funcs 可以精确地让第一次 Patch 失败:
go
func conflictOnce() (interceptor.Funcs, *int) {
calls := new(int)
return interceptor.Funcs{
Patch: func(ctx context.Context, c client.WithWatch, obj client.Object,
patch client.Patch, opts ...client.PatchOption) error {
if _, isApp := obj.(*appsv1alpha1.App); isApp && *calls == 0 {
*calls++
return apierrors.NewConflict(
schema.GroupResource{Group: "kubestasis.io", Resource: "apps"},
obj.GetName(), fmt.Errorf("the object has been modified"))
}
return c.Patch(ctx, obj, patch, opts...)
},
}, calls
}
用例同时断言三件事:err == nil(冲突被吸收)、*calls == 1(只注入了一次,没有被反复重试掩盖)、replicas 5 → 6(最终写成功)。只断言第一件是不够的,因为"重试十次才成功"和"一次就成功"在 err == nil 里看不出区别。
这一层的能力边界很清楚:它能验我构造的补丁,不能验 apiserver 会不会接受这个补丁。CRD schema、enum、默认值都不在这一层。
四、第三层:envtest,验闭环
前面两层都有个共同的盲区:没有任何东西真的 watch 过 。我可以在单测里手动调 Reconcile(req),但真实链路上是 apiserver 发事件、informer 入队、workqueue 去重、然后才轮到我。
envtest 补的就是这段。它在进程内起一个真实的 etcd 和 kube-apiserver,没有 kubelet、没有 controller-manager、没有调度器。
test/envtest/suite_test.go 里有几个决定值得单独说,每一个都不是随手写的。
CRD 只装 config/crd/bases。
go
env := &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
ErrorIfCRDPathMissing: true,
AttachControlPlaneOutput: false,
}
装的是 controller-gen 生成的那份产物,和 kubectl apply 装进集群的完全同一份文件。这样 envtest 顺带把 CRD 产物本身也验了:如果 schema 生成错了,测试起不来。
ErrorIfCRDPathMissing: true 是防路径写错。默认行为是路径不存在就跳过装 CRD,然后所有用例因为 no matches for kind "App" 失败。那个报错会让你去怀疑 scheme,而不是怀疑路径。
每个用例独立命名空间。
go
func newNamespace(t *testing.T, prefix string) string {
ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: prefix + "-"}}
if err := k8sClient.Create(context.Background(), ns); err != nil {
t.Fatalf("create namespace: %v", err)
}
t.Cleanup(func() { _ = k8sClient.Delete(context.Background(), ns) })
return ns.Name
}
共用 default 命名空间会出事:上一个用例留下的 Deployment 还在,下一个用例的 Get 可能拿到别人的对象。用 GenerateName 而不是 t.Name(),因为测试名里带下划线和斜杠,不能直接做 namespace。
伸缩引擎故意不注入指标源。
go
Scaler: scaler.NewEngine(), // 未注入指标源,伸缩保持休眠------用例才确定
这一行是整个套件里最重要的设计。如果注入了真实指标源,Reconcile 每轮都会尝试伸缩,4 条链路就会随机地扩缩容,用例变成"有时过有时不过"。不注入时 Decide 稳定返回 ActionNone + ReasonNoMetricsSource,测的才是部署和状态机,不是伸缩。
四个用例各自锁一条链路。
text
TestCreateApp_ReconcilesChildren 创建 → Deployment/Service/ownerRef/finalizer/status
TestDeploymentReady_AppBecomesAvailable 代写 Deployment.status → App Available=True
TestDeleteApp_FinalizerReleased 删 App → 摘 finalizer → 对象消失
TestDrift_DeploymentReplicasCorrected 改 Deployment.replicas=7 → 被拉回 3
第三条和第四条是重点。
删除那条要先等到 finalizer 就位再删,否则 delete 直接成功,什么都没验到:
go
eventually(t, waitTimeout, "finalizer 就位", func() (bool, error) {
if err := k8sClient.Get(ctx, key, &app); err != nil {
return false, err
}
return len(app.Finalizers) > 0, nil
})
漂移那条是幂等性的控制循环级回归。真集群里有人 kubectl scale 绕过 App,CreateOrUpdate 必须把它拉回来。这个行为在单测里只能验"我算出了正确的期望值",验不到"watch 真的把我的期望值应用上去了"。
五、envtest 的四条硬边界
这一节是这篇文章真正的重点。envtest 很容易被当成"轻量版集群",一旦这么认为,就会写出一堆看起来在验集成、实际什么都没验的用例。
边界一:没有 kube-controller-manager,Deployment 的 status 永远是空的。
你 Create 一个 Deployment,.status.readyReplicas 一直是 0。因为填这个字段的是 Deployment 控制器,envtest 里没有它。
所以第二条用例手工代写:
go
// 代写 Deployment.status(真实集群里这是 kubelet 干的)
deploy.Status.Replicas = 2
deploy.Status.ReadyReplicas = 2
deploy.Status.AvailableReplicas = 2
deploy.Status.UpdatedReplicas = 2
if err := k8sClient.Status().Update(ctx, &deploy); err != nil {
t.Fatalf("update deployment status: %v", err)
}
这是模拟输入,不是伪造通过。区别在于:我明确知道这个字段在真集群里由谁填,envtest 里我替它填,验的是从 status 到 App.status.conditions 的那一段转换。如果哪天 Deployment 控制器真的不填这些字段了,这条用例不会告诉你。那件事归 kind e2e 管。
边界二:没有 kubelet,Pod 永远不会 Ready,containerStatuses 永远是空的。
这意味着 healer 的 Inspect(读 containerStatuses[].state.waiting.reason 判断 CrashLoop)没法在 envtest 里验 。Pod 会停在 Pending,status.containerStatuses 是空数组,Inspect 只能看到"没有异常"。
所以 healer 那 24 个用例全部喂构造出来的 Pod 对象,不经过 apiserver。这是唯一可行的做法,代价是"我构造的 Pod 长得像不像真的"这件事没人保证。lastState.terminated.reason 和当前 terminated 都算 OOMKilled,这条规则就写在这里,而它是否覆盖了真集群的全部形态,envtest 回答不了。
边界三:没有 GC,删掉 App 之后 Deployment 会留下。
TestDeleteApp_FinalizerReleased 只断言 finalizer 摘除、App 对象消失。它不断言 Deployment 也被删了,因为 ownerReference 的级联删除由 kube-controller-manager 里的 GC 控制器执行,envtest 里没有这个控制器。
代码注释里写得很直白:
go
// (envtest 无 kube-controller-manager,GC 不跑,Deployment 残留是预期------
// 真集群的 ownerRef 级联回收由 kind e2e 兜底验证。)
如果有人不知道这条,会写一个"删 App 后 Deployment 也应该消失"的用例,然后发现它在 envtest 里必挂,进而怀疑自己的 finalizer 逻辑有问题。实际上逻辑是对的,是这个环境不会回收。
边界四:没有 scheduler,Pod 不会被调度到任何节点。
推论和边界二连在一起:既然 Pod 停在 Pending,任何依赖"Pod 跑起来之后"的行为都验不了。探针失败、OOMKilled、CrashLoopBackOff,这三类自愈的触发条件在 envtest 里造不出来。
所以矩阵是这样的:
| 想验的东西 | 只能用哪一层 |
|---|---|
| 决策算法对不对 | 纯函数 |
| 补丁写对没有、冲突重试了没有 | fake client |
| watch 触发没有、status 回写没有、finalizer 放行没有 | envtest |
| Pod 真崩了会不会自愈、镜像坏了会不会回滚 | kind e2e |
这张表是从代码注释和目录结构里读出来的,不是一开始就设计好的。suite_test.go 的注释自己就写着"envtest 没有 kube-controller-manager,Deployment.status 由测试代写",删除用例的注释里写着"GC 不跑,Deployment 残留是预期"。这些话是踩过之后补的,写在那儿就是为了让下一个人不用再踩。
六、防止测试静默变绿
这是我踩得最疼的一类坑:测试明明没跑,CI 却是绿的。
第一道:KUBESTASIS_REQUIRE_ENVTEST=1。
没有 envtest 二进制时,TestMain 里 env.Start() 会失败。默认行为是让所有用例 t.Skip,本地 go test ./... 不阻塞开发。但这意味着 CI 上如果忘了装二进制,整套集成测试会静默跳绿。
我实测过这个现场。在没装二进制的机器上跑:
text
=== RUN TestCreateApp_ReconcilesChildren
--- SKIP: TestCreateApp_ReconcilesChildren (0.00s)
=== RUN TestDeploymentReady_AppBecomesAvailable
--- SKIP: TestDeploymentReady_AppBecomesAvailable (0.00s)
=== RUN TestDeleteApp_FinalizerReleased
--- SKIP: TestDeleteApp_FinalizerReleased (0.00s)
=== RUN TestDrift_DeploymentReplicasCorrected
--- SKIP: TestDrift_DeploymentReplicasCorrected (0.00s)
PASS
ok github.com/weirong-ui/kubestasis/test/envtest 3.520s
四条全 Skip,包状态是 PASS。如果只看 CI 有没有红叉,这就是一次完美通过。
加上环境变量之后,同一个命令:
text
FATAL: start envtest: unable to start control plane itself: failed to start the controlplane. retried 5 times: exec: "\\usr\\local\\kubebuilder\\bin\\etcd": executable file not found in %PATH%
FAIL github.com/weirong-ui/kubestasis/test/envtest 2.885s
所以 CI 的 envtest job 里这一行不是可选项:
yaml
- name: go test ./test/envtest
env:
KUBESTASIS_REQUIRE_ENVTEST: "1"
run: go test ./test/envtest/... -v -count=1
-count=1 也有用。Go 默认缓存测试结果,改了代码但没改依赖时,可能直接返回上次的 ok (cached)。
第二道:-race。
11-05 加进 build job。同期做的一件事是把 Decide 纯化,它原本会改 Engine 的时钟字段,两个 goroutine 同时决策就是数据竞争。这类 bug 不会让测试失败,只会让线上概率性地算错一次决策,普通单测永远抓不到。
第三道:用例数只认一个源。
STATUS 里的 139、README 徽章里的 139、文档正文里的 139,这三处只要有一处漂移,就会有人拿着旧数字去写简历。这件事人工核对漏过一次:用例数已经从 137 加到 139,README 开发节里还写着 113。后来改成 hack/doc_consistency.py 机器查,用例数以徽章为单一事实源,历史演进行豁免。
一个数字能被三处引用,就一定会漂。
七、今天在 Windows 上跑一遍的真实结果
写这篇之前,我在 Windows 上从 c2fef19 起了一份干净克隆,把能在本机跑的两层都跑了一遍。这不是为了引用 CI 的截图(CI 是 Ubuntu),而是想知道换平台会发生什么。kind e2e 没跑,那需要真集群。
全套单测:
text
ok .../internal/controller 3.392s
ok .../internal/healer 1.690s
ok .../internal/metrics 2.793s
ok .../internal/scaler 1.996s
ok .../internal/webhook 3.094s
ok .../pkg/miniinformer 0.755s
ok .../test/envtest 3.520s
--- PASS 行数 139,--- SKIP 行数 4,和 STATUS 完全一致。分包核对:
| 包 | 用例 |
|---|---|
internal/scaler |
61 |
internal/healer |
24 |
internal/webhook |
24 |
internal/controller |
16 |
pkg/miniinformer |
11 |
internal/metrics |
3 |
装上 envtest 二进制之后,4 条链路真跑:
text
--- PASS: TestCreateApp_ReconcilesChildren (2.22s)
--- PASS: TestDeploymentReady_AppBecomesAvailable (0.42s)
--- PASS: TestDeleteApp_FinalizerReleased (0.42s)
--- PASS: TestDrift_DeploymentReplicasCorrected (0.42s)
ok github.com/weirong-ui/kubestasis/test/envtest 9.684s
第一条 2.22 秒,后面每条 0.42 秒。差的那 1.8 秒是控制面启动,不是用例本身。整包 9.68 秒,CI 上是 34 秒(Ubuntu,从零装二进制)。
日志里能看到真实的链路:
text
installing CRD {"crd": "apps.kubestasis.io"}
Starting EventSource {"source": "kind source: *v1alpha1.App"}
Starting EventSource {"source": "kind source: *v1.Deployment"}
Starting EventSource {"source": "kind source: *v1.Service"}
Starting workers {"worker count": 1}
DEBUG events app available: 2/2 replicas ready {"reason": "KubeStasisReconcile"}
三个 EventSource 就是这四行声明的结果:
go
func (r *AppReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&appsv1alpha1.App{}).
Owns(&appsv1.Deployment{}). // Deployment 变化也会触发 Reconcile,加速收敛
Owns(&corev1.Service{}). // Service 被误删时自动重建
Complete(r)
}
日志里的三个 kind source 和 For / Owns 三行一一对应。这条对应关系值得记一下:如果你在 envtest 里发现某个资源的变更没触发 Reconcile,先回去数 Owns 有几行。
但 Windows 上有一个退出时的坑。
text
stop envtest: [unable to signal for process ...\kube-apiserver to stop: not supported by windows,
unable to signal for process ...\etcd to stop: not supported by windows]
env.Stop() 在 Windows 上没法向子进程发信号,于是返回一个错误。包状态仍然是 ok,退出码 0,所以你很可能不会注意到这行。我事后查了进程表,etcd 和 kube-apiserver 都没有残留,说明父进程退出时它们被带走了。
但如果你在 Windows 上遇到过"端口被占用"或"下次启动 apiserver 失败",先看看是不是上一次的进程还没走。CI 跑 Ubuntu,没有这个问题。
八、这套东西没覆盖什么
按这个项目一贯的写法,边界要自己先说出来。
覆盖不到的,写在 STATUS 里: envtest 没有覆盖 webhook 与 controller 的联动,也没有覆盖 status 子资源的并发写竞争。前者要靠 kind e2e 的负向用例(:latest 被拒、默认值注入生效),后者目前完全没有自动化测试,只在 11-03 那次事故后靠 RetryOnConflict 和 conflictOnce() 那条单测兜着。
kind e2e 也只覆盖三条链路。 P0(部署 + Available)、P3(webhook 拒绝与默认值)、P2(crash 镜像 → L2 → L3 自动回滚 + 防乒乓)。伸缩那条链路有对照压测的数据,但没有进 e2e 断言,因为负载生成器和采样周期让它的时间成本远超一个 CI job 的合理范围。
探针的失败路径没有被测过。 probes_test.go 验的是"探针配置生成了什么",不是"探针失败之后会发生什么"。后者需要 kubelet,只能真集群。
139 个用例里没有一条是模糊测试或属性测试。 决策函数的输入空间不算大,我判断收益不高。这个判断可能是错的。
九、要照抄的话,抄这四件事
如果你也在写 Operator,这套结构里真正可迁移的只有四条:
- 时钟做参数,不要做字段。 这是纯函数层能成立的唯一前提,也是并行测试的前提。
- 每一层都写清楚"这一层造不出什么"。 envtest 造不出 CrashLoop,fake client 造不出 CRD 校验,纯函数造不出 watch。写下来,就不会有人写两小时注定失败的用例。
- 给"测试没跑"单独设一道闸。 Skip 是开发期的便利,CI 上必须变成失败。
REQUIRE=1这种变量只需要一次os.Exit(1)。 - 让用例名带上故障现象。
TestAppSpec_OmitsNilPolicies比TestScalingSerialize有用,因为半年后你只记得"那次所有 App 都起不来",不记得"序列化"。
至于用例数量。没人定过 139 这个目标,它是跟着事故一条一条长出来的。
系列其他文章
- HPA 只会伸缩不会自愈?我设计了一个 K8s 应用自愈 Operator:KubeStasis
- 从零写一个 K8s Operator:kubebuilder 初始化到第一个 Reconcile
- 为什么 HPA 会震荡?手写一个带冷却窗口的伸缩决策引擎
- 给 K8s 加一个"自动值班员":分级故障自愈引擎
- 图解 client-go Informer:手写一个 mini-Informer 才敢说懂
- 给 Operator 加 Admission Webhook 踩的 4 个坑(其中 3 个本可以不踩)
- 跟原生 HPA 做对照压测:五轮里前四轮全废,最后一个结论也被我自己降级
- Operator 上生产前补的 6 件事,和 5 件我明确不做的
代码与完整能力清单:github.com/weirong-ui/...(v0.1.0 快照) · docs/STATUS.md(唯一可信账本,含"已知不解决的问题") · docs/DESIGN.md §5 测试策略
文中的测试输出来自 c2fef19,2026-11-09 在 Windows 本地实测;CI 数字来自 Ubuntu 上的 envtest job。两边平台不同,耗时不可直接比较,结论一致。