KAFKA-18617 Allow use of ClusterInstance inside BeforeEach (#18662)

Move the ClusterTest setup callback to `BeforeEachCallback` instead of
`BeforeTestExecutionCallback`. This allows the setup to happen slightly
earlier and before any `@BeforeEach` methods are invoked.

We cannot inject ClusterInstance into a `@BeforeEach` method, but we can
still use the constructor injection which allows a `@BeforeEach` fixture
to access the ClusterInstance. This enables tests to do some common
cluster setup (like creating topics) before each test case.

No changes were made to `@AfterEach` or our ClusterTest teardown logic.

Reviewers: Sushant Mahajan <smahajan@confluent.io>, Chia-Ping Tsai <chia7712@gmail.com>
This commit is contained in:
David Arthur 2025-03-12 13:59:20 -04:00 committed by GitHub
parent e3080684c0
commit 25f8bf82f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 195 additions and 55 deletions

View File

@ -3,17 +3,50 @@ cluster configurations.
# Annotations
A new `@ClusterTest` annotation is introduced which allows for a test to declaratively configure an underlying Kafka cluster.
Three annotations are provided for defining a template of a Kafka cluster.
```scala
@ClusterTest
def testSomething(): Unit = { ... }
```
* `@ClusterTest`: declarative style cluster definition
* `@ClusterTests`: wrapper around multiple `@ClusterTest`-s
* `@ClusterTemplate`: points to a function for imperative cluster definition
This annotation has fields for a set of cluster types and number of brokers, as well as commonly parameterized configurations.
Arbitrary server properties can also be provided in the annotation:
Another helper annotation `@ClusterTestDefaults` allows overriding the defaults for
all `@ClusterTest` in a single test class.
# Usage
The simplest usage is `@ClusterTest` by itself which will use some reasonable defaults.
```java
public class SampleTest {
@ClusterTest
void testSomething() { ... }
}
```
The defaults can be modified by setting specific paramters on the annotation.
```java
public class SampleTest {
@ClusterTest(brokers = 3, metadataVersion = MetadataVersion.IBP_4_0_IV3)
void testSomething() { ... }
}
```
It is also possible to modify the defaults for a whole class using `@ClusterTestDefaults`.
```java
@ClusterTestDefaults(brokers = 3, metadataVersion = MetadataVersion.IBP_4_0_IV3)
public class SampleTest {
@ClusterTest
void testSomething() { ... }
}
```
To set some specific config, an array of `@ClusterProperty` annotations can be
given.
```java
public class SampleTest {
@ClusterTest(
types = {Type.KRAFT},
brokerSecurityProtocol = SecurityProtocol.PLAINTEXT,
@ -22,27 +55,28 @@ Arbitrary server properties can also be provided in the annotation:
@ClusterProperty(key = "socket.send.buffer.bytes", value = "10240"),
})
void testSomething() { ... }
}
```
Multiple `@ClusterTest` annotations can be given to generate more than one test invocation for the annotated method.
Using the `@ClusterTests` annotation, multiple declarative cluster templates can
be given.
```scala
@ClusterTests(Array(
new ClusterTest(brokerSecurityProtocol = SecurityProtocol.PLAINTEXT),
new ClusterTest(brokerSecurityProtocol = SecurityProtocol.SASL_PLAINTEXT)
))
def testSomething(): Unit = { ... }
```java
public class SampleTest {
@ClusterTests({
@ClusterTest(brokerSecurityProtocol = SecurityProtocol.PLAINTEXT),
@ClusterTest(brokerSecurityProtocol = SecurityProtocol.SASL_PLAINTEXT)
})
void testSomething() { ... }
}
```
A class-level `@ClusterTestDefaults` annotation is added to provide default values for `@ClusterTest` defined within
the class. The intention here is to reduce repetitive annotation declarations and also make changing defaults easier
for a class with many test cases.
# Dynamic Configuration
In order to allow for more flexible cluster configuration, a `@ClusterTemplate` annotation is also introduced. This
annotation takes a single string value which references a static method on the test class. This method is used to
produce any number of test configurations using a fluent builder style API.
In order to allow for more flexible cluster configuration, a `@ClusterTemplate`
annotation is also introduced. This annotation takes a single string value which
references a static method on the test class. This method is used to produce any
number of test configurations using a fluent builder style API.
```java
import java.util.Arrays;
@ -69,57 +103,90 @@ static List<ClusterConfig> generateConfigs() {
}
```
This "escape hatch" from the simple declarative style configuration makes it easy to dynamically configure clusters.
This alternate configuration style makes it easy to create any number of complex
configurations. Each returned ClusterConfig by a template method will result in
an additional variation of the run.
# JUnit Extension
One thing to note is that our "test*" methods are no longer _tests_, but rather they are test templates. We have added
a JUnit extension called `ClusterTestExtensions` which knows how to process these annotations in order to generate test
invocations. Test classes that wish to make use of these annotations need to explicitly register this extension:
The core logic of our test framework lies in `ClusterTestExtensions` which is a
JUnit extension. It is automatically registered using SPI and will look for test
methods that include one of the three annotations mentioned above.
```scala
import org.apache.kafka.common.test.junit.ClusterTestExtensions
@ExtendWith(value = Array(classOf[ClusterTestExtensions]))
class ApiVersionsRequestTest {
...
}
```
This way of dynamically generating tests uses the JUnit concept of test templates.
# JUnit Lifecycle
The lifecycle of a test class that is extended with `ClusterTestExtensions` follows:
JUnit discovers test template methods that are annotated with `@ClusterTest`,
`@ClusterTests`, or `@ClusterTemplate`. These annotations are processed and some
number of test invocations are created.
* JUnit discovers test template methods that are annotated with `@ClusterTest`, `@ClusterTests`, or `@ClusterTemplate`
* `ClusterTestExtensions` is called for each of these template methods in order to generate some number of test invocations
For each generated test invocation we have the following lifecycle:
For each generated invocation:
* Static `@BeforeAll` methods are called
* Test class is instantiated
* Kafka Cluster is started (if autoStart=true)
* Non-static `@BeforeEach` methods are called
* Kafka Cluster is started
* Test method is invoked
* Kafka Cluster is stopped
* Non-static `@AfterEach` methods are called
* Static `@AfterAll` methods are called
`@BeforeEach` methods give an opportunity to setup additional test dependencies before the cluster is started.
`@BeforeEach` methods give an opportunity to set up additional test dependencies
after the cluster has started but before the test method is run.
# Dependency Injection
The class is introduced to provide context to the underlying cluster and to provide reusable functionality that was
previously garnered from the test hierarchy.
A ClusterInstance object can be injected into the test method or the test class constructor.
This object is a shim to the underlying test framework and provides access to things like
SocketServers and has convenience factory methods for getting a client.
* ClusterInstance: a shim to the underlying class that actually runs the cluster, provides access to things like SocketServers
The class is introduced to provide context to the underlying cluster and to provide reusable
functionality that was previously garnered from the test hierarchy.
In order to inject the object, simply add it as a parameter to your test class, `@BeforeEach` method, or test method.
Common usage is to inject this class into a test method
| Injection | Class | BeforeEach | Test | Notes
| --- | --- | --- | --- | --- |
| ClusterInstance | yes* | no | yes | Injectable at class level for convenience, can only be accessed inside test |
```java
class SampleTest {
@ClusterTest
public void testOne(ClusterInstance cluster) {
this.cluster.admin().createTopics(...);
// Test code
}
}
```
For cases where there is common setup code that involves the cluster (such as
creating topics), it is possible to access the ClusterInstance from a `@BeforeEach`
method. This requires injecting the object in the constructor. For example,
```java
class SampleTest {
private final ClusterInstance cluster;
SampleTest(ClusterInstance cluster) {
this.cluster = cluster;
}
@BeforeEach
public void setup() {
// Common setup code with started ClusterInstance
this.cluster.admin().createTopics(...);
}
@ClusterTest
public void testOne() {
// Test code
}
}
```
It is okay to inject the ClusterInstance in both ways. The same object will be
provided in either case.
# Gotchas
* Cluster tests are not compatible with other test templates like `@ParameterizedTest`
* Test methods annotated with JUnit's `@Test` will still be run, but no cluster will be started and no dependency
injection will happen. This is generally not what you want.
* Even though ClusterConfig is accessible, it is immutable inside the test method.

View File

@ -263,8 +263,12 @@ public interface ClusterInstance {
void start();
boolean started();
void stop();
boolean stopped();
void shutdownBroker(int brokerId);
void startBroker(int brokerId);

View File

@ -38,7 +38,7 @@ import org.apache.kafka.server.common.MetadataVersion;
import org.apache.kafka.server.fault.FaultHandlerException;
import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.Extension;
import org.junit.jupiter.api.extension.TestTemplateInvocationContext;
@ -90,7 +90,7 @@ public class RaftClusterInvocationContext implements TestTemplateInvocationConte
public List<Extension> getAdditionalExtensions() {
RaftClusterInstance clusterInstance = new RaftClusterInstance(clusterConfig, isCombined);
return Arrays.asList(
(BeforeTestExecutionCallback) context -> {
(BeforeEachCallback) context -> {
clusterInstance.format();
if (clusterConfig.isAutoStart()) {
clusterInstance.start();
@ -189,6 +189,11 @@ public class RaftClusterInvocationContext implements TestTemplateInvocationConte
}
}
@Override
public boolean started() {
return started.get();
}
@Override
public void stop() {
if (stopped.compareAndSet(false, true)) {
@ -196,6 +201,11 @@ public class RaftClusterInvocationContext implements TestTemplateInvocationConte
}
}
@Override
public boolean stopped() {
return stopped.get();
}
@Override
public void shutdownBroker(int brokerId) {
findBrokerOrThrow(brokerId).shutdown();

View File

@ -0,0 +1,59 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.kafka.common.test.junit;
import org.apache.kafka.common.test.ClusterInstance;
import org.apache.kafka.common.test.api.AutoStart;
import org.apache.kafka.common.test.api.ClusterTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@ExtendWith(ClusterTestExtensions.class)
public class ClusterTestBeforeEachTest {
private final ClusterInstance clusterInstance;
ClusterTestBeforeEachTest(ClusterInstance clusterInstance) { // Constructor injections
this.clusterInstance = clusterInstance;
}
@BeforeEach
void before() {
assertNotNull(clusterInstance);
if (!clusterInstance.started()) {
clusterInstance.start();
}
assertDoesNotThrow(clusterInstance::waitForReadyBrokers);
}
@ClusterTest
public void testDefault() {
assertTrue(true);
assertNotNull(clusterInstance);
}
@ClusterTest(autoStart = AutoStart.NO)
public void testNoAutoStart() {
assertTrue(true);
assertNotNull(clusterInstance);
}
}