You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@flink.apache.org by da...@apache.org on 2026/07/15 20:07:46 UTC
(flink) branch flink-40138 created (now c73cacd92bb)
This is an automated email from the ASF dual-hosted git repository.
alpinegizmo pushed a change to branch flink-40138
in repository https://gitbox.apache.org/repos/asf/flink.git
at c73cacd92bb [FLINK-40138][tests] Drive JUnit 5 lifecycle when generating migration test snapshots
This branch includes the following new commits:
new c73cacd92bb [FLINK-40138][tests] Drive JUnit 5 lifecycle when generating migration test snapshots
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(flink) 01/01: [FLINK-40138][tests] Drive JUnit 5 lifecycle when generating migration test snapshots
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
alpinegizmo pushed a commit to branch flink-40138
in repository https://gitbox.apache.org/repos/asf/flink.git
commit c73cacd92bb9c3617c2e31af44dcc853d75b0dae
Author: David Anderson <da...@alpinegizmo.com>
AuthorDate: Mon Jul 13 17:32:12 2026 -0400
[FLINK-40138][tests] Drive JUnit 5 lifecycle when generating migration test snapshots
Generated-by: Claude Opus 4.8 (1M context) <no...@anthropic.com>
---
.../flink-migration-test-utils/pom.xml | 11 +
.../test/migration/JUnit5TestEnvironment.java | 543 +++++++++++++++++++++
.../test/migration/SnapshotGeneratorUtils.java | 189 +++++--
3 files changed, 704 insertions(+), 39 deletions(-)
diff --git a/flink-test-utils-parent/flink-migration-test-utils/pom.xml b/flink-test-utils-parent/flink-migration-test-utils/pom.xml
index 5ba3484453e..88056800df2 100644
--- a/flink-test-utils-parent/flink-migration-test-utils/pom.xml
+++ b/flink-test-utils-parent/flink-migration-test-utils/pom.xml
@@ -51,6 +51,17 @@ under the License.
<artifactId>junit-vintage-engine</artifactId>
</dependency>
+ <!--
+ The snapshot generator drives the JUnit 5 lifecycle of migration tests (see
+ JUnit5TestEnvironment), so the JUnit 5 API is needed on the main (compile) classpath,
+ overriding the test scope that is otherwise inherited.
+ -->
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <scope>compile</scope>
+ </dependency>
+
<dependency>
<groupId>commons-cli</groupId>
<artifactId>commons-cli</artifactId>
diff --git a/flink-test-utils-parent/flink-migration-test-utils/src/main/java/org/apache/flink/test/migration/JUnit5TestEnvironment.java b/flink-test-utils-parent/flink-migration-test-utils/src/main/java/org/apache/flink/test/migration/JUnit5TestEnvironment.java
new file mode 100644
index 00000000000..bf5d13569ab
--- /dev/null
+++ b/flink-test-utils-parent/flink-migration-test-utils/src/main/java/org/apache/flink/test/migration/JUnit5TestEnvironment.java
@@ -0,0 +1,543 @@
+/*
+ * 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.flink.test.migration;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.extension.AfterAllCallback;
+import org.junit.jupiter.api.extension.AfterEachCallback;
+import org.junit.jupiter.api.extension.BeforeAllCallback;
+import org.junit.jupiter.api.extension.BeforeEachCallback;
+import org.junit.jupiter.api.extension.ExecutableInvoker;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.api.extension.ParameterContext;
+import org.junit.jupiter.api.extension.ParameterResolutionException;
+import org.junit.jupiter.api.extension.ParameterResolver;
+import org.junit.jupiter.api.extension.RegisterExtension;
+import org.junit.jupiter.api.extension.TestInstances;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.api.parallel.ExecutionMode;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.AnnotatedElement;
+import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.Parameter;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+/**
+ * Drives the subset of the JUnit 5 lifecycle that the migration test data generation relies on.
+ *
+ * <p>The {@link MigrationTestsSnapshotGenerator} instantiates the migration test classes directly
+ * via reflection and invokes their
+ * {@code @SnapshotsGenerator}/{@code @ParameterizedSnapshotsGenerator} methods outside of any JUnit
+ * engine. Migration tests that have been migrated to JUnit 5 therefore need their extensions and
+ * lifecycle methods to be driven manually, otherwise resources such as the {@link
+ * org.apache.flink.runtime.minicluster.MiniCluster} or the executor service backing the test would
+ * never be set up.
+ *
+ * <p>This environment supports:
+ *
+ * <ul>
+ * <li>{@code @RegisterExtension} fields (static and instance) implementing the {@link
+ * BeforeAllCallback}, {@link BeforeEachCallback}, {@link AfterEachCallback}, {@link
+ * AfterAllCallback} and {@link ParameterResolver} extension points,
+ * <li>{@code @BeforeEach}/{@code @AfterEach} lifecycle methods, including parameter injection
+ * (for example {@link org.apache.flink.test.junit5.InjectClusterClient}) through the
+ * registered {@link ParameterResolver}s, and
+ * <li>{@code @TempDir} fields.
+ * </ul>
+ *
+ * <p>Class-level extensions registered via {@code @ExtendWith} are intentionally not driven: they
+ * are concerned with the test runner itself (parameterization of {@code @TestTemplate} methods,
+ * logging) and are not required to set up the resources used during snapshot generation.
+ *
+ * <p>The lifecycle is driven so that {@link #beforeAll()} runs before the test instance is created,
+ * matching JUnit 5 semantics and, crucially, allowing instance field initializers to reference
+ * resources set up by statically registered extensions.
+ */
+class JUnit5TestEnvironment {
+
+ private final Class<?> testClass;
+ private final GeneratorExtensionContext extensionContext;
+ private final List<Object> staticExtensions = new ArrayList<>();
+ private final List<Object> instanceExtensions = new ArrayList<>();
+ private final List<Path> temporaryDirectories = new ArrayList<>();
+
+ JUnit5TestEnvironment(Class<?> testClass) {
+ this.testClass = testClass;
+ this.extensionContext = new GeneratorExtensionContext(testClass);
+ }
+
+ /**
+ * Runs the {@code beforeAll} callbacks of statically registered extensions. Must be called
+ * before the test instance is created, because instance field initializers may already
+ * reference resources set up by these callbacks (for example the executor service used to build
+ * a {@link org.apache.flink.util.concurrent.ScheduledExecutorServiceAdapter}).
+ */
+ void beforeAll() throws Exception {
+ collectRegisteredExtensions(null, staticExtensions);
+ for (Object extension : staticExtensions) {
+ if (extension instanceof BeforeAllCallback) {
+ ((BeforeAllCallback) extension).beforeAll(extensionContext);
+ }
+ }
+ }
+
+ /** Collects instance-level extensions and injects {@code @TempDir} fields. */
+ void initializeTestInstance(Object testInstance) throws Exception {
+ extensionContext.setTestInstance(testInstance);
+ collectRegisteredExtensions(testInstance, instanceExtensions);
+ injectTemporaryDirectories(testInstance);
+ }
+
+ /** Runs {@code beforeEach} callbacks followed by the {@code @BeforeEach} methods. */
+ void beforeEach(Object testInstance) throws Throwable {
+ for (Object extension : allExtensions()) {
+ if (extension instanceof BeforeEachCallback) {
+ ((BeforeEachCallback) extension).beforeEach(extensionContext);
+ }
+ }
+ for (Method method : lifecycleMethods(BeforeEach.class, true)) {
+ invokeLifecycleMethod(testInstance, method);
+ }
+ }
+
+ /** Runs the {@code @AfterEach} methods followed by the {@code afterEach} callbacks. */
+ void afterEach(Object testInstance) throws Throwable {
+ for (Method method : lifecycleMethods(AfterEach.class, false)) {
+ invokeLifecycleMethod(testInstance, method);
+ }
+ for (Object extension : reversed(allExtensions())) {
+ if (extension instanceof AfterEachCallback) {
+ ((AfterEachCallback) extension).afterEach(extensionContext);
+ }
+ }
+ }
+
+ /**
+ * Runs the {@code afterAll} callbacks of statically registered extensions, closes the stored
+ * {@link ExtensionContext.Store.CloseableResource}s and removes the temporary directories.
+ */
+ void afterAll() throws Exception {
+ for (Object extension : reversed(staticExtensions)) {
+ if (extension instanceof AfterAllCallback) {
+ ((AfterAllCallback) extension).afterAll(extensionContext);
+ }
+ }
+ extensionContext.close();
+ deleteTemporaryDirectories();
+ }
+
+ private List<Object> allExtensions() {
+ List<Object> all = new ArrayList<>(staticExtensions);
+ all.addAll(instanceExtensions);
+ return all;
+ }
+
+ private static List<Object> reversed(List<Object> list) {
+ List<Object> reversed = new ArrayList<>(list);
+ Collections.reverse(reversed);
+ return reversed;
+ }
+
+ private void collectRegisteredExtensions(Object instance, List<Object> target)
+ throws IllegalAccessException {
+ // Iterate the hierarchy from the topmost ancestor down to the test class, so that
+ // extensions declared in super classes are set up before those of sub classes.
+ for (Class<?> clazz : classHierarchy(testClass)) {
+ for (Field field : clazz.getDeclaredFields()) {
+ if (!field.isAnnotationPresent(RegisterExtension.class)) {
+ continue;
+ }
+ boolean isStatic = Modifier.isStatic(field.getModifiers());
+ // When looking up static extensions no instance is available yet; when looking up
+ // instance extensions we skip the static ones as they were already collected.
+ if (isStatic != (instance == null)) {
+ continue;
+ }
+ field.setAccessible(true);
+ Object value = field.get(instance);
+ if (value != null) {
+ target.add(value);
+ }
+ }
+ }
+ }
+
+ private void injectTemporaryDirectories(Object instance)
+ throws IOException, IllegalAccessException {
+ for (Class<?> clazz : classHierarchy(testClass)) {
+ for (Field field : clazz.getDeclaredFields()) {
+ if (!field.isAnnotationPresent(TempDir.class)) {
+ continue;
+ }
+ Path directory = Files.createTempDirectory("migration-test-generator");
+ temporaryDirectories.add(directory);
+ field.setAccessible(true);
+ Class<?> type = field.getType();
+ if (Path.class.equals(type)) {
+ field.set(instance, directory);
+ } else if (File.class.equals(type)) {
+ field.set(instance, directory.toFile());
+ } else {
+ throw new IllegalStateException(
+ "Unsupported @TempDir field type "
+ + type.getName()
+ + " on field "
+ + field
+ + ". Only java.nio.file.Path and java.io.File are supported.");
+ }
+ }
+ }
+ }
+
+ private void deleteTemporaryDirectories() {
+ for (Path directory : temporaryDirectories) {
+ try (Stream<Path> paths = Files.walk(directory)) {
+ paths.sorted(Comparator.reverseOrder())
+ .forEach(JUnit5TestEnvironment::deleteQuietly);
+ } catch (IOException ignored) {
+ // Best effort clean up of temporary directories.
+ }
+ }
+ }
+
+ private static void deleteQuietly(Path path) {
+ try {
+ Files.deleteIfExists(path);
+ } catch (IOException ignored) {
+ // Best effort clean up of temporary directories.
+ }
+ }
+
+ private List<Method> lifecycleMethods(
+ Class<? extends Annotation> annotation, boolean superClassFirst) {
+ List<Class<?>> hierarchy = classHierarchy(testClass);
+ if (!superClassFirst) {
+ Collections.reverse(hierarchy);
+ }
+ List<Method> methods = new ArrayList<>();
+ Set<String> visitedSignatures = new HashSet<>();
+ for (Class<?> clazz : hierarchy) {
+ for (Method method : clazz.getDeclaredMethods()) {
+ if (!method.isAnnotationPresent(annotation)) {
+ continue;
+ }
+ String signature = method.getName() + Arrays.toString(method.getParameterTypes());
+ if (visitedSignatures.add(signature)) {
+ methods.add(method);
+ }
+ }
+ }
+ return methods;
+ }
+
+ private void invokeLifecycleMethod(Object testInstance, Method method) throws Throwable {
+ method.setAccessible(true);
+ Object[] arguments = resolveParameters(testInstance, method);
+ try {
+ method.invoke(testInstance, arguments);
+ } catch (InvocationTargetException e) {
+ throw e.getTargetException();
+ }
+ }
+
+ private Object[] resolveParameters(Object testInstance, Method method) {
+ Parameter[] parameters = method.getParameters();
+ Object[] arguments = new Object[parameters.length];
+ for (int i = 0; i < parameters.length; i++) {
+ GeneratorParameterContext parameterContext =
+ new GeneratorParameterContext(parameters[i], i, Optional.of(testInstance));
+ arguments[i] = resolveParameter(parameterContext);
+ }
+ return arguments;
+ }
+
+ private Object resolveParameter(GeneratorParameterContext parameterContext) {
+ for (Object extension : allExtensions()) {
+ if (extension instanceof ParameterResolver) {
+ ParameterResolver resolver = (ParameterResolver) extension;
+ if (resolver.supportsParameter(parameterContext, extensionContext)) {
+ return resolver.resolveParameter(parameterContext, extensionContext);
+ }
+ }
+ }
+ throw new ParameterResolutionException(
+ "No registered extension can resolve parameter ["
+ + parameterContext.getParameter()
+ + "] of ["
+ + parameterContext.getParameter().getDeclaringExecutable()
+ + "].");
+ }
+
+ private static List<Class<?>> classHierarchy(Class<?> testClass) {
+ List<Class<?>> hierarchy = new ArrayList<>();
+ for (Class<?> clazz = testClass;
+ clazz != null && clazz != Object.class;
+ clazz = clazz.getSuperclass()) {
+ hierarchy.add(clazz);
+ }
+ // Topmost ancestor first.
+ Collections.reverse(hierarchy);
+ return hierarchy;
+ }
+
+ /**
+ * A minimal {@link ExtensionContext} that provides just enough behavior for the extensions used
+ * by the migration tests, most importantly a working {@link ExtensionContext.Store}.
+ */
+ private static final class GeneratorExtensionContext implements ExtensionContext {
+
+ private final Class<?> testClass;
+ private final Map<Namespace, GeneratorStore> stores = new ConcurrentHashMap<>();
+ private Object testInstance;
+
+ private GeneratorExtensionContext(Class<?> testClass) {
+ this.testClass = testClass;
+ }
+
+ private void setTestInstance(Object testInstance) {
+ this.testInstance = testInstance;
+ }
+
+ private void close() throws Exception {
+ for (GeneratorStore store : stores.values()) {
+ store.close();
+ }
+ }
+
+ @Override
+ public Optional<ExtensionContext> getParent() {
+ return Optional.empty();
+ }
+
+ @Override
+ public ExtensionContext getRoot() {
+ return this;
+ }
+
+ @Override
+ public String getUniqueId() {
+ return testClass.getName();
+ }
+
+ @Override
+ public String getDisplayName() {
+ return testClass.getSimpleName();
+ }
+
+ @Override
+ public Set<String> getTags() {
+ return Collections.emptySet();
+ }
+
+ @Override
+ public Optional<AnnotatedElement> getElement() {
+ return Optional.of(testClass);
+ }
+
+ @Override
+ public Optional<Class<?>> getTestClass() {
+ return Optional.of(testClass);
+ }
+
+ @Override
+ public Optional<TestInstance.Lifecycle> getTestInstanceLifecycle() {
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional<Object> getTestInstance() {
+ return Optional.ofNullable(testInstance);
+ }
+
+ @Override
+ public Optional<TestInstances> getTestInstances() {
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional<Method> getTestMethod() {
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional<Throwable> getExecutionException() {
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional<String> getConfigurationParameter(String key) {
+ return Optional.empty();
+ }
+
+ @Override
+ public <T> Optional<T> getConfigurationParameter(
+ String key, Function<String, T> transformer) {
+ return Optional.empty();
+ }
+
+ @Override
+ public void publishReportEntry(Map<String, String> map) {
+ // Report entries are not collected during snapshot generation.
+ }
+
+ @Override
+ public Store getStore(Namespace namespace) {
+ return stores.computeIfAbsent(namespace, ignored -> new GeneratorStore());
+ }
+
+ @Override
+ public ExecutionMode getExecutionMode() {
+ return ExecutionMode.SAME_THREAD;
+ }
+
+ @Override
+ public ExecutableInvoker getExecutableInvoker() {
+ throw new UnsupportedOperationException(
+ "ExecutableInvoker is not supported during migration test data generation.");
+ }
+ }
+
+ /** A minimal {@link ExtensionContext.Store} backed by a map. */
+ private static final class GeneratorStore implements ExtensionContext.Store {
+
+ private final Map<Object, Object> values = new ConcurrentHashMap<>();
+
+ @Override
+ public Object get(Object key) {
+ return values.get(key);
+ }
+
+ @Override
+ public <V> V get(Object key, Class<V> requiredType) {
+ return requiredType.cast(values.get(key));
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public <K, V> Object getOrComputeIfAbsent(K key, Function<K, V> defaultCreator) {
+ return values.computeIfAbsent(key, mapKey -> defaultCreator.apply((K) mapKey));
+ }
+
+ @Override
+ public <K, V> V getOrComputeIfAbsent(
+ K key, Function<K, V> defaultCreator, Class<V> requiredType) {
+ return requiredType.cast(getOrComputeIfAbsent(key, defaultCreator));
+ }
+
+ @Override
+ public void put(Object key, Object value) {
+ values.put(key, value);
+ }
+
+ @Override
+ public Object remove(Object key) {
+ return values.remove(key);
+ }
+
+ @Override
+ public <V> V remove(Object key, Class<V> requiredType) {
+ return requiredType.cast(values.remove(key));
+ }
+
+ private void close() throws Exception {
+ Throwable firstError = null;
+ for (Object value : values.values()) {
+ if (value instanceof CloseableResource) {
+ try {
+ ((CloseableResource) value).close();
+ } catch (Throwable t) {
+ if (firstError == null) {
+ firstError = t;
+ } else {
+ firstError.addSuppressed(t);
+ }
+ }
+ }
+ }
+ if (firstError != null) {
+ throw new Exception("Failed to close stored resources.", firstError);
+ }
+ }
+ }
+
+ /** A minimal {@link ParameterContext} wrapping a single {@link Parameter}. */
+ private static final class GeneratorParameterContext implements ParameterContext {
+
+ private final Parameter parameter;
+ private final int index;
+ private final Optional<Object> target;
+
+ private GeneratorParameterContext(Parameter parameter, int index, Optional<Object> target) {
+ this.parameter = parameter;
+ this.index = index;
+ this.target = target;
+ }
+
+ @Override
+ public Parameter getParameter() {
+ return parameter;
+ }
+
+ @Override
+ public int getIndex() {
+ return index;
+ }
+
+ @Override
+ public Optional<Object> getTarget() {
+ return target;
+ }
+
+ @Override
+ public boolean isAnnotated(Class<? extends Annotation> annotationType) {
+ return parameter.isAnnotationPresent(annotationType);
+ }
+
+ @Override
+ public <A extends Annotation> Optional<A> findAnnotation(Class<A> annotationType) {
+ return Optional.ofNullable(parameter.getAnnotation(annotationType));
+ }
+
+ @Override
+ public <A extends Annotation> List<A> findRepeatableAnnotations(Class<A> annotationType) {
+ return Arrays.asList(parameter.getAnnotationsByType(annotationType));
+ }
+ }
+}
diff --git a/flink-test-utils-parent/flink-migration-test-utils/src/main/java/org/apache/flink/test/migration/SnapshotGeneratorUtils.java b/flink-test-utils-parent/flink-migration-test-utils/src/main/java/org/apache/flink/test/migration/SnapshotGeneratorUtils.java
index 5fa0542d795..1f9e2bfab04 100644
--- a/flink-test-utils-parent/flink-migration-test-utils/src/main/java/org/apache/flink/test/migration/SnapshotGeneratorUtils.java
+++ b/flink-test-utils-parent/flink-migration-test-utils/src/main/java/org/apache/flink/test/migration/SnapshotGeneratorUtils.java
@@ -35,18 +35,22 @@ import java.lang.annotation.Annotation;
import java.lang.reflect.Array;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collection;
+import java.util.HashSet;
import java.util.List;
+import java.util.Set;
/** A utility class to execute generateSnapshots method for migration tests. */
class SnapshotGeneratorUtils {
static void executeGenerate(Class<?> migrationTestClass, FlinkVersion flinkVersion)
throws Throwable {
- for (Method method : migrationTestClass.getMethods()) {
+ for (Method method : findGeneratorMethods(migrationTestClass)) {
if (method.isAnnotationPresent(MigrationTest.SnapshotsGenerator.class)) {
executeGenerateMethods(migrationTestClass, method, flinkVersion);
} else if (method.isAnnotationPresent(
@@ -55,36 +59,74 @@ class SnapshotGeneratorUtils {
method.getAnnotation(MigrationTest.ParameterizedSnapshotsGenerator.class)
.value();
Method parametersMethod =
- migrationTestClass.getMethod(parametersMethodName, FlinkVersion.class);
+ findDeclaredMethod(
+ migrationTestClass, parametersMethodName, FlinkVersion.class);
executeParameterizedGenerateMethods(
migrationTestClass, method, parametersMethod, flinkVersion);
}
}
}
+ /**
+ * Finds all methods annotated with {@link MigrationTest.SnapshotsGenerator} or {@link
+ * MigrationTest.ParameterizedSnapshotsGenerator} declared by the test class or any of its super
+ * classes. Unlike {@link Class#getMethods()}, this also discovers non-public generator methods.
+ */
+ private static List<Method> findGeneratorMethods(Class<?> migrationTestClass) {
+ List<Method> methods = new ArrayList<>();
+ Set<String> visitedSignatures = new HashSet<>();
+ for (Class<?> clazz = migrationTestClass;
+ clazz != null && clazz != Object.class;
+ clazz = clazz.getSuperclass()) {
+ for (Method method : clazz.getDeclaredMethods()) {
+ if (method.isAnnotationPresent(MigrationTest.SnapshotsGenerator.class)
+ || method.isAnnotationPresent(
+ MigrationTest.ParameterizedSnapshotsGenerator.class)) {
+ String signature =
+ method.getName() + Arrays.toString(method.getParameterTypes());
+ if (visitedSignatures.add(signature)) {
+ methods.add(method);
+ }
+ }
+ }
+ }
+ return methods;
+ }
+
+ /**
+ * Finds a method by name and parameter types declared by the test class or any of its super
+ * classes. Unlike {@link Class#getMethod(String, Class[])}, this also discovers non-public
+ * methods, such as the {@code parameters} method referenced by {@link
+ * MigrationTest.ParameterizedSnapshotsGenerator}.
+ */
+ private static Method findDeclaredMethod(
+ Class<?> migrationTestClass, String name, Class<?>... parameterTypes)
+ throws NoSuchMethodException {
+ for (Class<?> clazz = migrationTestClass;
+ clazz != null && clazz != Object.class;
+ clazz = clazz.getSuperclass()) {
+ try {
+ return clazz.getDeclaredMethod(name, parameterTypes);
+ } catch (NoSuchMethodException ignored) {
+ // Continue searching up the class hierarchy.
+ }
+ }
+ throw new NoSuchMethodException(
+ "Could not find method "
+ + name
+ + Arrays.toString(parameterTypes)
+ + " in "
+ + migrationTestClass
+ + " or any of its super classes.");
+ }
+
private static void executeGenerateMethods(
Class<?> migrationTestClass, Method method, FlinkVersion version) throws Throwable {
method.setAccessible(true);
- List<TestRule> classRules = getRuleFields(migrationTestClass, ClassRule.class, null);
-
- executeWithRules(
- classRules,
- new Statement() {
- @Override
- public void evaluate() throws Throwable {
- Object migrationTest = createMigrationTest(migrationTestClass);
- List<TestRule> rules =
- getRuleFields(migrationTestClass, Rule.class, migrationTest);
- executeWithRules(
- rules,
- new Statement() {
- @Override
- public void evaluate() throws Throwable {
- method.invoke(migrationTest, version);
- }
- });
- }
- });
+ executeWithLifecycle(
+ migrationTestClass,
+ (migrationTest, invocation) ->
+ invocation.run(() -> invoke(method, migrationTest, version)));
}
private static void executeParameterizedGenerateMethods(
@@ -95,27 +137,68 @@ class SnapshotGeneratorUtils {
throws Throwable {
method.setAccessible(true);
parametersMethod.setAccessible(true);
- List<TestRule> classRules = getRuleFields(migrationTestClass, ClassRule.class, null);
+ executeWithLifecycle(
+ migrationTestClass,
+ (migrationTest, invocation) -> {
+ Collection<?> arguments =
+ (Collection<?>) invoke(parametersMethod, migrationTest, version);
+ for (Object argument : arguments) {
+ invocation.run(() -> invoke(method, migrationTest, argument));
+ }
+ });
+ }
+ /**
+ * Drives the migration test's lifecycle around the given {@code action}. Both the JUnit 4
+ * {@code @ClassRule}/{@code @Rule} {@link ExternalResource}s and the JUnit 5 lifecycle (see
+ * {@link JUnit5TestEnvironment}) are set up, so that migration tests using either framework
+ * have their resources initialized. The JUnit 5 steps are no-ops for tests that do not use any
+ * of the supported JUnit 5 constructs, leaving pure JUnit 4 tests unaffected.
+ *
+ * <p>Class-level setup ({@code @ClassRule}s, {@code beforeAll}) and the test instance are
+ * created once. The {@code action} then triggers one or more generator method invocations
+ * through the provided {@link Invocation}, and each invocation is wrapped in its own instance
+ * lifecycle ({@code @Rule}s, {@code beforeEach}/{@code afterEach}), so that a fresh set of
+ * per-test resources (such as a {@code MiniCluster}) is provided for every generated snapshot.
+ */
+ private static void executeWithLifecycle(Class<?> migrationTestClass, GenerateAction action)
+ throws Throwable {
+ List<TestRule> classRules = getRuleFields(migrationTestClass, ClassRule.class, null);
executeWithRules(
classRules,
new Statement() {
@Override
public void evaluate() throws Throwable {
- Object migrationTest = createMigrationTest(migrationTestClass);
- List<TestRule> rules =
- getRuleFields(migrationTestClass, Rule.class, migrationTest);
- Collection<?> arguments =
- (Collection<?>) parametersMethod.invoke(migrationTest, version);
- for (Object argument : arguments) {
- executeWithRules(
- rules,
- new Statement() {
- @Override
- public void evaluate() throws Throwable {
- method.invoke(migrationTest, argument);
- }
- });
+ JUnit5TestEnvironment environment =
+ new JUnit5TestEnvironment(migrationTestClass);
+ // beforeAll must run before the instance is created, as instance field
+ // initializers may reference resources set up by statically registered
+ // extensions.
+ environment.beforeAll();
+ try {
+ Object migrationTest = createMigrationTest(migrationTestClass);
+ environment.initializeTestInstance(migrationTest);
+ List<TestRule> rules =
+ getRuleFields(migrationTestClass, Rule.class, migrationTest);
+ Invocation invocation =
+ generatorCall ->
+ executeWithRules(
+ rules,
+ new Statement() {
+ @Override
+ public void evaluate() throws Throwable {
+ environment.beforeEach(migrationTest);
+ try {
+ generatorCall.run();
+ } finally {
+ environment.afterEach(
+ migrationTest);
+ }
+ }
+ });
+ action.execute(migrationTest, invocation);
+ } finally {
+ environment.afterAll();
}
}
});
@@ -126,6 +209,36 @@ class SnapshotGeneratorUtils {
new RunRules(statement, rules, Description.EMPTY).evaluate();
}
+ private static Object invoke(Method method, Object target, Object... args) throws Throwable {
+ try {
+ return method.invoke(target, args);
+ } catch (InvocationTargetException e) {
+ throw e.getTargetException();
+ }
+ }
+
+ /**
+ * Generates snapshots using a migration test instance. Every generator method invocation must
+ * be triggered through the given {@link Invocation} so that it runs within its own instance
+ * lifecycle.
+ */
+ @FunctionalInterface
+ private interface GenerateAction {
+ void execute(Object migrationTest, Invocation invocation) throws Throwable;
+ }
+
+ /** Runs a single generator method invocation within a fresh instance lifecycle. */
+ @FunctionalInterface
+ private interface Invocation {
+ void run(GeneratorCall generatorCall) throws Throwable;
+ }
+
+ /** A single generator method invocation. */
+ @FunctionalInterface
+ private interface GeneratorCall {
+ void run() throws Throwable;
+ }
+
private static List<TestRule> getRuleFields(
Class<?> migrationTestClass, Class<? extends Annotation> tagClass, Object migrationTest)
throws IllegalAccessException {
@@ -156,13 +269,11 @@ class SnapshotGeneratorUtils {
Constructor<?> constructor = constructors[0];
constructor.setAccessible(true);
- System.out.println("create test!!!");
// Check if we could find method labeled with @Parameterized.Parameters or @Parameters
for (Method method : migrationTestClass.getMethods()) {
if (Modifier.isStatic(method.getModifiers())
&& (method.isAnnotationPresent(Parameterized.Parameters.class)
|| method.isAnnotationPresent(Parameters.class))) {
- System.out.println("ffffffff!!!!");
Object argumentLists = method.invoke(null);
if (argumentLists instanceof Collection) {
return constructor.newInstance(