When you change a Kubernetes operator, you usually want fast feedback: did Reconcile return the right ctrl.Result, treat errors the way you expect, and write the child objects you care about—without starting a cluster for every edit. Ginkgo and Gomega are one way to spell that out so tests read like stories, and controller-runtime’s fake client gives you an in-memory API to exercise those paths in milliseconds.
The fake client, envtest, and kind guide explains which test layer to use when. This page is the how for the fast layer: how to lay out Describe / Context / It (think what you are testing → given this starting state → then this should happen), how to keep pure “desired state” helpers easy to cover with small table tests, and where to stop relying on the fake client—because it does not run admission, CRD validation, or webhooks the way a real apiserver does; for that you still reach for envtest or kind.
By the end, you should be able to wire a minimal Ginkgo suite next to your reconciler, assert on Result, typed errors, and objects after Reconcile, and split CI so slow integration jobs do not hide quick unit signal.
If you want to go deeper first (helpful but skippable for a first pass): Requeue, errors, and ctrl.Result, controller-runtime architecture.
What you will build
The practical testing path is:
pure desired-state helpers
-> fast table tests
-> Ginkgo suite bootstrap
-> fake client Reconcile specs
-> assertions on ctrl.Result, error, and stored objects
-> envtest or kind only when fake-client semantics are not enoughI validated the examples in a temporary Go module with current packages:
github.com/onsi/ginkgo/v2 v2.31.0
github.com/onsi/gomega v1.42.0
sigs.k8s.io/controller-runtime v0.24.1
k8s.io/api v0.36.2The local Go binary was go1.24.4, but the latest Kubernetes modules required a newer toolchain, so go test automatically downloaded and used Go 1.26. That is worth calling out in CI: pin your Kubernetes/controller-runtime versions and make sure the runner Go version matches their go directive.
A full runnable copy of the reconciler, table tests, and Ginkgo suite lives in this repo under examples/kubernetes-operator-reconcile-ginkgo-gomega/. Use run-tests.sh there if you want hard wall-clock limits: it runs go test -c under timeout (default 120s for compile) and then executes the test binary under a second timeout (default 30s), so a stuck toolchain or run cannot hang your terminal. Override with COMPILE_TIMEOUT / RUN_TIMEOUT if the first compile needs more time.
Step 1: Choose table tests or Ginkgo specs
Stdlib table-driven tests
Use testing.T and a slice of cases when you have many permutations of the same shape—inputs are structs, assertions are uniform, and you want go test -run granularity without Ginkgo’s DSL.
Ginkgo structure
Describe— the subject under test (FooReconciler.Reconcile).Context— a precondition (“CR exists but Deployment missing”, “finalizer already set”).It— the expected behavior for that precondition.
Ginkgo v2 DescribeTable / Entry is the hybrid: table-like rows with Ginkgo reporting—ideal for “same reconcile, different spec.replicas” without losing readable output.
Practical rule
One primary style per package: either mostly tables or mostly Describe/Context/It. Hybrid is fine across packages (internal/render tables, controllers Ginkgo)—not inside the same file with competing entrypoints.
Step 2: Split pure builders from full Reconcile tests
Pure builders
Extract functions such as desiredDeployment(cr *myv1.MyApp) *appsv1.Deployment (or a small struct of objects). Unit test them with normal Go and cmp.Diff or field-by-field Expect(...).To(Equal(...)) without any Kubernetes client:
- Fast, no scheme registration for unrelated types.
- Documents the desired state contract independent of orchestration bugs.
Cost: you must keep builders in sync with what Reconcile actually applies—review diffs in PRs that touch both.
Full Reconcile with client.Client fake
Use fake.NewClientBuilder().WithScheme(scheme).WithObjects(initial...).Build() from sigs.k8s.io/controller-runtime/pkg/client/fake, construct your reconciler with that client and a typed logger, then call Reconcile(ctx, req).
This exercises ordering, multiple Get/Create/Patch calls, and error branches that pure builders never see.
If your reconciler updates status, register the status subresource on the fake client when your controller-runtime version supports it:
fakeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(initialObjects...).
WithStatusSubresource(&myv1.MyApp{}).
Build()That still does not make the fake client a real apiserver, but it catches a common class of unit-test false positives around status updates.
Trade-off summary
| Layer | What it catches | Maintenance |
|---|---|---|
Pure desired* helpers |
Labels, probes, volumes, defaults | Duplication risk vs templates |
Reconcile + fake |
Missing child, wrong namespace, error handling | Scheme bumps, fake semantics |
| envtest / kind | CRD, status subresource, GC, webhooks | Slower, more setup |
Step 3: Add the minimal Ginkgo suite
For a package that uses Ginkgo, keep the suite bootstrap boring and short:
package controllers_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestReconcile(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Reconcile Suite")
}Use package controllers_test when you can test through exported behavior. Use package controllers only when the tests must reach unexported helpers; do that deliberately because it couples tests more tightly to implementation details.
A validated fake-client spec looked like this simplified pattern:
var _ = Describe("DemoReconciler", func() {
var (
ctx context.Context
scheme *runtime.Scheme
fakeClient client.Client
reconciler *DemoReconciler
)
BeforeEach(func() {
ctx = context.Background()
scheme = runtime.NewScheme()
Expect(corev1.AddToScheme(scheme)).To(Succeed())
Expect(appsv1.AddToScheme(scheme)).To(Succeed())
})
JustBeforeEach(func() {
reconciler = &DemoReconciler{client: fakeClient}
})
Context("when the source object exists", func() {
BeforeEach(func() {
cm := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: "demo", Namespace: "default"},
}
fakeClient = fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(cm).
Build()
})
It("creates the Deployment and exits quietly", func() {
req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "demo", Namespace: "default"}}
result, err := reconciler.Reconcile(ctx, req)
Expect(err).NotTo(HaveOccurred())
Expect(result).To(Equal(ctrl.Result{}))
var dep appsv1.Deployment
Expect(fakeClient.Get(ctx, req.NamespacedName, &dep)).To(Succeed())
Expect(dep.Spec.Replicas).NotTo(BeNil())
Expect(*dep.Spec.Replicas).To(Equal(int32(1)))
Expect(dep.Labels).To(HaveKeyWithValue("app.kubernetes.io/name", "demo"))
})
})
})The temporary suite passed with go test ./...:
ok example.com/ginkgo-reconcile-test 0.306sStep 4: Assert Result, errors, and client state
Results and errors
Separate expectations clearly:
- Terminal success:
Expect(res).To(Equal(ctrl.Result{}))andExpect(err).NotTo(HaveOccurred())when you intend a quiet exit. - Requeue: compare
res.RequeueAfterwithBeNumerically("~", expected, tolerance)if time is variable; or assertres.Requeue/RequeueAfterexplicitly. - Error path:
Expect(err).To(MatchError(...))orExpect(apierrors.IsNotFound(err)).To(BeTrue())when you assert typed API machinery errors—avoid only checkingerr != nil.
Post-conditions on the fake client
After Reconcile, Get the objects you expect to exist:
var dep appsv1.Deployment
Expect(fakeClient.Get(ctx, types.NamespacedName{Name: "x", Namespace: "ns"}, &dep)).To(Succeed())
Expect(dep.Spec.Replicas).NotTo(BeNil())
Expect(*dep.Spec.Replicas).To(Equal(int32(3)))Assert ownerReferences, labels, and annotations your product relies on. Remember: resourceVersion and generation behavior on the fake client are not identical to a live apiserver—treat RV-sensitive logic as an envtest concern.
Eventually and Consistently
For pure fake-client tests, prefer direct assertions after Reconcile; the call is synchronous. Reach for Eventually when you start a goroutine, use a real manager, or run envtest/kind where controllers act asynchronously.
Use Gomega's function form so failures show the real returned error:
Eventually(func(g Gomega) {
var dep appsv1.Deployment
g.Expect(k8sClient.Get(ctx, key, &dep)).To(Succeed())
g.Expect(dep.Status.ReadyReplicas).To(Equal(int32(1)))
}).WithTimeout(10 * time.Second).WithPolling(250 * time.Millisecond).Should(Succeed())Use Consistently sparingly. It is useful for proving a reconciler does not recreate a deleted child while a pause annotation is set, but it can make test suites slow if every assertion waits a full duration.
Step 5: Know when fake-client tests stop
| Stay with fake + Ginkgo | Prefer envtest | Reach for kind |
|---|---|---|
Branching on spec fields, error returns |
CRD validation, /status subresource semantics |
CNI, multi-node, cloud IAM, real admission chain |
Stop relying on the fake client when tests need Established CRDs, garbage collection timing, conversion webhooks, or SSA merge behavior that differs from the in-memory implementation.
The boundary is spelled out with setup examples in Testing with envtest, fake client, and kind.
Step 6: Use a minimal project layout
Kubebuilder defaults
-
controllers/suite_test.go— sharedenvtest.Environmentlifecycle (BeforeSuite/AfterSuite) when integration tests live next to controllers. Pure unit files can usepackage controllers_testwith no envtest if every test uses only the fake client—some teams still keep a thinsuite_test.goregistering Ginkgo and shared scheme helpers. -
controllers/foo_controller_test.go—Describe("FooReconciler", ...)per reconciler. -
internal/<domain>/..._test.go— table-driven tests for pure builders away from Ginkgo noise.
Avoid
- One file with every Kind’s tests—merge conflicts and slow IDE navigation.
- Starting envtest in
BeforeSuitewhen no test in the package needs it—CI time adds up.
Step 7: Split CI by test cost
Use separate jobs or make targets so a slow envtest setup does not hide fast unit feedback:
go test ./internal/...
go test ./controllers/... -run 'TestReconcile'If you install the Ginkgo CLI, you can get richer spec output, but it is optional because Ginkgo suites still run through go test:
go run github.com/onsi/ginkgo/v2/ginkgo -r ./controllersRecommended split:
| Job | Command | Purpose |
|---|---|---|
| Pure helpers | go test ./internal/... |
Desired-state builders and small domain logic |
| Reconcile unit | go test ./controllers/... |
Fake-client specs and error branches |
| API integration | go test ./controllers/... -tags=envtest |
CRD/status/webhook behavior with envtest assets |
| Cluster smoke | kind workflow | RBAC, image, manager, probes, and real admission chain |
Cache envtest assets and etcd archives per the testing article. Also pin Go to a version supported by your Kubernetes modules; the validation run pulled Go 1.26 automatically because the latest k8s.io/api required it.
FAQ
Ginkgo vs testify?
Both work; Ginkgo’s value is spec-shaped output and shared setup blocks. If the team already standardized on testify, use tables there—do not rewrite working suites for aesthetics.
Helm-based operators?
The pre-built Helm reconciler is not your Go Reconcile—this article targets Go operators.
See also
- Testing Kubernetes Operators with envtest, fake client, and kind
- Go Kubernetes Operator SDK tutorial
- controller-runtime tutorial
- Requeue, RequeueAfter, and error handling
- Avoid reconcile loop explosions
- Ginkgo documentation
- Ginkgo v2 migration guide
- Gomega matchers
- controller-runtime fake client
Bottom line: extract pure desired-state functions and test them cheaply; wrap Reconcile in Ginkgo contexts that mirror real operator stories; assert Result, errors, and cluster state via the fake client; and escalate to envtest or kind the moment you need real API semantics—not longer fake workarounds.

