Unit Testing Operator Reconcile Logic with Ginkgo and Gomega

Tech reviewed: Deepak Prasad
Unit Testing Operator Reconcile Logic with Ginkgo and Gomega

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:

text
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 enough

I validated the examples in a temporary Go module with current packages:

text
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.2

The 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:

go
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:

go
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:

go
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 ./...:

text
ok  	example.com/ginkgo-reconcile-test	0.306s

Step 4: Assert Result, errors, and client state

Results and errors

Separate expectations clearly:

  • Terminal success: Expect(res).To(Equal(ctrl.Result{})) and Expect(err).NotTo(HaveOccurred()) when you intend a quiet exit.
  • Requeue: compare res.RequeueAfter with BeNumerically("~", expected, tolerance) if time is variable; or assert res.Requeue / RequeueAfter explicitly.
  • Error path: Expect(err).To(MatchError(...)) or Expect(apierrors.IsNotFound(err)).To(BeTrue()) when you assert typed API machinery errors—avoid only checking err != nil.

Post-conditions on the fake client

After Reconcile, Get the objects you expect to exist:

go
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:

go
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 — shared envtest.Environment lifecycle (BeforeSuite / AfterSuite) when integration tests live next to controllers. Pure unit files can use package controllers_test with no envtest if every test uses only the fake client—some teams still keep a thin suite_test.go registering Ginkgo and shared scheme helpers.

  • controllers/foo_controller_test.goDescribe("FooReconciler", ...) per reconciler.

  • internal/<domain>/..._test.gotable-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 BeforeSuite when 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:

bash
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:

bash
go run github.com/onsi/ginkgo/v2/ginkgo -r ./controllers

Recommended 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


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.

Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with over a decade of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, …

  • Red Hat Certified System Administrator in Red Hat OpenStack
  • Certified Kubernetes Application Developer (CKAD)
  • Red Hat Certified Specialist in Ansible Automation
  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security