Controller tests: attack the lifecycle boundaries
Build invariant-based scenarios for stale reads, conflicts, retries, deletion races, and stale leaders using the right test environment.
On this page
The question for today
What you’ll understand
- Separate safety assertions from eventual-progress assertions.
- Choose unit, API integration, and cluster tests for the guarantees they cover.
- Construct adversarial schedules around uncertain side effects and object incarnations.
A controller can pass a create-and-delete demonstration while still leaking external resources, overwriting concurrent fields, or exposing stale-leader effects. Correctness testing should target the boundaries where observations and accepted actions separate. Start with named safety invariants and explicit conditions for progress, then generate or script interleavings that challenge them. The goal is evidence about the real contract, not a test that merely repeats the implementation’s own branch structure.
Assert what must never happen and what must eventually happen
A safety invariant might require at most one external allocation per object UID, preservation of another actor’s label, or no removal of a finalizer before confirmed cleanup. A liveness assertion might require eventual readiness once the API, dependencies, and capacity stay healthy and intent stops changing. Those assumptions matter: no test should demand successful external deletion while the external service rejects every request forever. Conversely, a quick timeout is not proof that a controller can never make progress; it can be weak evidence from an unsuitable observation window.
Build a controllable external fake that records operation identities and permits a request to commit remotely while its response times out. Crash or restart the controller before its local success record, then reconcile again. Assert the external cardinality and returned identity rather than only the number of HTTP calls. Multiple calls can be correct when they implement an idempotent ensure. Also test delete-and-recreate with the same namespace/name but different UID so a stale action cannot silently target the new object’s resource.
A fake client cannot certify API-server semantics
Pure unit tests can check the desired action calculation and error classification quickly. An API-server integration environment is needed for server validation, resourceVersion conflicts, subresource behavior, and installed admission configuration. The Kubebuilder envtest documentation explains that its ordinary test control plane does not run built-in controllers. Therefore owner references do not automatically trigger garbage collection there. A test expecting a dependent to disappear needs a real cluster or the relevant controller, while an envtest assertion can verify the reference metadata your code creates.
Keep asynchronous tests condition-based: repeatedly observe the required state within a declared deadline instead of using one arbitrary sleep and assuming success. Capture useful logs, object versions, UID, and operation history on failure. Use deterministic scheduling controls where possible to reproduce a race, then add stress tests for broader schedules. Passing a hundred runs of one easy schedule is not equivalent to covering a single paused-leader overlap that the system’s safety property actually depends on.
Test the cross-product that your product actually needs
Create a table of failure locations: before finalizer persistence, after external commit but before response, after status conflict, during deletion, during watch expiration, and after leadership changes. Combine each with the relevant restart or duplicate notification rather than every imaginable combination. Include blocked permissions, invalid specs, and permanent external failures so the controller reports actionable state without hammering dependencies. Observe no-op steady state to catch status feedback loops that a single successful reconciliation would miss.
The optimization lab illustrates one narrow repeatability check. Reset, set η=1 and x₀=2, predict x₁=0, then Step once and again. The selected quadratic reaches its fixed point and remains there. Reset and set η=2.5, x₀=2 to predict first position −3 and an unstable trajectory as Step continues; out-of-range reporting is part of that model. It is not a controller test harness. The contrast helps explain why a test should exercise both a converging condition and a documented failing condition, then assert the actual system’s own invariant.
Work it through
A timeout retry needs an external-state assertion
A controller uses one stable external key derived from UID u. A test service creates the resource on the first request but loses the response. After controller restart, two more ensure calls use the same key and return the existing resource.
There are three external calls in total, but only one logical external resource should exist. Assert cardinality one and the same stable returned identity, not an incorrect requirement that only one call ever occur.
Add an unrelated label update between an API read and status write, forcing a conflict. The controller must reread and preserve that update while recovering; a fixed token substituted into stale contents would fail the preservation assertion.
Finally request deletion, confirm the external target is absent, and verify only the controller’s owned finalizer is removed. These three boundaries test uncertain success, concurrency, and cleanup rather than merely replaying one successful create branch.
The evidence concerns one resource despite three calls, preserved concurrent state, and cleanup justified by confirmation. It does not require a fictitious exactly-once network.
The living notebook
Explore the model
Interactive lab · optimization
Check both a fixed point and an unstable condition
Reset, set η=1 and x₀=2, predict x₁=0, then Step twice to see it remain zero. Reset and set η=2.5 and x₀=2; predict x₁=−3, then Step. This quadratic descent instrument is not a controller test harness or convergence proof for reconciliation.
- Position x0
- 2
- Objective x²/2
- 2
- Iteration behavior
- Stable step
Gradient descent on f(x) = x²/2: xₙ = (1 − η)ⁿx₀. Playback advances two discrete iterations per second. Values outside |x| ≤ 4 are explicitly reported. Parameter changes pause playback and recompute the model at the current elapsed time. Reset restores both parameters and returns time to zero. Playback starts paused. Adjust either parameter, then choose Play or Step.
Look forA meaningful test states the expected behavior under both successful and adversarial conditions.
Pause & reason
Check your understanding
Your turn
Try it yourself
A controlled retry test sends four ensure calls with one supported idempotency key and observes one external resource. Is four calls a correctness failure? Name a separate stale-leader test.
Take a moment to make a prediction or work it out on paper. Open a hint when you need one.
Give me a hint
Judge the resource invariant and API contract, then construct overlap rather than just counting elections.
Compare with the solution
Four calls can be correct when the receiving API’s ensure/idempotency semantics leave exactly one intended resource. Separately pause an old leader before an external effect, let a new leader install stronger authority, then resume the old request and verify the target’s documented fencing or safety behavior. Lease acquisition alone is not the assertion.
Keep the idea
Take it with you
- Safety, liveness, timing, and no-op behavior require different assertions.
- Choose a test environment that actually contains the mechanism being verified.
- Adversarial schedules should target identity, conflict, side-effect, deletion, and leadership boundaries.
Read further
Original sources and deeper explanations to follow at your own pace.
Optional progress, saved in this browser when available. Revisit any lesson at any time.
Lesson not marked complete.