A comprehensive JUnit 6 extension for Kubernetes testing that provides declarative test configuration, namespace management via annotations, comprehensive log collection, and automatic resource management.
- Automatic Resource Management - Configurable cleanup strategies and lifecycle management
- Declarative Namespaces -
@ClassNamespacefor class-level and@MethodNamespacefor per-test-method isolation - Multi-Context Testing - Test across different Kubernetes clusters simultaneously with context-specific namespaces and resources
- Namespace Protection - Existing namespaces are used as-is and never deleted during cleanup
- Log Collection - Automatic log collection with multiple strategies and exception handling
- Dependency Injection - Inject Kubernetes clients, resource managers, and resources with context-specific support
- YAML Resource Loading - Load and inject resources from YAML files with multi-context support
- YAML Storage - Automatically store deployed resources as YAML files for debugging and audit
- Visual Test Output - Enhanced test logging with visual separators
Add the JUnit 6 extension dependency to your project:
<dependency>
<groupId>io.skodjob.kubetest4j</groupId>
<artifactId>junit-extension</artifactId>
<version>{version}</version>
<scope>test</scope>
</dependency>@KubernetesTest
class MyKubernetesTest {
@ClassNamespace(name = "my-test")
static Namespace testNs;
@InjectKubeClient
KubeClient client;
@InjectResourceManager
KubeResourceManager resourceManager;
@Test
void testPodCreation() {
Pod pod = new PodBuilder()
.withNewMetadata()
.withName("test-pod")
.withNamespace("my-test")
.endMetadata()
.withNewSpec()
.addNewContainer()
.withName("test")
.withImage("quay.io/prometheus/busybox")
.withCommand("sleep", "300")
.endContainer()
.endSpec()
.build();
resourceManager.createResourceWithWait(pod);
// Pod is automatically cleaned up after test
assertNotNull(client.getClient().pods()
.inNamespace("my-test")
.withName("test-pod")
.get());
}
}Namespaces are declared using field-level annotations on the test class, not inside @KubernetesTest.
Class namespaces are created in beforeAll and deleted in afterAll. They are shared across all test methods in the class. Must be placed on static fields of type Namespace.
@KubernetesTest(cleanup = CleanupStrategy.AUTOMATIC)
class MultiNamespaceTest {
@ClassNamespace(name = "frontend",
labels = {"env=test", "tier=frontend"},
annotations = {"description=Frontend test namespace"})
static Namespace frontendNs;
@ClassNamespace(name = "backend")
static Namespace backendNs;
@ClassNamespace(name = "monitoring")
static Namespace monitoringNs;
@InjectResourceManager
KubeResourceManager resourceManager;
@Test
void testCrossNamespaceNetworking() {
Service backendService = new ServiceBuilder()
.withNewMetadata()
.withName("api-service")
.withNamespace("backend")
.endMetadata()
.withNewSpec()
.withSelector(Map.of("app", "backend"))
.addNewPort()
.withPort(8080)
.withTargetPort(new IntOrStringBuilder().withValue(8080).build())
.endPort()
.endSpec()
.build();
resourceManager.createResourceWithWait(backendService);
}
}Existing namespaces are automatically protected. If a namespace already exists on the cluster, it is used as-is and never deleted during cleanup. You can safely mix existing namespaces (like default) with test-created ones:
@KubernetesTest(cleanup = CleanupStrategy.AUTOMATIC)
class SafeNamespaceTest {
@ClassNamespace(name = "default")
static Namespace defaultNs; // Protected — never deleted
@ClassNamespace(name = "my-test-ns")
static Namespace testNs; // Created by test, deleted on cleanup
}Create isolated namespaces per test method, similar to JUnit's @TempDir. Each test method gets its own fresh namespace. Ideal for parallel test execution.
Field injection:
@KubernetesTest
class PerMethodNamespaceTest {
@MethodNamespace
Namespace testNs; // Unique namespace created for each test method
@MethodNamespace(prefix = "kafka", labels = {"app=kafka"})
Namespace kafkaNs; // Custom prefix and labels
@Test
void testOne() {
// testNs and kafkaNs are fresh namespaces, unique to this test method
}
@Test
void testTwo() {
// testNs and kafkaNs are different namespaces than in testOne()
}
}Parameter injection:
@Test
void testWithNamespace(@MethodNamespace Namespace ns) {
// ns is a fresh, unique namespace for this test invocation
assertNotNull(ns.getMetadata().getName());
}
@Test
void testWithCustomPrefix(
@MethodNamespace(prefix = "app", annotations = {"purpose=testing"}) Namespace ns
) {
assertTrue(ns.getMetadata().getName().startsWith("app-"));
}Namespace naming: Generated names follow the pattern <prefix>-<methodName>-<index>, truncated to 63 characters (DNS-1123). The index is a simple counter (0, 1, 2, ...) for each namespace within the same test method.
Coexistence: @ClassNamespace and @MethodNamespace serve different purposes and can be used together:
@ClassNamespace— class-level, same namespace for all tests@MethodNamespace— method-level, unique per test
@KubernetesTest
class MixedNamespaceTest {
@ClassNamespace(name = "shared")
static Namespace sharedNs; // Same namespace for all tests
@MethodNamespace
Namespace isolatedNs; // Different namespace per test method
@Test
void test() {
// sharedNs is the class-level "shared" namespace
// isolatedNs is a fresh namespace just for this test
}
}The framework uses exception handlers to capture failures from ANY test lifecycle phase:
@BeforeAllmethod failures@BeforeEachmethod failures- Test execution failures
@AfterEachmethod failures@AfterAllmethod failures
Log collection discovers namespaces automatically via the kubetest4j.skodjob.io/log-collection=enabled label, which is applied to all namespaces created by the framework.
@KubernetesTest(
collectLogs = true,
logCollectionStrategy = LogCollectionStrategy.ON_FAILURE,
logCollectionPath = "target/test-logs",
collectPreviousLogs = true,
collectNamespacedResources = {"pods", "services", "configmaps", "secrets"},
collectClusterWideResources = {"nodes"}
)
class LogCollectionTest {
@ClassNamespace(name = "log-test")
static Namespace logTestNs;
@Test
void testThatFails() {
// If this test fails, logs will be automatically collected
// from all labeled namespaces
throw new AssertionError("Demonstrating automatic log collection");
}
}ON_FAILURE- Collect logs only when tests fail (default)AFTER_EACH- Collect logs after each test method (success or failure)
For custom collection logic (e.g. collecting from dynamic namespaces, calling oc adm must-gather, or post-processing logs), use the @MustGather annotation with a MustGatherSupplier implementation:
@MustGather(config = MyMustGatherSupplier.class)
class MyTest {
@Test
void someTest() { ... }
}MustGatherSupplier.saveKubernetesState(ExtensionContext context) is called on any test failure, including lifecycle failures. See the log-collector README for a full implementation example.
Main annotation to enable Kubernetes test features. This annotation is @Inherited, so it propagates to subclasses (see Annotation Inheritance).
@KubernetesTest(
// Resource types (declarative alternative to setResourceTypes())
resourceTypes = {NamespaceType.class, DeploymentType.class},
// Resource management
cleanup = CleanupStrategy.AUTOMATIC, // Cleanup strategy
storeYaml = true, // Store resource YAMLs
yamlStorePath = "target/yamls", // YAML storage path
// Visual output
visualSeparatorChar = "#", // Separator character
visualSeparatorLength = 76, // Separator length
// ===== Log Collection =====
collectLogs = true,
logCollectionStrategy = LogCollectionStrategy.ON_FAILURE,
logCollectionPath = "target/test-logs",
collectPreviousLogs = true,
collectNamespacedResources = {"pods", "services"},
collectClusterWideResources = {"nodes"}
)Declare class-level namespaces on static fields:
@ClassNamespace(name = "my-ns")
static Namespace ns;
@ClassNamespace(name = "labeled-ns",
labels = {"env=test", "team=backend"},
annotations = {"description=Test namespace"})
static Namespace labeledNs;
// Multi-context support
@ClassNamespace(name = "stg-app", kubeContext = "staging")
static Namespace stagingNs;Declare per-test-method namespaces on instance fields or parameters:
@MethodNamespace
Namespace testNs;
@MethodNamespace(prefix = "kafka", labels = {"app=kafka"})
Namespace kafkaNs;
// Multi-context support
@MethodNamespace(kubeContext = "staging")
Namespace stagingTestNs;
// Parameter injection
@Test
void test(@MethodNamespace(prefix = "app") Namespace ns) { ... }Inject a configured Kubernetes client:
@InjectKubeClient
KubeClient client;
// Context-specific injection
@InjectKubeClient(kubeContext = "staging")
KubeClient stagingClient;
@Test
void testWithClient(
@InjectKubeClient KubeClient client,
@InjectKubeClient(kubeContext = "staging") KubeClient stagingClient
) {
// Both field and parameter injection work
}Inject a command-line kubectl client:
@InjectCmdKubeClient
KubeCmdClient<?> cmdClient;
// Context-specific injection
@InjectCmdKubeClient(kubeContext = "staging")
KubeCmdClient<?> stagingCmdClient;
@Test
void testWithCmdClient() {
cmdClient.exec("get", "pods", "-n", "my-namespace");
}Inject the resource manager for lifecycle management:
@InjectResourceManager
KubeResourceManager resourceManager;
// Context-specific injection
@InjectResourceManager(kubeContext = "staging")
KubeResourceManager stagingResourceManager;
@Test
void testResourceLifecycle() {
// Primary context
resourceManager.createResourceWithWait(myResource);
// Context-specific resource management - truly isolated instances
stagingResourceManager.createResourceWithWait(stagingResource);
}The framework uses per-context singleton instances, ensuring true isolation between different Kubernetes contexts:
// Each context gets its own dedicated KubeResourceManager instance
KubeResourceManager defaultMgr = KubeResourceManager.get(); // Default context
KubeResourceManager stagingMgr = KubeResourceManager.getForContext("staging"); // Staging context
KubeResourceManager prodMgr = KubeResourceManager.getForContext("production"); // Production context
// All instances can operate simultaneously without conflictsLoad and inject resources from YAML files or classpath manifests:
@InjectResource("deployment.yaml")
Deployment deployment;
@InjectResource(value = "service.yaml", waitForReady = true)
Service service;
@InjectResource(value = "manifest.yaml", type = Service.class, name = "frontend-service")
Service selectedService;
// Context-specific resource injection
@InjectResource(kubeContext = "staging", value = "staging-deployment.yaml")
Deployment stagingDeployment;
@Test
void testResourceInjection(
@InjectResource(kubeContext = "staging", value = "staging-config.yaml") ConfigMap stagingConfig
) {
assertNotNull(stagingConfig);
}value accepts either a classpath resource name such as deployment.yaml or a filesystem path.
For multi-document YAML manifests, kubetest4j applies all resources from the manifest and injects the one matching the field or parameter type, optionally narrowed by type and name.
Lifecycle: Resources are loaded from YAML and created on the cluster during field injection:
- Instance fields (
@InjectResourceon non-static fields) — created inbeforeEach, once per test method. Cleaned up automatically after each test. - Method parameters (
@InjectResourceon test method parameters) — created just before the test method runs. - With
@TestInstance(Lifecycle.PER_CLASS)— instance fields are injected inbeforeAll, so resources are created once for the entire class.
Use waitForReady = true (default) to wait for the resource to become ready, or waitForReady = false to create without waiting.
Control when resources are cleaned up:
AUTOMATIC- Automatically clean up resources using KubeResourceManager (default)- Resources are cleaned up both after each test method and after all tests complete
- Uses async deletion for better performance
- Covers failure scenarios as well
MANUAL- No automatic cleanup, manual resource management required
@KubernetesTest(
cleanup = CleanupStrategy.MANUAL // No automatic cleanup
)Test across multiple Kubernetes clusters simultaneously using kubeContext on namespace and injection annotations:
@KubernetesTest(
storeYaml = true,
yamlStorePath = "target/test-yamls"
)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class MultiContextTest {
// Default context namespaces
@ClassNamespace(name = "local-test",
labels = {"environment=local"})
static Namespace localTestNs;
// Staging context namespaces
@ClassNamespace(name = "stg-frontend", kubeContext = "staging",
labels = {"environment=staging", "tier=application"})
static Namespace stagingFrontendNs;
// Production context namespaces
@ClassNamespace(name = "prod-api", kubeContext = "production",
labels = {"environment=production"})
static Namespace productionApiNs;
// Default context injections
@InjectKubeClient
KubeClient defaultClient;
@InjectResourceManager
KubeResourceManager defaultResourceManager;
// Context-specific injections
@InjectKubeClient(kubeContext = "staging")
KubeClient stagingClient;
@InjectResourceManager(kubeContext = "staging")
KubeResourceManager stagingResourceManager;
@InjectKubeClient(kubeContext = "production")
KubeClient productionClient;
@Test
void testCrossContextOperations() {
// Default context
ConfigMap defaultConfig = new ConfigMapBuilder()
.withNewMetadata()
.withName("multi-context-config")
.withNamespace("local-test")
.endMetadata()
.addToData("environment", "local")
.build();
defaultResourceManager.createResourceWithoutWait(defaultConfig);
// Staging context
Pod stagingPod = new PodBuilder()
.withNewMetadata()
.withName("staging-test-pod")
.withNamespace("stg-frontend")
.endMetadata()
.withNewSpec()
.addNewContainer()
.withName("test-container")
.withImage("nginx:alpine")
.endContainer()
.endSpec()
.build();
stagingResourceManager.createResourceWithWait(stagingPod);
}
@Test
void testResourceInjectionWithContext(
@InjectResource(kubeContext = "staging", value = "deployment.yaml")
Deployment stagingDeployment
) {
assertNotNull(stagingDeployment);
}
}Configure contexts using environment variables:
# Default context
KUBE_URL=https://api.default:6443
KUBE_TOKEN=default-token
# or
KUBECONFIG=/path/to/default.kubeconfig
# Staging context
KUBE_URL_STAGING=https://api.staging:6443
KUBE_TOKEN_STAGING=staging-token
# or
KUBECONFIG_STAGING=/path/to/staging.kubeconfig
# Production context
KUBE_URL_PRODUCTION=https://api.prod:6443
KUBE_TOKEN_PRODUCTION=prod-tokenThe framework provides visual separators for enhanced readability:
############################################################################
TestClass io.example.MyKubernetesTest STARTED
Setting up Kubernetes test environment for class: MyKubernetesTest
############################################################################
Test io.example.MyKubernetesTest.testMethod STARTED
...
Test io.example.MyKubernetesTest.testMethod SUCCEEDED
############################################################################
TestClass io.example.MyKubernetesTest FINISHED
############################################################################
Customize separators:
@KubernetesTest(
visualSeparatorChar = "=",
visualSeparatorLength = 60
)Automatically store deployed resources as YAML files for debugging and audit purposes:
@KubernetesTest(
storeYaml = true,
yamlStorePath = "target/test-yamls"
)
class YamlStorageTest {
@ClassNamespace(name = "yaml-test")
static Namespace yamlTestNs;
@Test
void testYamlStorage() {
ConfigMap config = new ConfigMapBuilder()
.withNewMetadata()
.withName("test-config")
.withNamespace("yaml-test")
.endMetadata()
.addToData("key", "value")
.build();
resourceManager.createResourceWithWait(config);
// YAML file will be stored at:
// target/test-yamls/test-files/primary/YamlStorageTest/testYamlStorage/ConfigMap-yaml-test-test-config.yaml
}
}YAML files are organized by context and test:
target/test-yamls/
├── test-files/
│ ├── primary/ # Primary context resources
│ │ └── TestClass/
│ │ ├── before-all/ # Resources created in @BeforeAll
│ │ ├── testMethod/ # Resources created in test method
│ │ └── after-each/ # Resources created in @AfterEach
│ ├── staging/ # Staging context resources
│ │ └── TestClass/
│ │ └── testMethod/
│ └── production/ # Production context resources
│ └── TestClass/
│ └── testMethod/
@TestInstance(TestInstance.Lifecycle.PER_CLASS) // Important for @BeforeAll injection
@KubernetesTest(cleanup = CleanupStrategy.AUTOMATIC)
class SharedResourceTest {
@ClassNamespace(name = "shared-resources")
static Namespace sharedNs;
@InjectResourceManager
KubeResourceManager resourceManager;
@BeforeAll
void setupSharedResources() {
ConfigMap sharedConfig = new ConfigMapBuilder()
.withNewMetadata()
.withName("shared-config")
.withNamespace("shared-resources")
.endMetadata()
.addToData("shared.key", "shared.value")
.build();
resourceManager.createResourceWithWait(sharedConfig);
}
}All injection annotations work with test method parameters, including context-specific injections:
@Test
void testWithInjection(
@InjectKubeClient KubeClient client,
@InjectKubeClient(kubeContext = "staging") KubeClient stagingClient,
@InjectResourceManager KubeResourceManager resourceManager,
@InjectResourceManager(kubeContext = "production") KubeResourceManager prodResourceManager,
@InjectCmdKubeClient KubeCmdClient<?> cmdClient,
@InjectResource("deployment.yaml") Deployment deployment,
@InjectResource(kubeContext = "staging", value = "staging-deployment.yaml") Deployment stagingDeployment,
@MethodNamespace Namespace testNs,
@MethodNamespace(prefix = "app") Namespace appNs
) {
// All parameters are automatically injected
}The framework provides full thread safety support for parallel test execution:
- Per-Context Instances: Each Kubernetes context gets its own KubeResourceManager
- Automatic Cleanup: Resources are tracked per-test and cleaned up automatically
- Context Isolation: Each context operates independently without conflicts
- Parallel Execution: Tests can run in parallel without context contamination
// Maven Surefire configuration for parallel execution
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<parallel>classes</parallel>
<threadCount>4</threadCount>
</configuration>
</plugin>See comprehensive examples in the test directory:
BasicKubernetesIT- Basic usage, injection, and resource creationMultiNamespaceIT- Multi-namespace testing with@ClassNamespaceMultiContextIT- Advanced multi-context testing withkubeContextNamespaceInjectionIT- Multiple class namespace injectionNamespaceProtectionIT- Namespace protection and safe cleanup demonstrationAdvancedKubernetesIT- Advanced features, PER_CLASS lifecycle, manual cleanupResourceInjectionIT- YAML resource injection patternsPerMethodNamespaceIT- Per-method namespace isolation with@MethodNamespaceParallelExecutionIT- Parallel test execution with@MethodNamespaceLogCollectionIT- Log collection strategies and configuration
@KubernetesTest is @Inherited, so you can define a shared base class and have child classes inherit the configuration:
@KubernetesTest(
cleanup = CleanupStrategy.AUTOMATIC,
resourceTypes = {NamespaceType.class, DeploymentType.class},
collectLogs = true,
logCollectionStrategy = LogCollectionStrategy.ON_FAILURE
)
abstract class AbstractIT {
// Shared setup, helpers, etc.
}
// Inherits everything from parent — no annotation needed
class BasicIT extends AbstractIT {
@ClassNamespace(name = "basic-test")
static Namespace testNs;
@Test
void testSomething() { ... }
}
// Override: child re-declares @KubernetesTest with different config
@KubernetesTest(cleanup = CleanupStrategy.MANUAL)
class ManualCleanupIT extends AbstractIT {
// cleanup is MANUAL, but resourceTypes are still inherited from parent
}The resourceTypes parameter has special merge behavior. When a child overrides @KubernetesTest but doesn't declare its own resourceTypes, it inherits the parent's resourceTypes automatically. This means you can override individual parameters without losing the parent's resource type registrations:
@KubernetesTest(resourceTypes = {NamespaceType.class, DeploymentType.class})
abstract class AbstractIT {}
// Inherits parent's resourceTypes via @Inherited
class ChildIT extends AbstractIT {}
// Overrides cleanup but keeps parent's resourceTypes
@KubernetesTest(cleanup = CleanupStrategy.MANUAL)
class OverrideIT extends AbstractIT {}
// Declares own resourceTypes — uses these instead of parent's
@KubernetesTest(resourceTypes = {NamespaceType.class, ServiceType.class})
class FullOverrideIT extends AbstractIT {}All resources must explicitly specify their namespace in the metadata. The framework does not inject or default namespaces:
// CORRECT - Namespace explicitly specified
ConfigMap config = new ConfigMapBuilder()
.withNewMetadata()
.withName("config")
.withNamespace("my-test") // Required!
.endMetadata()
.build();
// INCORRECT - No namespace specified
ConfigMap config = new ConfigMapBuilder()
.withNewMetadata()
.withName("config")
// Missing namespace - will fail or use unexpected namespace
.endMetadata()
.build();# CORRECT
apiVersion: v1
kind: ConfigMap
metadata:
name: my-config
namespace: my-test # Required!
data:
key: value
# INCORRECT - Missing namespace
apiVersion: v1
kind: ConfigMap
metadata:
name: my-config
# No namespace specified
data:
key: value