io.camunda.zeebe.qa.util.junit.ZeebeIntegrationExtension Maven / Gradle / Ivy
/*
* Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH under
* one or more contributor license agreements. See the NOTICE file distributed
* with this work for additional information regarding copyright ownership.
* Licensed under the Zeebe Community License 1.1. You may not use this file
* except in compliance with the Zeebe Community License 1.1.
*/
package io.camunda.zeebe.qa.util.junit;
import io.atomix.cluster.MemberId;
import io.camunda.zeebe.qa.util.cluster.TestApplication;
import io.camunda.zeebe.qa.util.cluster.TestCluster;
import io.camunda.zeebe.qa.util.cluster.TestGateway;
import io.camunda.zeebe.qa.util.cluster.TestHealthProbe;
import io.camunda.zeebe.qa.util.cluster.TestStandaloneBroker;
import io.camunda.zeebe.qa.util.junit.ZeebeIntegration.TestZeebe;
import io.camunda.zeebe.test.util.record.RecordLogger;
import io.camunda.zeebe.test.util.record.RecordingExporter;
import io.camunda.zeebe.util.FileUtil;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.function.Predicate;
import org.agrona.CloseHelper;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import org.junit.jupiter.api.extension.ExtensionContext.Store;
import org.junit.jupiter.api.extension.ExtensionContext.Store.CloseableResource;
import org.junit.jupiter.api.extension.TestWatcher;
import org.junit.platform.commons.support.AnnotationSupport;
import org.junit.platform.commons.support.HierarchyTraversalMode;
import org.junit.platform.commons.support.ModifierSupport;
import org.junit.platform.commons.util.ReflectionUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* An extension which will manage all static and instance level fields of type {@link
* TestApplication} and {@link TestCluster}, iff they are annotated by {@link TestZeebe}.
*
* The lifecycle of these thus depends on the field being static. If it's static, then it's
* started once before all tests, and stopped after all tests; if it's instance, then it's started
* for every test, and stopped after every test. This includes all adjacent resources created for
* that field (e.g. temporary folders, assigned ports, etc.)
*
*
For brokers, a temporary folder is created and managed by the extension. This allows you to
* stop and restart the same broker with the same data without losing it.
*
*
Additionally, after every test, will reset the recording exporter. On failure, prints out the
* recording exporter using a {@link RecordLogger}. If using a shared cluster, this may output
* records from a previous test, since the recording exporter is not isolated to your test.
*
*
See {@link TestZeebe} for annotation parameters.
*/
final class ZeebeIntegrationExtension
implements BeforeAllCallback, BeforeEachCallback, TestWatcher {
private static final Logger LOG = LoggerFactory.getLogger(ZeebeIntegrationExtension.class);
/**
* Looks up all static {@link TestCluster} and {@link TestApplication} fields, tying their own
* lifecycle to the {@link org.junit.jupiter.api.TestInstance.Lifecycle#PER_CLASS} lifecycle.
*
*
{@inheritDoc}
*/
@Override
public void beforeAll(final ExtensionContext extensionContext) {
final var resources = lookupClusters(extensionContext, null, ModifierSupport::isStatic);
final var nodes = lookupApplications(extensionContext, null, ModifierSupport::isStatic);
manageClusters(extensionContext, resources);
manageApplications(extensionContext, nodes);
}
/**
* Looks up all non-static {@link TestCluster} and {@link TestApplication} fields, tying their own
* lifecycle to the {@link org.junit.jupiter.api.TestInstance.Lifecycle#PER_METHOD} lifecycle.
*
*
{@inheritDoc}
*/
@Override
public void beforeEach(final ExtensionContext extensionContext) {
final var testInstance = extensionContext.getRequiredTestInstance();
final var clusters =
lookupClusters(extensionContext, testInstance, ModifierSupport::isNotStatic);
final var nodes =
lookupApplications(extensionContext, testInstance, ModifierSupport::isNotStatic);
manageClusters(extensionContext, clusters);
manageApplications(extensionContext, nodes);
RecordingExporter.reset();
}
@Override
public void testFailed(final ExtensionContext context, final Throwable cause) {
RecordLogger.logRecords();
}
private Iterable lookupClusters(
final ExtensionContext extensionContext,
final Object testInstance,
final Predicate fieldType) {
return AnnotationSupport.findAnnotatedFields(
extensionContext.getRequiredTestClass(),
TestZeebe.class,
fieldType.and(
field -> ReflectionUtils.isAssignableTo(field.getType(), TestCluster.class)),
HierarchyTraversalMode.TOP_DOWN)
.stream()
.map(field -> asClusterResource(testInstance, field))
.toList();
}
private Iterable lookupApplications(
final ExtensionContext extensionContext,
final Object testInstance,
final Predicate fieldType) {
return AnnotationSupport.findAnnotatedFields(
extensionContext.getRequiredTestClass(),
TestZeebe.class,
fieldType.and(
field -> ReflectionUtils.isAssignableTo(field.getType(), TestApplication.class)),
HierarchyTraversalMode.TOP_DOWN)
.stream()
.map(field -> asNodeResource(testInstance, field))
.toList();
}
private void manageClusters(
final ExtensionContext extensionContext, final Iterable resources) {
final var store = store(extensionContext);
// register all resources first to ensure we close them; this avoids leaking resource if
// starting one fails
resources.forEach(resource -> store.put(resource, resource));
for (final var resource : resources) {
final var directory = createManagedDirectory(store, resource.cluster.name());
manageCluster(directory, resource);
}
}
private void manageCluster(final Path directory, final ClusterResource resource) {
final var cluster = resource.cluster;
// assign a working directory for each broker that gets deleted with the extension lifecycle,
// and not when the broker is shutdown. this allows to introspect or move the data around even
// after stopping a broker
cluster.brokers().forEach((id, broker) -> setWorkingDirectory(directory, id, broker));
startTestZeebe(resource);
}
private void manageApplications(
final ExtensionContext extensionContext, final Iterable resources) {
final var store = store(extensionContext);
// register all resources first to ensure we close them; this avoids leaking resource if
// starting one fails
resources.forEach(resource -> store.put(resource, resource));
for (final var resource : resources) {
manageApplication(store, resource);
}
}
private void manageApplication(final Store store, final ApplicationResource resource) {
// assign a working directory to the broker that gets deleted with the extension lifecycle,
// and not when the broker is shutdown. this allows to introspect or move the data around even
// after stopping a broker
if (resource.app instanceof final TestStandaloneBroker broker) {
final var directory = createManagedDirectory(store, "broker-" + broker.nodeId().id());
setWorkingDirectory(directory, broker.nodeId(), broker);
}
startTestZeebe(resource);
}
private void startTestZeebe(final TestZeebeResource resource) {
final var annotation = resource.annotation();
if (annotation.autoStart()) {
resource.start();
if (annotation.awaitStarted()) {
resource.await(TestHealthProbe.STARTED);
}
if (annotation.awaitReady()) {
resource.await(TestHealthProbe.READY);
}
if (annotation.awaitCompleteTopology()) {
resource.awaitCompleteTopology();
}
}
}
private void setWorkingDirectory(
final Path directory, final MemberId id, final TestStandaloneBroker broker) {
final Path workingDirectory = directory.resolve("broker-" + id.id());
try {
Files.createDirectory(workingDirectory);
} catch (final IOException e) {
throw new UncheckedIOException(e);
}
broker.withWorkingDirectory(workingDirectory);
}
private Path createManagedDirectory(final Store store, final String prefix) {
try {
// add the common junit prefix to clearly indicate these are test folders
final var directory = Files.createTempDirectory("junit-" + prefix);
store.put(directory, new DirectoryResource(directory));
return directory;
} catch (final IOException e) {
throw new UncheckedIOException(e);
}
}
private ClusterResource asClusterResource(final Object testInstance, final Field field) {
final TestCluster value;
try {
value = (TestCluster) ReflectionUtils.makeAccessible(field).get(testInstance);
} catch (final IllegalAccessException e) {
throw new UnsupportedOperationException(e);
}
return new ClusterResource(value, field.getAnnotation(TestZeebe.class));
}
private ApplicationResource asNodeResource(final Object testInstance, final Field field) {
final TestApplication> value;
try {
value = (TestApplication>) ReflectionUtils.makeAccessible(field).get(testInstance);
} catch (final IllegalAccessException e) {
throw new UnsupportedOperationException(e);
}
return new ApplicationResource(value, field.getAnnotation(TestZeebe.class));
}
private Store store(final ExtensionContext extensionContext) {
return extensionContext.getStore(Namespace.create(ZeebeIntegrationExtension.class));
}
private record ClusterResource(TestCluster cluster, TestZeebe annotation)
implements TestZeebeResource, CloseableResource {
@Override
public void close() {
CloseHelper.close(
error -> LOG.warn("Failed to close cluster {}, leaking resources", cluster.name(), error),
cluster);
}
@Override
public void start() {
cluster.start();
}
@Override
public void await(final TestHealthProbe probe) {
cluster.await(probe);
}
@Override
public void awaitCompleteTopology() {
final var clusterSize =
annotation.clusterSize() <= 0 ? cluster.brokers().size() : annotation.clusterSize();
final var partitionCount =
annotation.partitionCount() <= 0
? cluster.partitionsCount()
: annotation.partitionCount();
final var replicationFactor =
annotation.replicationFactor() <= 0
? cluster.replicationFactor()
: annotation.replicationFactor();
final var timeout =
annotation.topologyTimeoutMs() == 0
? Duration.ofMinutes(clusterSize)
: Duration.ofMillis(annotation().topologyTimeoutMs());
cluster.awaitCompleteTopology(clusterSize, partitionCount, replicationFactor, timeout);
}
}
private record ApplicationResource(TestApplication> app, TestZeebe annotation)
implements TestZeebeResource, CloseableResource {
@Override
public void close() {
CloseHelper.close(
error -> LOG.warn("Failed to close test app {}, leaking resources", app.nodeId()), app);
}
@Override
public void start() {
app.start();
}
@Override
public void await(final TestHealthProbe probe) {
app.await(probe);
}
@Override
public void awaitCompleteTopology() {
if (!(app.isGateway() && (app instanceof final TestGateway> gateway))) {
return;
}
final var timeout =
annotation.topologyTimeoutMs() == 0
? Duration.ofMinutes(1)
: Duration.ofMillis(annotation().topologyTimeoutMs());
gateway.awaitCompleteTopology(
Math.max(1, annotation.clusterSize()),
Math.max(1, annotation.partitionCount()),
Math.max(1, annotation.replicationFactor()),
timeout);
}
}
private record DirectoryResource(Path directory) implements CloseableResource {
@Override
public void close() {
try {
FileUtil.deleteFolderIfExists(directory);
} catch (final IOException e) {
LOG.warn("Failed to clean up temporary directory {}, leaking resources...", directory, e);
}
}
}
private interface TestZeebeResource {
TestZeebe annotation();
void start();
void await(final TestHealthProbe probe);
void awaitCompleteTopology();
}
}