Skip to content

Commit 1e93ba0

Browse files
authored
docs: testing page (#3313)
1 parent a22b9c8 commit 1e93ba0

2 files changed

Lines changed: 374 additions & 0 deletions

File tree

docs/content/en/docs/documentation/_index.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ This section contains detailed documentation for all Java Operator SDK features
1616
- **[Error Handling & Retries](error-handling-retries/)** - Managing failures gracefully
1717
- **[Rate Limiting](rate-limiting/)** - Controlling reconciliation frequency per resource
1818

19+
## Testing
20+
21+
- **[Testing](testing/)** - Unit tests, integration tests, and E2E testing strategies
22+
1923
## Advanced Features
2024

2125
- **[Eventing](eventing/)** - Understanding the event-driven model
Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
---
2+
title: Testing
3+
weight: 56
4+
---
5+
6+
Testing is a critical part of building reliable operators. JOSDK supports multiple testing
7+
strategies, from fast unit tests that mock the Kubernetes API, to full integration tests that run
8+
your operator against a real cluster.
9+
10+
## Unit Testing Reconcilers
11+
12+
The fastest way to test reconciler logic is to unit test the `reconcile` method directly. You can
13+
construct a mock or stub `Context` and call your reconciler without starting an operator or
14+
connecting to a cluster.
15+
16+
```java
17+
class MyReconcilerTest {
18+
19+
@Test
20+
void shouldSetStatusOnReconcile() {
21+
var client = mock(KubernetesClient.class);
22+
var context = mock(Context.class);
23+
when(context.getClient()).thenReturn(client);
24+
25+
var resource = new MyCustomResource();
26+
resource.setMetadata(new ObjectMetaBuilder().withName("test").build());
27+
resource.setSpec(new MySpec());
28+
29+
var reconciler = new MyReconciler();
30+
var result = reconciler.reconcile(resource, context);
31+
32+
assertThat(resource.getStatus().getState()).isEqualTo("Ready");
33+
assertThat(result.isPatchStatus()).isTrue();
34+
}
35+
}
36+
```
37+
38+
This approach is useful for testing pure business logic in the reconciler (e.g. computing desired
39+
state, setting status fields, deciding whether to patch or reschedule). It runs in milliseconds
40+
and needs no cluster.
41+
42+
### Mocking Secondary Resources
43+
44+
If your reconciler reads secondary resources from the context, you can stub
45+
`getSecondaryResource`:
46+
47+
```java
48+
var deployment = new DeploymentBuilder()
49+
.withNewMetadata().withName("my-deploy").endMetadata()
50+
.withNewStatus().withReadyReplicas(3).endStatus()
51+
.build();
52+
53+
when(context.getSecondaryResource(Deployment.class)).thenReturn(Optional.of(deployment));
54+
```
55+
56+
## Integration Testing with `LocallyRunOperatorExtension`
57+
58+
For integration tests, JOSDK provides a JUnit 5 extension that starts your operator locally and
59+
connects it to a real Kubernetes cluster (e.g. a local Kind or Minikube cluster). It automatically:
60+
61+
- Creates an isolated test namespace
62+
- Applies CRDs from the project classpath
63+
- Registers your reconcilers and starts the operator
64+
- Cleans up everything after the test
65+
66+
Add dependency to your project:
67+
68+
```xml
69+
<dependency>
70+
<groupId>io.javaoperatorsdk</groupId>
71+
<artifactId>operator-framework-junit</artifactId>
72+
<version>${josdk.version}</version>
73+
<scope>test</scope>
74+
</dependency>
75+
```
76+
77+
```java
78+
class MyOperatorIT {
79+
80+
@RegisterExtension
81+
LocallyRunOperatorExtension extension =
82+
LocallyRunOperatorExtension.builder()
83+
.withReconciler(new MyReconciler())
84+
.build();
85+
86+
@Test
87+
void shouldCreateDeploymentForCustomResource() {
88+
var resource = new MyCustomResource();
89+
resource.setMetadata(new ObjectMetaBuilder()
90+
.withName("test-resource")
91+
.withNamespace(extension.getNamespace())
92+
.build());
93+
resource.setSpec(new MySpec());
94+
resource.getSpec().setReplicas(3);
95+
96+
extension.create(resource);
97+
98+
await().atMost(Duration.ofMinutes(1)).untilAsserted(() -> {
99+
var updated = extension.get(MyCustomResource.class, "test-resource");
100+
assertThat(updated.getStatus()).isNotNull();
101+
assertThat(updated.getStatus().getReadyReplicas()).isEqualTo(3);
102+
});
103+
}
104+
}
105+
```
106+
107+
See the [Integration Test Index](../testindex/_index.md) for a comprehensive list of
108+
integration test samples covering various use cases.
109+
110+
### Builder Configuration
111+
112+
The builder offers several configuration options:
113+
114+
```java
115+
LocallyRunOperatorExtension.builder()
116+
.withReconciler(new MyReconciler())
117+
// Override controller configuration
118+
.withReconciler(new MyReconciler(), config -> config
119+
.settingNamespace("specific-namespace")
120+
.withRetry(new GenericRetry().withLinearRetry()))
121+
// Pre-deploy infrastructure resources before operator starts
122+
.withInfrastructure(configMap, secret)
123+
// Register CRDs for resources not managed by a reconciler
124+
.withAdditionalCustomResourceDefinition(OtherResource.class)
125+
// Provide CRD files from custom paths
126+
.withAdditionalCRD("path/to/my-crd.yaml")
127+
// Run initialization logic after namespace is created but before operator starts
128+
.withBeforeStartHook(ext -> {
129+
ext.create(somePrerequisiteResource());
130+
})
131+
// Use a specific Kubernetes client
132+
.withKubernetesClient(myClient)
133+
// Reuse the same namespace for all tests in a class
134+
.oneNamespacePerClass(true)
135+
// Keep namespace around on test failure for debugging
136+
.preserveNamespaceOnError(true)
137+
.build();
138+
```
139+
140+
### Accessing the Reconciler
141+
142+
If your test needs to inspect the reconciler's internal state (e.g. counters, caches), you can
143+
retrieve it from the extension:
144+
145+
```java
146+
@RegisterExtension
147+
LocallyRunOperatorExtension extension =
148+
LocallyRunOperatorExtension.builder()
149+
.withReconciler(new MyReconciler())
150+
.build();
151+
152+
@Test
153+
void shouldReconcileExactlyOnce() {
154+
extension.create(testResource());
155+
156+
await().untilAsserted(() -> {
157+
var reconciler = extension.getReconcilerOfType(MyReconciler.class);
158+
assertThat(reconciler.getReconcileCount()).isEqualTo(1);
159+
});
160+
}
161+
```
162+
163+
## Testing with a Cluster-Deployed Operator
164+
165+
For end-to-end tests where the operator runs as a container in the cluster (e.g. to test the
166+
Docker image, RBAC, or resource limits), use `ClusterDeployedOperatorExtension`:
167+
168+
```java
169+
class MyOperatorE2E {
170+
171+
@RegisterExtension
172+
ClusterDeployedOperatorExtension extension = createExtension();
173+
174+
private ClusterDeployedOperatorExtension createExtension() {
175+
try (var operatorManifest = Files.newInputStream(Path.of("k8s/operator.yaml"))) {
176+
return ClusterDeployedOperatorExtension.builder()
177+
.withOperatorDeployment(client.load(operatorManifest).items())
178+
.withDeploymentTimeout(Duration.ofMinutes(2))
179+
.build();
180+
} catch (IOException e) {
181+
throw new UncheckedIOException(e);
182+
}
183+
}
184+
@Test
185+
void operatorShouldReconcile() {
186+
var resource = new MyCustomResource();
187+
resource.setMetadata(new ObjectMetaBuilder()
188+
.withName("test")
189+
.withNamespace(extension.getNamespace())
190+
.build());
191+
192+
extension.create(resource);
193+
194+
await().atMost(Duration.ofMinutes(3)).untilAsserted(() -> {
195+
var cr = extension.get(MyCustomResource.class, "test");
196+
assertThat(cr.getStatus()).isNotNull();
197+
});
198+
}
199+
}
200+
```
201+
202+
This extension:
203+
204+
- Deploys the operator YAML manifests (Deployment, ServiceAccount, RBAC, etc.) into the test
205+
namespace
206+
- Applies CRDs from `./target/classes/META-INF/fabric8/`
207+
- Adjusts `ClusterRoleBinding` subjects to point to the test namespace
208+
- Waits for the operator Deployment to become ready
209+
- Cleans up after the test
210+
211+
See tests in [sample-operators](https://github.com/operator-framework/java-operator-sdk/blob/main/sample-operators)
212+
for usage.
213+
214+
### Choosing Between Local and Cluster-Deployed
215+
216+
| Aspect | `LocallyRunOperatorExtension` | `ClusterDeployedOperatorExtension` |
217+
|----------------------------|--------------------------------|------------------------------------|
218+
| Operator runs | In the test JVM | As a Pod in the cluster |
219+
| Startup time | Fast | Slower (image pull, pod start) |
220+
| Debugging | Attach debugger directly | Requires remote debugging or logs |
221+
| Tests | RBAC not exercised | Full RBAC and resource limits |
222+
| Typical use | Development, CI integration | Pre-release E2E validation |
223+
224+
## Using Fabric8 Mock Server for Fast Integration Tests
225+
226+
The [Fabric8 Kubernetes Mock Server](https://github.com/fabric8io/kubernetes-client/blob/main/doc/KubernetesClientWithMockWebServer.md) provides an in-memory Kubernetes API server that supports
227+
CRUD operations. This is useful for testing reconciler logic that interacts with the Kubernetes
228+
API without needing a real cluster.
229+
230+
Add the dependency:
231+
232+
```xml
233+
<dependency>
234+
<groupId>io.fabric8</groupId>
235+
<artifactId>kubernetes-server-mock</artifactId>
236+
<version>${fabric8-client.version}</version>
237+
<scope>test</scope>
238+
</dependency>
239+
```
240+
241+
Use `@EnableKubernetesMockClient` to inject a mock client:
242+
243+
```java
244+
@EnableKubernetesMockClient(crud = true)
245+
class MyReconcilerMockTest {
246+
247+
KubernetesClient client;
248+
249+
@Test
250+
void shouldCreateSecondaryResources() {
251+
// Pre-create resources in the mock server
252+
client.resource(testConfigMap()).create();
253+
254+
var context = mock(Context.class);
255+
when(context.getClient()).thenReturn(client);
256+
257+
var resource = testCustomResource();
258+
var reconciler = new MyReconciler();
259+
reconciler.reconcile(resource, context);
260+
261+
// Verify that the reconciler created the expected Deployment
262+
var deployment = client.apps().deployments()
263+
.inNamespace("test-ns")
264+
.withName("expected-deploy")
265+
.get();
266+
assertThat(deployment).isNotNull();
267+
assertThat(deployment.getSpec().getReplicas()).isEqualTo(3);
268+
}
269+
}
270+
```
271+
272+
The `crud = true` flag enables automatic CRUD behavior: resources you create are stored and can be
273+
retrieved, updated, and deleted, simulating a real API server. Without it, you would need to set up
274+
explicit request/response expectations.
275+
276+
## Using Fabric8 `@KubeAPITest` for Realistic API Testing
277+
278+
For tests that need a more realistic Kubernetes API (including watches, status subresources, and
279+
server-side apply), the Fabric8 client provides the
280+
[`@KubeAPITest`](https://github.com/fabric8io/kubernetes-client/blob/main/doc/kube-api-test.md)
281+
annotation. It starts a lightweight Kubernetes API server that behaves more closely to a real cluster than
282+
the mock server. The API Server starts quickly, so it is suitable to run it from unit tests, even separately
283+
for each test case if needed. In addition to that comes handy if your CI does not support running tools like
284+
Kind and/or Minikube.
285+
286+
```xml
287+
<dependency>
288+
<groupId>io.fabric8</groupId>
289+
<artifactId>kubernetes-junit-jupiter</artifactId>
290+
<version>${fabric8-client.version}</version>
291+
<scope>test</scope>
292+
</dependency>
293+
```
294+
295+
```java
296+
@KubeAPITest
297+
class MyReconcilerKubeAPITest {
298+
299+
KubernetesClient client;
300+
301+
@Test
302+
void shouldHandleStatusUpdates() {
303+
// The API server supports watches, SSA, and status subresources
304+
client.resource(testCRD()).create();
305+
client.resource(testCustomResource()).create();
306+
307+
var reconciler = new MyReconciler();
308+
var context = mock(Context.class);
309+
when(context.getClient()).thenReturn(client);
310+
311+
var resource = client.resources(MyCustomResource.class)
312+
.withName("test").get();
313+
reconciler.reconcile(resource, context);
314+
315+
var updated = client.resources(MyCustomResource.class)
316+
.withName("test").get();
317+
assertThat(updated.getStatus().getState()).isEqualTo("Ready");
318+
}
319+
}
320+
```
321+
322+
## Multi-Reconciliation Testing Pattern
323+
324+
Operator reconciliation is often a multi-step process. A realistic test exercises your reconciler
325+
through multiple cycles, verifying the state transitions:
326+
327+
```java
328+
@Test
329+
void shouldProgressThroughLifecycle() {
330+
extension.create(testResource());
331+
332+
// Step 1: reconciler creates Deployment
333+
await().untilAsserted(() -> {
334+
var deploy = extension.get(Deployment.class, "my-deploy");
335+
assertThat(deploy).isNotNull();
336+
});
337+
338+
// Step 2: simulate Deployment becoming ready
339+
var deploy = extension.get(Deployment.class, "my-deploy");
340+
deploy.getStatus().setReadyReplicas(
341+
deploy.getSpec().getReplicas());
342+
extension.getKubernetesClient().resource(deploy)
343+
.inNamespace(extension.getNamespace()).patchStatus();
344+
345+
// Step 3: verify that the custom resource status reflects readiness
346+
await().untilAsserted(() -> {
347+
var cr = extension.get(MyCustomResource.class, "test");
348+
assertThat(cr.getStatus().getState()).isEqualTo("Ready");
349+
});
350+
}
351+
```
352+
353+
## Configuration via System Properties
354+
355+
The test extensions can be configured via system properties (useful in CI):
356+
357+
| System Property | Default | Description |
358+
|--------------------------------------|---------|----------------------------------------------------|
359+
| `josdk.it.preserveNamespaceOnError` | `false` | Keep namespace when tests fail, for debugging |
360+
| `josdk.it.skipNamespaceDeletion` | `false` | Skip namespace cleanup after tests |
361+
| `josdk.it.waitForNamespaceDeletion` | `true` | Wait for namespace to be fully deleted |
362+
| `josdk.it.oneNamespacePerClass` | `false` | Reuse the same namespace for all tests in a class |
363+
| `josdk.it.namespaceDeleteTimeout` | `90` | Namespace deletion timeout in seconds |
364+
| `testsuite.deleteCRDs` | `true` | Delete applied CRDs after tests |
365+
366+
Example:
367+
368+
```bash
369+
mvn test -Djosdk.it.preserveNamespaceOnError=true -Djosdk.it.oneNamespacePerClass=true
370+
```

0 commit comments

Comments
 (0)