diff --git a/docs/content/en/docs/documentation/_index.md b/docs/content/en/docs/documentation/_index.md index 57e7fef122..c0805dfa2f 100644 --- a/docs/content/en/docs/documentation/_index.md +++ b/docs/content/en/docs/documentation/_index.md @@ -16,6 +16,10 @@ This section contains detailed documentation for all Java Operator SDK features - **[Error Handling & Retries](error-handling-retries/)** - Managing failures gracefully - **[Rate Limiting](rate-limiting/)** - Controlling reconciliation frequency per resource +## Testing + +- **[Testing](testing/)** - Unit tests, integration tests, and E2E testing strategies + ## Advanced Features - **[Eventing](eventing/)** - Understanding the event-driven model diff --git a/docs/content/en/docs/documentation/testing.md b/docs/content/en/docs/documentation/testing.md new file mode 100644 index 0000000000..d65a945320 --- /dev/null +++ b/docs/content/en/docs/documentation/testing.md @@ -0,0 +1,370 @@ +--- +title: Testing +weight: 56 +--- + +Testing is a critical part of building reliable operators. JOSDK supports multiple testing +strategies, from fast unit tests that mock the Kubernetes API, to full integration tests that run +your operator against a real cluster. + +## Unit Testing Reconcilers + +The fastest way to test reconciler logic is to unit test the `reconcile` method directly. You can +construct a mock or stub `Context` and call your reconciler without starting an operator or +connecting to a cluster. + +```java +class MyReconcilerTest { + + @Test + void shouldSetStatusOnReconcile() { + var client = mock(KubernetesClient.class); + var context = mock(Context.class); + when(context.getClient()).thenReturn(client); + + var resource = new MyCustomResource(); + resource.setMetadata(new ObjectMetaBuilder().withName("test").build()); + resource.setSpec(new MySpec()); + + var reconciler = new MyReconciler(); + var result = reconciler.reconcile(resource, context); + + assertThat(resource.getStatus().getState()).isEqualTo("Ready"); + assertThat(result.isPatchStatus()).isTrue(); + } +} +``` + +This approach is useful for testing pure business logic in the reconciler (e.g. computing desired +state, setting status fields, deciding whether to patch or reschedule). It runs in milliseconds +and needs no cluster. + +### Mocking Secondary Resources + +If your reconciler reads secondary resources from the context, you can stub +`getSecondaryResource`: + +```java +var deployment = new DeploymentBuilder() + .withNewMetadata().withName("my-deploy").endMetadata() + .withNewStatus().withReadyReplicas(3).endStatus() + .build(); + +when(context.getSecondaryResource(Deployment.class)).thenReturn(Optional.of(deployment)); +``` + +## Integration Testing with `LocallyRunOperatorExtension` + +For integration tests, JOSDK provides a JUnit 5 extension that starts your operator locally and +connects it to a real Kubernetes cluster (e.g. a local Kind or Minikube cluster). It automatically: + +- Creates an isolated test namespace +- Applies CRDs from the project classpath +- Registers your reconcilers and starts the operator +- Cleans up everything after the test + +Add dependency to your project: + +```xml + + io.javaoperatorsdk + operator-framework-junit + ${josdk.version} + test + +``` + +```java +class MyOperatorIT { + + @RegisterExtension + LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new MyReconciler()) + .build(); + + @Test + void shouldCreateDeploymentForCustomResource() { + var resource = new MyCustomResource(); + resource.setMetadata(new ObjectMetaBuilder() + .withName("test-resource") + .withNamespace(extension.getNamespace()) + .build()); + resource.setSpec(new MySpec()); + resource.getSpec().setReplicas(3); + + extension.create(resource); + + await().atMost(Duration.ofMinutes(1)).untilAsserted(() -> { + var updated = extension.get(MyCustomResource.class, "test-resource"); + assertThat(updated.getStatus()).isNotNull(); + assertThat(updated.getStatus().getReadyReplicas()).isEqualTo(3); + }); + } +} +``` + +See the [Integration Test Index](../testindex/_index.md) for a comprehensive list of +integration test samples covering various use cases. + +### Builder Configuration + +The builder offers several configuration options: + +```java +LocallyRunOperatorExtension.builder() + .withReconciler(new MyReconciler()) + // Override controller configuration + .withReconciler(new MyReconciler(), config -> config + .settingNamespace("specific-namespace") + .withRetry(new GenericRetry().withLinearRetry())) + // Pre-deploy infrastructure resources before operator starts + .withInfrastructure(configMap, secret) + // Register CRDs for resources not managed by a reconciler + .withAdditionalCustomResourceDefinition(OtherResource.class) + // Provide CRD files from custom paths + .withAdditionalCRD("path/to/my-crd.yaml") + // Run initialization logic after namespace is created but before operator starts + .withBeforeStartHook(ext -> { + ext.create(somePrerequisiteResource()); + }) + // Use a specific Kubernetes client + .withKubernetesClient(myClient) + // Reuse the same namespace for all tests in a class + .oneNamespacePerClass(true) + // Keep namespace around on test failure for debugging + .preserveNamespaceOnError(true) + .build(); +``` + +### Accessing the Reconciler + +If your test needs to inspect the reconciler's internal state (e.g. counters, caches), you can +retrieve it from the extension: + +```java +@RegisterExtension +LocallyRunOperatorExtension extension = + LocallyRunOperatorExtension.builder() + .withReconciler(new MyReconciler()) + .build(); + +@Test +void shouldReconcileExactlyOnce() { + extension.create(testResource()); + + await().untilAsserted(() -> { + var reconciler = extension.getReconcilerOfType(MyReconciler.class); + assertThat(reconciler.getReconcileCount()).isEqualTo(1); + }); +} +``` + +## Testing with a Cluster-Deployed Operator + +For end-to-end tests where the operator runs as a container in the cluster (e.g. to test the +Docker image, RBAC, or resource limits), use `ClusterDeployedOperatorExtension`: + +```java +class MyOperatorE2E { + + @RegisterExtension + ClusterDeployedOperatorExtension extension = createExtension(); + + private ClusterDeployedOperatorExtension createExtension() { + try (var operatorManifest = Files.newInputStream(Path.of("k8s/operator.yaml"))) { + return ClusterDeployedOperatorExtension.builder() + .withOperatorDeployment(client.load(operatorManifest).items()) + .withDeploymentTimeout(Duration.ofMinutes(2)) + .build(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + @Test + void operatorShouldReconcile() { + var resource = new MyCustomResource(); + resource.setMetadata(new ObjectMetaBuilder() + .withName("test") + .withNamespace(extension.getNamespace()) + .build()); + + extension.create(resource); + + await().atMost(Duration.ofMinutes(3)).untilAsserted(() -> { + var cr = extension.get(MyCustomResource.class, "test"); + assertThat(cr.getStatus()).isNotNull(); + }); + } +} +``` + +This extension: + +- Deploys the operator YAML manifests (Deployment, ServiceAccount, RBAC, etc.) into the test + namespace +- Applies CRDs from `./target/classes/META-INF/fabric8/` +- Adjusts `ClusterRoleBinding` subjects to point to the test namespace +- Waits for the operator Deployment to become ready +- Cleans up after the test + +See tests in [sample-operators](https://github.com/operator-framework/java-operator-sdk/blob/main/sample-operators) +for usage. + +### Choosing Between Local and Cluster-Deployed + +| Aspect | `LocallyRunOperatorExtension` | `ClusterDeployedOperatorExtension` | +|----------------------------|--------------------------------|------------------------------------| +| Operator runs | In the test JVM | As a Pod in the cluster | +| Startup time | Fast | Slower (image pull, pod start) | +| Debugging | Attach debugger directly | Requires remote debugging or logs | +| Tests | RBAC not exercised | Full RBAC and resource limits | +| Typical use | Development, CI integration | Pre-release E2E validation | + +## Using Fabric8 Mock Server for Fast Integration Tests + +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 +CRUD operations. This is useful for testing reconciler logic that interacts with the Kubernetes +API without needing a real cluster. + +Add the dependency: + +```xml + + io.fabric8 + kubernetes-server-mock + ${fabric8-client.version} + test + +``` + +Use `@EnableKubernetesMockClient` to inject a mock client: + +```java +@EnableKubernetesMockClient(crud = true) +class MyReconcilerMockTest { + + KubernetesClient client; + + @Test + void shouldCreateSecondaryResources() { + // Pre-create resources in the mock server + client.resource(testConfigMap()).create(); + + var context = mock(Context.class); + when(context.getClient()).thenReturn(client); + + var resource = testCustomResource(); + var reconciler = new MyReconciler(); + reconciler.reconcile(resource, context); + + // Verify that the reconciler created the expected Deployment + var deployment = client.apps().deployments() + .inNamespace("test-ns") + .withName("expected-deploy") + .get(); + assertThat(deployment).isNotNull(); + assertThat(deployment.getSpec().getReplicas()).isEqualTo(3); + } +} +``` + +The `crud = true` flag enables automatic CRUD behavior: resources you create are stored and can be +retrieved, updated, and deleted, simulating a real API server. Without it, you would need to set up +explicit request/response expectations. + +## Using Fabric8 `@KubeAPITest` for Realistic API Testing + +For tests that need a more realistic Kubernetes API (including watches, status subresources, and +server-side apply), the Fabric8 client provides the +[`@KubeAPITest`](https://github.com/fabric8io/kubernetes-client/blob/main/doc/kube-api-test.md) +annotation. It starts a lightweight Kubernetes API server that behaves more closely to a real cluster than +the mock server. The API Server starts quickly, so it is suitable to run it from unit tests, even separately +for each test case if needed. In addition to that comes handy if your CI does not support running tools like +Kind and/or Minikube. + +```xml + + io.fabric8 + kubernetes-junit-jupiter + ${fabric8-client.version} + test + +``` + +```java +@KubeAPITest +class MyReconcilerKubeAPITest { + + KubernetesClient client; + + @Test + void shouldHandleStatusUpdates() { + // The API server supports watches, SSA, and status subresources + client.resource(testCRD()).create(); + client.resource(testCustomResource()).create(); + + var reconciler = new MyReconciler(); + var context = mock(Context.class); + when(context.getClient()).thenReturn(client); + + var resource = client.resources(MyCustomResource.class) + .withName("test").get(); + reconciler.reconcile(resource, context); + + var updated = client.resources(MyCustomResource.class) + .withName("test").get(); + assertThat(updated.getStatus().getState()).isEqualTo("Ready"); + } +} +``` + +## Multi-Reconciliation Testing Pattern + +Operator reconciliation is often a multi-step process. A realistic test exercises your reconciler +through multiple cycles, verifying the state transitions: + +```java +@Test +void shouldProgressThroughLifecycle() { + extension.create(testResource()); + + // Step 1: reconciler creates Deployment + await().untilAsserted(() -> { + var deploy = extension.get(Deployment.class, "my-deploy"); + assertThat(deploy).isNotNull(); + }); + + // Step 2: simulate Deployment becoming ready + var deploy = extension.get(Deployment.class, "my-deploy"); + deploy.getStatus().setReadyReplicas( + deploy.getSpec().getReplicas()); + extension.getKubernetesClient().resource(deploy) + .inNamespace(extension.getNamespace()).patchStatus(); + + // Step 3: verify that the custom resource status reflects readiness + await().untilAsserted(() -> { + var cr = extension.get(MyCustomResource.class, "test"); + assertThat(cr.getStatus().getState()).isEqualTo("Ready"); + }); +} +``` + +## Configuration via System Properties + +The test extensions can be configured via system properties (useful in CI): + +| System Property | Default | Description | +|--------------------------------------|---------|----------------------------------------------------| +| `josdk.it.preserveNamespaceOnError` | `false` | Keep namespace when tests fail, for debugging | +| `josdk.it.skipNamespaceDeletion` | `false` | Skip namespace cleanup after tests | +| `josdk.it.waitForNamespaceDeletion` | `true` | Wait for namespace to be fully deleted | +| `josdk.it.oneNamespacePerClass` | `false` | Reuse the same namespace for all tests in a class | +| `josdk.it.namespaceDeleteTimeout` | `90` | Namespace deletion timeout in seconds | +| `testsuite.deleteCRDs` | `true` | Delete applied CRDs after tests | + +Example: + +```bash +mvn test -Djosdk.it.preserveNamespaceOnError=true -Djosdk.it.oneNamespacePerClass=true +```