From 965af1bfe3a485748f7d84f729da08442bbadd64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Michael?= Date: Tue, 5 Dec 2023 11:54:14 +0100 Subject: [PATCH] Introduce @AutoClose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Björn Michael --- .../asciidoc/user-guide/writing-tests.adoc | 1 + .../java/org/junit/jupiter/api/AutoClose.java | 57 +++++++++++ .../engine/extension/AutoCloseExtension.java | 99 +++++++++++++++++++ .../extension/MutableExtensionRegistry.java | 1 + .../extension/ExtensionRegistryTests.java | 2 +- 5 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/AutoClose.java create mode 100644 junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/AutoCloseExtension.java diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index 4f8d59925fbf..2bcf6e4ad3da 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -45,6 +45,7 @@ in the `junit-jupiter-api` module. | `@ExtendWith` | Used to <>. Such annotations are _inherited_. | `@RegisterExtension` | Used to <> via fields. Such fields are _inherited_ unless they are _shadowed_. | `@TempDir` | Used to supply a <> via field injection or parameter injection in a lifecycle method or test method; located in the `org.junit.jupiter.api.io` package. +| @AutoClose | Indicates that a field in a JUnit 5 test class represents a resource that should be automatically closed after test execution. |=== WARNING: Some annotations may currently be _experimental_. Consult the table in diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AutoClose.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AutoClose.java new file mode 100644 index 000000000000..f20b4cf5bd43 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AutoClose.java @@ -0,0 +1,57 @@ +/* + * Copyright 2015-2023 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.jupiter.api; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.apiguardian.api.API; + +/** + * The {@code AutoClose} annotation is used to automatically close resources used in JUnit 5 tests. + * + *

+ * This annotation should be applied to fields within JUnit 5 test classes. It indicates that the annotated + * resource should be automatically closed after the test execution. The annotation targets + * {@link java.lang.annotation.ElementType#FIELD} elements, allowing it to be applied to instance variables. + *

+ * + *

+ * By default, the {@code AutoClose} annotation expects the annotated resource to provide a {@code close()} method + * that will be invoked for closing the resource. However, developers can customize the closing behavior by providing + * a different method name through the {@code value} attribute. For example, setting {@code value = "destroy"} will + * look for a method named {@code destroy()} to close the resource. + *

+ * + *

+ * The {@code AutoClose} annotation is retained at runtime, allowing it to be accessed and processed during test execution. + *

+ * + * @see java.lang.annotation.Retention + * @see java.lang.annotation.Target + * @since 5.11 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +@API(status = API.Status.EXPERIMENTAL, since = "5.11") +public @interface AutoClose { + + /** + * Specifies the name of the method to invoke for closing the resource. + * The default value is "close". + * + * @return the method name for closing the resource + */ + String value() default "close"; + +} diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/AutoCloseExtension.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/AutoCloseExtension.java new file mode 100644 index 000000000000..2b2e8da7eabd --- /dev/null +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/AutoCloseExtension.java @@ -0,0 +1,99 @@ +/* + * Copyright 2015-2023 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ +package org.junit.jupiter.engine.extension; + +import static org.junit.platform.commons.util.AnnotationUtils.findAnnotatedFields; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.function.Predicate; +import org.apiguardian.api.API; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.ExtensionConfigurationException; +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.platform.commons.logging.Logger; +import org.junit.platform.commons.logging.LoggerFactory; +import org.junit.platform.commons.util.ExceptionUtils; +import org.junit.platform.commons.util.ReflectionUtils; + +/** + * {@code AutoCloseExtension} is a JUnit Jupiter extension that closes resources if a field in a test class is annotated + * with {@link AutoClose @AutoClose}. + * + *

Consult the Javadoc for {@link AutoClose} for details on the contract. + * + * @since 5.11 + * @see AutoClose + * @see AutoCloseable + */ +@API(status = API.Status.EXPERIMENTAL, since = "5.11") +public class AutoCloseExtension implements AfterAllCallback, AfterEachCallback { + + private static final Logger logger = LoggerFactory.getLogger(AutoCloseExtension.class); + static final Namespace NAMESPACE = Namespace.create(AutoClose.class); + + @Override + public void afterAll(ExtensionContext context) { + Store contextStore = context.getStore(NAMESPACE); + Class testClass = context.getRequiredTestClass(); + + registerCloseables(contextStore, testClass, null); + } + + @Override + public void afterEach(ExtensionContext context) { + Store contextStore = context.getStore(NAMESPACE); + + for (Object instance : context.getRequiredTestInstances().getAllInstances()) { + registerCloseables(contextStore, instance.getClass(), instance); + } + } + + private void registerCloseables(Store contextStore, Class testClass, /* @Nullable */ Object testInstance) { + Predicate isStatic = testInstance == null ? ReflectionUtils::isStatic : ReflectionUtils::isNotStatic; + findAnnotatedFields(testClass, AutoClose.class, isStatic).forEach(field -> { + try { + contextStore.put(field, asCloseableResource(testInstance, field)); + } + catch (Throwable t) { + ExceptionUtils.throwAsUncheckedException(t); + } + }); + } + + private static Store.CloseableResource asCloseableResource(/* @Nullable */ Object testInstance, Field field) { + return () -> { + Object toBeClosed = ReflectionUtils.tryToReadFieldValue(field, testInstance).get(); + if (toBeClosed == null) { + logger.warn(() -> + "@AutoClose: Field " + getQualifiedFieldName(field) + " couldn't be closed because it was null."); + return; + } + getAndDestroy(field, toBeClosed); + }; + } + + private static void getAndDestroy(Field field, Object toBeClosed) { + String methodName = field.getAnnotation(AutoClose.class).value(); + Method destroyMethod = ReflectionUtils.findMethod(toBeClosed.getClass(), methodName).orElseThrow( + () -> new ExtensionConfigurationException("@AutoClose: Cannot resolve the destroy method " + + methodName + "() at " + getQualifiedFieldName(field) + ": " + field.getType().getSimpleName())); + ReflectionUtils.invokeMethod(destroyMethod, toBeClosed); + } + + private static String getQualifiedFieldName(Field field) { + return field.getDeclaringClass().getSimpleName() + "." + field.getName(); + } + +} diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/MutableExtensionRegistry.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/MutableExtensionRegistry.java index 0a1433505c7b..8bbd37f99f68 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/MutableExtensionRegistry.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/MutableExtensionRegistry.java @@ -73,6 +73,7 @@ public static MutableExtensionRegistry createRegistryWithDefaultExtensions(Jupit DEFAULT_STATELESS_EXTENSIONS.forEach(extensionRegistry::registerDefaultExtension); extensionRegistry.registerDefaultExtension(new TempDirectory(configuration)); + extensionRegistry.registerDefaultExtension(new AutoCloseExtension()); if (configuration.isExtensionAutoDetectionEnabled()) { registerAutoDetectedExtensions(extensionRegistry); diff --git a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/ExtensionRegistryTests.java b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/ExtensionRegistryTests.java index cbe91c6ea9f6..93e345326345 100644 --- a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/ExtensionRegistryTests.java +++ b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/ExtensionRegistryTests.java @@ -39,7 +39,7 @@ */ class ExtensionRegistryTests { - private static final int NUM_DEFAULT_EXTENSIONS = 6; + private static final int NUM_DEFAULT_EXTENSIONS = 7; private final JupiterConfiguration configuration = mock();