From 70d9fe8a01f71a744d5876c688ad9e4642503951 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Sat, 30 Dec 2023 15:30:41 +0100 Subject: [PATCH] =?UTF-8?q?Revise=20@=E2=81=A0AutoClose=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3367 Closes #3592 --- .../src/docs/asciidoc/link-attributes.adoc | 3 +- .../release-notes-5.11.0-M1.adoc | 4 +- .../asciidoc/user-guide/writing-tests.adoc | 72 ++- .../java/example/registration/WebClient.java | 7 +- .../src/test/java/example/AutoCloseDemo.java | 38 +- .../example/registration/WebServerDemo.java | 3 + .../java/org/junit/jupiter/api/AutoClose.java | 81 +++- .../junit/jupiter/api/AutoCloseExtension.java | 99 ----- .../org.junit.jupiter.api/module-info.java | 1 - .../engine/extension/AutoCloseExtension.java | 106 +++++ .../extension/MutableExtensionRegistry.java | 1 + .../engine/extension/AutoCloseTests.java | 419 ++++++++++++++---- .../extension/ExtensionRegistryTests.java | 2 +- .../junit-jupiter-api.expected.txt | 1 - 14 files changed, 568 insertions(+), 269 deletions(-) delete mode 100644 junit-jupiter-api/src/main/java/org/junit/jupiter/api/AutoCloseExtension.java create mode 100644 junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/AutoCloseExtension.java diff --git a/documentation/src/docs/asciidoc/link-attributes.adoc b/documentation/src/docs/asciidoc/link-attributes.adoc index 5822bffb57aa..08d9a793ad50 100644 --- a/documentation/src/docs/asciidoc/link-attributes.adoc +++ b/documentation/src/docs/asciidoc/link-attributes.adoc @@ -68,6 +68,7 @@ endif::[] :api-package: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/package-summary.html[org.junit.jupiter.api] :Assertions: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/Assertions.html[org.junit.jupiter.api.Assertions] :Assumptions: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/Assumptions.html[org.junit.jupiter.api.Assumptions] +:AutoClose: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/AutoClose.html[@AutoClose] :ClassOrderer_ClassName: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/ClassOrderer.ClassName.html[ClassOrderer.ClassName] :ClassOrderer_DisplayName: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/ClassOrderer.DisplayName.html[ClassOrderer.DisplayName] :ClassOrderer_OrderAnnotation: {javadoc-root}/org.junit.jupiter.api/org/junit/jupiter/api/ClassOrderer.OrderAnnotation.html[ClassOrderer.OrderAnnotation] @@ -153,7 +154,7 @@ endif::[] // Jupiter Engine :junit-jupiter-engine: {javadoc-root}/org.junit.jupiter.engine/org/junit/jupiter/engine/package-summary.html[junit-jupiter-engine] // Jupiter Extension Implementations -:AutoCloseExtension: {current-branch}/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AutoCloseExtension.java[AutoCloseExtension] +:AutoCloseExtension: {current-branch}/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/AutoCloseExtension.java[AutoCloseExtension] :DisabledCondition: {current-branch}/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/DisabledCondition.java[DisabledCondition] :RepetitionExtension: {current-branch}/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/RepetitionExtension.java[RepetitionExtension] :TempDirectory: {current-branch}/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TempDirectory.java[TempDirectory] diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.0-M1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.0-M1.adoc index 8a07333dea11..5e6ade320218 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.0-M1.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.0-M1.adoc @@ -45,8 +45,8 @@ repository on GitHub. ==== New Features and Improvements -* The new `@AutoClose` annotation can be applied to fields within tests to automatically - close the annotated resource after test execution. See +* New `@AutoClose` annotation that can be applied to fields within tests to automatically + close the annotated resource after test execution. See the <<../user-guide/index.adoc#writing-tests-built-in-extensions-AutoClose, User Guide>> for details. diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index 809c0b4d5238..15c9bd87cb19 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -41,11 +41,11 @@ in the `junit-jupiter-api` module. | `@Nested` | Denotes that the annotated class is a non-static <>. On Java 8 through Java 15, `@BeforeAll` and `@AfterAll` methods cannot be used directly in a `@Nested` test class unless the "per-class" <> is used. Beginning with Java 16, `@BeforeAll` and `@AfterAll` methods can be declared as `static` in a `@Nested` test class with either test instance lifecycle mode. Such annotations are not _inherited_. | `@Tag` | Used to declare <>, either at the class or method level; analogous to test groups in TestNG or Categories in JUnit 4. Such annotations are _inherited_ at the class level but not at the method level. | `@Disabled` | Used to <> a test class or test method; analogous to JUnit 4's `@Ignore`. Such annotations are not _inherited_. +| `@AutoClose` | Denotes that the annotated field represents a resource that will be <> after test execution. | `@Timeout` | Used to fail a test, test factory, test template, or lifecycle method if its execution exceeds a given duration. Such annotations are _inherited_. +| `@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. | `@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` | Denotes that the annotated field represents a resource that should be automatically closed after test execution. |=== WARNING: Some annotations may currently be _experimental_. Consult the table in @@ -2573,12 +2573,12 @@ include::{testDir}/example/SharedResourcesDemo.java[tags=user_guide] === Built-in Extensions While the JUnit team encourages reusable extensions to be packaged and maintained in -separate libraries, the JUnit Jupiter API artifact includes a few user-facing extension -implementations that are considered so generally useful that users shouldn't have to add -another dependency. +separate libraries, JUnit Jupiter includes a few user-facing extension implementations +that are considered so generally useful that users shouldn't have to add another +dependency. [[writing-tests-built-in-extensions-TempDirectory]] -==== The TempDirectory Extension +==== The @TempDir Extension The built-in `{TempDirectory}` extension is used to create and clean up a temporary directory for an individual test or all tests in a test class. It is registered by @@ -2712,24 +2712,48 @@ parameter, if present 3. Otherwise, `org.junit.jupiter.api.io.TempDirFactory$Standard` will be used. [[writing-tests-built-in-extensions-AutoClose]] -==== The AutoClose Extension - -The built-in `{AutoCloseExtension}` is used to automatically close resources used in -tests. Therefore, the `@AutoClose` annotation is applied to fields within the -test class to indicate that the annotated resource should be automatically closed after -the test execution. - -By default, the `@AutoClose` annotation expects the annotated resource to provide -a `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 -`value` attribute. For example, setting `value = "shutdown"` will look -for a method named `shutdown()` to close the resource. - -For example, the following test declares a database connection field annotated with -`@AutoClose` that is automatically closed afterward. - -[source,java,indent=0] -.A test class using @AutoClose annotation to close used resource +==== The @AutoClose Extension + +The built-in `{AutoCloseExtension}` automatically closes resources associated with fields. +It is registered by default. To use it, annotate a field in a test class with +`{AutoClose}`. + +`@AutoClose` fields may be either `static` or non-static. If the value of an `@AutoClose` +field is `null` when it is evaluated the field will be ignored, but a warning message will +be logged to inform you. + +By default, `@AutoClose` expects the value of the annotated field to implement a `close()` +method that will be invoked to close the resource. However, developers can customize the +name of the close method via the `value` attribute. For example, `@AutoClose("shutdown")` +instructs JUnit to look for a `shutdown()` method to close the resource. + +`@AutoClose` fields are inherited from superclasses as long as they are not hidden. +Furthermore, `@AutoClose` fields from subclasses will be closed before `@AutoClose` fields +in superclasses. + +When multiple `@AutoClose` fields exist within a given test class, the order in which the +resources are closed depends on an algorithm that is deterministic but intentionally +nonobvious. This ensures that subsequent runs of a test suite close resources in the same +order, thereby allowing for repeatable builds. + +The extension that closes `@AutoClose` fields implements the `AfterAllCallback` and +`TestInstancePreDestroyCallback` extension APIs. Consequently, a `static` `@AutoClose` +field will be closed after all tests in the current test class have completed, effectively +after `@AfterAll` methods have executed for the test class. A non-static `@AutoClose` +field will be closed before the current test class instance is destroyed. Specifically, if +the test class is configured with `@TestInstance(Lifecycle.PER_METHOD)` semantics, a +non-static `@AutoClose` field will be closed after the execution of each test method, test +factory method, or test template method. However, if the test class is configured with +`@TestInstance(Lifecycle.PER_CLASS)` semantics, a non-static `@AutoClose` field will not +be closed until the current test class instance is no longer needed, which means after +`@AfterAll` methods and after all `static` `@AutoClose` fields have been closed. + +The following example demonstrates how to annotate an instance field with `@AutoClose` so +that the resource is automatically closed after test execution. Note that `WebClient` +implements `java.lang.AutoCloseable` which defines a `close()` method. + +[source,java,indent=0] +.A test class using `@AutoClose` to close a resource ---- include::{testDir}/example/AutoCloseDemo.java[tags=user_guide_example] ---- diff --git a/documentation/src/main/java/example/registration/WebClient.java b/documentation/src/main/java/example/registration/WebClient.java index ee04ced7cf59..857259d85895 100644 --- a/documentation/src/main/java/example/registration/WebClient.java +++ b/documentation/src/main/java/example/registration/WebClient.java @@ -10,10 +10,15 @@ package example.registration; -public class WebClient { +public class WebClient implements AutoCloseable { public WebResponse get(String string) { return new WebResponse(); } + @Override + public void close() { + /* no-op for demo */ + } + } diff --git a/documentation/src/test/java/example/AutoCloseDemo.java b/documentation/src/test/java/example/AutoCloseDemo.java index 1f89d9fd30f8..038352e4e8ae 100644 --- a/documentation/src/test/java/example/AutoCloseDemo.java +++ b/documentation/src/test/java/example/AutoCloseDemo.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2023 the original author or authors. + * Copyright 2015-2024 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 @@ -10,40 +10,30 @@ package example; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.ResultSet; -import java.sql.SQLException; +import example.registration.WebClient; import org.junit.jupiter.api.AutoClose; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -@Disabled // tag::user_guide_example[] class AutoCloseDemo { + // WebClient implements AutoCloseable @AutoClose - Connection connection = getJdbcConnection("jdbc:mysql://localhost/testdb"); + WebClient webClient = new WebClient(); - @Test - void usersTableHasEntries() throws SQLException { - ResultSet resultSet = connection.createStatement().executeQuery("SELECT * FROM users"); - - assertTrue(resultSet.next()); - } + String serverUrl = // specify server URL ... + // end::user_guide_example[] + "https://localhost"; + // tag::user_guide_example[] - // ... - // end::user_guide_example[] - private static Connection getJdbcConnection(String url) { - try { - return DriverManager.getConnection(url); - } - catch (SQLException ex) { - throw new RuntimeException(ex); - } + @Test + void getProductList() { + // Use WebClient to connect to web server and verify response + assertEquals(200, webClient.get(serverUrl + "/products").getResponseStatus()); } } +// end::user_guide_example[] diff --git a/documentation/src/test/java/example/registration/WebServerDemo.java b/documentation/src/test/java/example/registration/WebServerDemo.java index be8f41a8ba5c..bd0f32587fdc 100644 --- a/documentation/src/test/java/example/registration/WebServerDemo.java +++ b/documentation/src/test/java/example/registration/WebServerDemo.java @@ -31,6 +31,9 @@ class WebServerDemo { @Test void getProductList() { + // end::user_guide[] + @SuppressWarnings("resource") + // tag::user_guide[] WebClient webClient = new WebClient(); String serverUrl = server.getServerUrl(); // Use WebClient to connect to web server using serverUrl and verify response 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 index 575b60d8c0fd..ea858baceee9 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2015-2023 the original author or authors. + * Copyright 2015-2024 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 @@ -10,6 +10,8 @@ package org.junit.jupiter.api; +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -17,42 +19,75 @@ import java.lang.annotation.Target; import org.apiguardian.api.API; -import org.junit.jupiter.api.extension.ExtendWith; /** - * The {@code AutoClose} annotation is used to automatically close resources - * used in tests. + * {@code @AutoClose} is used to indicate that an annotated field will be + * automatically closed after test execution. + * + *

{@code @AutoClose} fields may be either {@code static} or non-static. If + * the value of an {@code @AutoClose} field is {@code null} when it is evaluated + * the field will be ignored, but a warning message will be logged to inform you. + * + *

By default, {@code @AutoClose} expects the value of the annotated field to + * implement a {@code close()} method that will be invoked to close the resource. + * However, developers can customize the name of the {@code close} method via the + * {@link #value} attribute. For example, {@code @AutoClose("shutdown")} instructs + * JUnit to look for a {@code shutdown()} method to close the resource. + * + *

{@code @AutoClose} may be used as a meta-annotation in order to create a + * custom composed annotation that inherits the semantics of + * {@code @AutoClose}. + * + *

Inheritance

+ * + *

{@code @AutoClose} fields are inherited from superclasses as long as they + * are not hidden. Furthermore, {@code @AutoClose} fields from subclasses + * will be closed before {@code @AutoClose} fields in superclasses. + * + *

Evaluation Order

+ * + *

When multiple {@code @AutoClose} fields exist within a given test class, + * the order in which the resources are closed depends on an algorithm that is + * deterministic but intentionally nonobvious. This ensures that subsequent runs + * of a test suite close resources in the same order, thereby allowing for + * repeatable builds. * - *

This annotation should be applied to fields within test classes. It - * indicates that the annotated resource should be automatically closed after - * the test execution. + *

Scope and Lifecycle

* - *

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 {@link #value} attribute. For - * example, setting {@code value = "shutdown"} will look for a method named - * {@code shutdown()} to close the resource. When multiple annotated resources - * exist the order of closing them is unspecified. + *

The extension that closes {@code @AutoClose} fields implements the + * {@link org.junit.jupiter.api.extension.AfterAllCallback AfterAllCallback} and + * {@link org.junit.jupiter.api.extension.TestInstancePreDestroyCallback + * TestInstancePreDestroyCallback} extension APIs. Consequently, a {@code static} + * {@code @AutoClose} field will be closed after all tests in the current test + * class have completed, effectively after {@code @AfterAll} methods have executed + * for the test class. A non-static {@code @AutoClose} field will be closed before + * the current test class instance is destroyed. Specifically, if the test class + * is configured with + * {@link TestInstance.Lifecycle#PER_METHOD @TestInstance(Lifecycle.PER_METHOD)} + * semantics, a non-static {@code @AutoClose} field will be closed after the + * execution of each test method, test factory method, or test template method. + * However, if the test class is configured with + * {@link TestInstance.Lifecycle#PER_CLASS @TestInstance(Lifecycle.PER_CLASS)} + * semantics, a non-static {@code @AutoClose} field will not be closed until the + * current test class instance is no longer needed, which means after + * {@code @AfterAll} methods and after all {@code static} {@code @AutoClose} fields + * have been closed. * * @since 5.11 - * @see java.lang.annotation.Retention - * @see java.lang.annotation.Target */ -@Target(ElementType.FIELD) +@Target({ ElementType.ANNOTATION_TYPE, ElementType.FIELD }) @Retention(RetentionPolicy.RUNTIME) @Documented -@ExtendWith(AutoCloseExtension.class) -@API(status = API.Status.EXPERIMENTAL, since = "5.11") -@SuppressWarnings("exports") +@API(status = EXPERIMENTAL, since = "5.11") public @interface AutoClose { /** - * Specifies the name of the method to invoke for closing the resource. + * Specify the name of the method to invoke to close the resource. * - *

The default value is {@code close}. + *

The default value is {@code "close"} which works with any type that + * implements {@link AutoCloseable} or has a {@code close()} method. * - * @return the method name for closing the resource + * @return the name of the method to invoke to close the resource */ String value() default "close"; diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AutoCloseExtension.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AutoCloseExtension.java deleted file mode 100644 index 52bd5cd86b11..000000000000 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AutoCloseExtension.java +++ /dev/null @@ -1,99 +0,0 @@ -/* - * 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 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.junit.jupiter.api.extension.AfterAllCallback; -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.jupiter.api.extension.TestInstancePreDestroyCallback; -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 @AutoClose} for details on the - * contract. - * - * @since 5.11 - * @see AutoClose - */ -class AutoCloseExtension implements AfterAllCallback, TestInstancePreDestroyCallback { - - private static final Logger logger = LoggerFactory.getLogger(AutoCloseExtension.class); - private 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 preDestroyTestInstance(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, Object testInstance) { - Predicate predicate = testInstance == null ? ReflectionUtils::isStatic : ReflectionUtils::isNotStatic; - findAnnotatedFields(testClass, AutoClose.class, predicate).forEach(field -> { - try { - contextStore.put(field, asCloseableResource(testInstance, field)); - } - catch (Throwable t) { - throw ExceptionUtils.throwAsUncheckedException(t); - } - }); - } - - private static Store.CloseableResource asCloseableResource(Object testInstance, Field field) { - return () -> { - Object toBeClosed = ReflectionUtils.tryToReadFieldValue(field, testInstance).get(); - if (toBeClosed == null) { - logger.warn(() -> "@AutoClose couldn't close object for field " + getQualifiedFieldName(field) - + " because it was null."); - return; - } - invokeCloseMethod(field, toBeClosed); - }; - } - - private static void invokeCloseMethod(Field field, Object toBeClosed) { - String methodName = field.getAnnotation(AutoClose.class).value(); - Method closeMethod = ReflectionUtils.findMethod(toBeClosed.getClass(), methodName).orElseThrow( - () -> new ExtensionConfigurationException( - "@AutoClose failed to close object for field " + getQualifiedFieldName(field) + " because the " - + methodName + "() method could not be " + "resolved.")); - ReflectionUtils.invokeMethod(closeMethod, toBeClosed); - } - - private static String getQualifiedFieldName(Field field) { - return field.getDeclaringClass().getName() + "." + field.getName(); - } - -} diff --git a/junit-jupiter-api/src/module/org.junit.jupiter.api/module-info.java b/junit-jupiter-api/src/module/org.junit.jupiter.api/module-info.java index e7a7668b7873..b6856c78a11e 100644 --- a/junit-jupiter-api/src/module/org.junit.jupiter.api/module-info.java +++ b/junit-jupiter-api/src/module/org.junit.jupiter.api/module-info.java @@ -23,6 +23,5 @@ exports org.junit.jupiter.api.io; exports org.junit.jupiter.api.parallel; - opens org.junit.jupiter.api to org.junit.platform.commons; opens org.junit.jupiter.api.condition to org.junit.platform.commons; } 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..fdff834cf53c --- /dev/null +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/AutoCloseExtension.java @@ -0,0 +1,106 @@ +/* + * Copyright 2015-2024 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.ReflectionUtils.HierarchyTraversalMode.BOTTOM_UP; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.function.Predicate; + +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.ExtensionConfigurationException; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.TestInstancePreDestroyCallback; +import org.junit.platform.commons.logging.Logger; +import org.junit.platform.commons.logging.LoggerFactory; +import org.junit.platform.commons.util.AnnotationUtils; +import org.junit.platform.commons.util.ExceptionUtils; +import org.junit.platform.commons.util.Preconditions; +import org.junit.platform.commons.util.ReflectionUtils; +import org.junit.platform.commons.util.StringUtils; + +/** + * {@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 {@code @AutoClose} for details on the contract. + * + * @since 5.11 + * @see AutoClose + */ +class AutoCloseExtension implements TestInstancePreDestroyCallback, AfterAllCallback { + + private static final Logger logger = LoggerFactory.getLogger(AutoCloseExtension.class); + + @Override + public void preDestroyTestInstance(ExtensionContext context) { + TestInstancePreDestroyCallback.preDestroyTestInstances(context, + testInstance -> closeFields(testInstance.getClass(), testInstance)); + } + + @Override + public void afterAll(ExtensionContext context) { + closeFields(context.getRequiredTestClass(), null); + } + + private static void closeFields(Class testClass, Object testInstance) { + Predicate predicate = (testInstance == null ? ReflectionUtils::isStatic : ReflectionUtils::isNotStatic); + AnnotationUtils.findAnnotatedFields(testClass, AutoClose.class, predicate, BOTTOM_UP).forEach(field -> { + try { + closeField(field, testInstance); + } + catch (Throwable t) { + throw ExceptionUtils.throwAsUncheckedException(t); + } + }); + } + + private static void closeField(Field field, Object testInstance) throws Exception { + String methodName = AnnotationUtils.findAnnotation(field, AutoClose.class).get().value(); + Class fieldType = field.getType(); + + checkCondition(StringUtils.isNotBlank(methodName), "@AutoClose on field %s must specify a method name.", field); + checkCondition(!fieldType.isPrimitive(), "@AutoClose is not supported on primitive field %s.", field); + checkCondition(!fieldType.isArray(), "@AutoClose is not supported on array field %s.", field); + + Object fieldValue = ReflectionUtils.tryToReadFieldValue(field, testInstance).get(); + if (fieldValue == null) { + logger.warn(() -> String.format("Cannot @AutoClose field %s because it is null.", getQualifiedName(field))); + } + else { + invokeCloseMethod(field, fieldValue, methodName.trim()); + } + } + + private static void invokeCloseMethod(Field field, Object target, String methodName) { + Class targetType = target.getClass(); + Method closeMethod = ReflectionUtils.findMethod(targetType, methodName).orElseThrow( + () -> new ExtensionConfigurationException( + String.format("Cannot @AutoClose field %s because %s does not define method %s().", + getQualifiedName(field), targetType.getName(), methodName))); + ReflectionUtils.invokeMethod(closeMethod, target); + } + + private static void checkCondition(boolean condition, String messageFormat, Field field) { + Preconditions.condition(condition, () -> String.format(messageFormat, getQualifiedName(field))); + } + + private static String getQualifiedName(Field field) { + String typeName = field.getDeclaringClass().getCanonicalName(); + if (typeName == null) { + typeName = field.getDeclaringClass().getTypeName(); + } + return typeName + "." + 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 129aac880be7..3791f83b8c7b 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 @@ -49,6 +49,7 @@ public class MutableExtensionRegistry implements ExtensionRegistry, ExtensionReg private static final List DEFAULT_STATELESS_EXTENSIONS = Collections.unmodifiableList(Arrays.asList(// new DisabledCondition(), // + new AutoCloseExtension(), // new TimeoutExtension(), // new RepeatedTestExtension(), // new TestInfoParameterResolver(), // diff --git a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/AutoCloseTests.java b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/AutoCloseTests.java index 47eef593b92a..8f54d5d5f155 100644 --- a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/AutoCloseTests.java +++ b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/AutoCloseTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2023 the original author or authors. + * Copyright 2015-2024 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 @@ -10,33 +10,35 @@ package org.junit.jupiter.engine.extension; -import static java.util.Arrays.asList; +import static java.lang.StackWalker.Option.RETAIN_CLASS_REFERENCE; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; +import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD; import static org.junit.platform.testkit.engine.EventConditions.finishedWithFailure; -import static org.junit.platform.testkit.engine.TestExecutionResultConditions.cause; import static org.junit.platform.testkit.engine.TestExecutionResultConditions.message; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.List; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AutoClose; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.fixtures.TrackLogRecords; import org.junit.jupiter.engine.AbstractJupiterTestEngineTests; +import org.junit.platform.commons.logging.LogRecordListener; import org.junit.platform.testkit.engine.Events; /** - * Integration tests for the behavior of the - * {@link org.junit.jupiter.api.AutoCloseExtension} to release resources after - * test execution. + * Integration tests for {@link AutoClose @AutoClose} and the {@link AutoCloseExtension}. * * @since 5.11 */ @@ -45,180 +47,413 @@ class AutoCloseTests extends AbstractJupiterTestEngineTests { private static final List recorder = new ArrayList<>(); @BeforeEach - void resetRecorder() { + @AfterEach + void resetTracking() { + InstancePerClassTestCase.closed = false; recorder.clear(); } @Test - void fieldsAreProperlyClosed() { - Events tests = executeTestsForClass(AutoCloseTestCase.class).testEvents(); - tests.assertStatistics(stats -> stats.succeeded(2)); - // @formatter:off - assertThat(recorder).containsExactly( - "afterEach-close()", "afterEach-run()", - "afterEach-close()", "afterEach-run()", - "afterAll-close()"); - // @formatter:on + void blankCloseMethodName() { + Class testClass = BlankCloseMethodNameTestCase.class; + String msg = String.format("@AutoClose on field %s.field must specify a method name.", + testClass.getCanonicalName()); + Events tests = executeTestsForClass(testClass).testEvents(); + assertFailingWithMessage(tests, msg); } @Test - void noCloseMethod() { - String msg = "@AutoClose failed to close object for field " - + "org.junit.jupiter.engine.extension.AutoCloseTests$AutoCloseNoCloseMethodFailingTestCase.field " - + "because the close() method could not be resolved."; + void primitiveTypeCannotBeClosed() { + Class testClass = PrimitiveFieldTestCase.class; + String msg = String.format("@AutoClose is not supported on primitive field %s.x.", + testClass.getCanonicalName()); + Events tests = executeTestsForClass(testClass).testEvents(); + assertFailingWithMessage(tests, msg); + } - Events tests = executeTestsForClass(AutoCloseNoCloseMethodFailingTestCase.class).testEvents(); + @Test + void arrayCannotBeClosed() { + Class testClass = ArrayFieldTestCase.class; + String msg = String.format("@AutoClose is not supported on array field %s.x.", testClass.getCanonicalName()); + Events tests = executeTestsForClass(testClass).testEvents(); assertFailingWithMessage(tests, msg); } @Test - void noShutdownMethod() { - String msg = "@AutoClose failed to close object for field " - + "org.junit.jupiter.engine.extension.AutoCloseTests$AutoCloseNoShutdownMethodFailingTestCase.field " - + "because the shutdown() method could not be resolved."; + void nullCannotBeClosed(@TrackLogRecords LogRecordListener listener) { + Class testClass = NullCloseableFieldTestCase.class; + String msg = String.format("Cannot @AutoClose field %s.field because it is null.", + testClass.getCanonicalName()); + Events tests = executeTestsForClass(testClass).testEvents(); + tests.assertStatistics(stats -> stats.succeeded(1).failed(0)); + assertThat(listener.stream(Level.WARNING)).map(LogRecord::getMessage).anyMatch(msg::equals); + } - Events tests = executeTestsForClass(AutoCloseNoShutdownMethodFailingTestCase.class).testEvents(); - assertFailingWithMessage(tests, msg); + @Test + void noCloseMethod() { + assertMissingCloseMethod(NoCloseMethodTestCase.class, "close"); + } + + @Test + void noShutdownMethod() { + assertMissingCloseMethod(NoShutdownMethodTestCase.class, "shutdown"); } + /** + * Tests prerequisites for the {@link AutoCloseSpy} implementation. + */ @Test void spyPermitsOnlyASingleAction() { - AutoCloseSpy spy = new AutoCloseSpy(""); + AutoCloseSpy spy = new AutoCloseSpy("preconditions"); spy.close(); - assertThrows(IllegalStateException.class, spy::close); - assertThrows(IllegalStateException.class, spy::run); - assertEquals(asList("close()"), recorder); + assertThatIllegalStateException().isThrownBy(spy::run).withMessage("Already closed via close()"); + assertThatIllegalStateException().isThrownBy(spy::close).withMessage("Already closed via close()"); + assertThat(recorder).containsExactly("AutoCloseTests.preconditions.close()"); + } + + @Test + void fieldsAreProperlyClosedWithInstancePerMethodTestClass() { + Events tests = executeTestsForClass(InstancePerMethodTestCase.class).testEvents(); + tests.assertStatistics(stats -> stats.succeeded(2)); + assertThat(recorder).containsExactly(// + // test1() + "InstancePerMethodTestCase.runnable.run()", // + "InstancePerMethodTestCase.closable.close()", // + // test2() + "InstancePerMethodTestCase.runnable.run()", // + "InstancePerMethodTestCase.closable.close()", // + // Class-level cleanup + "InstancePerMethodTestCase.staticClosable.close()"// + ); + } + + @Test + void fieldsAreProperlyClosedWithInstancePerClassTestClass() { + Events tests = executeTestsForClass(InstancePerClassTestCase.class).testEvents(); + tests.assertStatistics(stats -> stats.succeeded(2)); + assertThat(InstancePerClassTestCase.closed).isTrue(); + } + + @Test + void fieldsAreProperlyClosedWithNestedTestClassesWithInstancePerMethod() { + Events tests = executeTestsForClass(InstancePerMethodEnclosingTestCase.NestedTestCase.class).testEvents(); + tests.assertStatistics(stats -> stats.succeeded(1)); + assertThat(recorder).containsExactly(// + "NestedTestCase.nestedClosable.close()", // + "InstancePerMethodEnclosingTestCase.enclosingClosable.close()", // + "NestedTestCase.nestedStaticClosable.close()", // + "InstancePerMethodEnclosingTestCase.enclosingStaticClosable.close()"// + ); + + // Reset tracking + resetTracking(); + + tests = executeTestsForClass(InstancePerMethodEnclosingTestCase.class).testEvents(); + tests.assertStatistics(stats -> stats.succeeded(2)); + assertThat(recorder).containsExactly(// + "InstancePerMethodEnclosingTestCase.enclosingClosable.close()", // + "NestedTestCase.nestedClosable.close()", // + "InstancePerMethodEnclosingTestCase.enclosingClosable.close()", // + "NestedTestCase.nestedStaticClosable.close()", // + "InstancePerMethodEnclosingTestCase.enclosingStaticClosable.close()" // + ); + } + + @Test + void fieldsAreProperlyClosedWithNestedTestClassesWithInstancePerClass() { + // With test instance lifecycle "per class" mode, we actually expect the + // same behavior for the closing of all fields when the nested test class + // is run standalone AND when it's run along with its enclosing class. + String[] expected = { // + "NestedTestCase.nestedStaticClosable.close()", // + "NestedTestCase.nestedClosable.close()", // + "InstancePerClassEnclosingTestCase.enclosingStaticClosable.close()", // + "InstancePerClassEnclosingTestCase.enclosingClosable.close()" // + }; + + Events tests = executeTestsForClass(InstancePerClassEnclosingTestCase.NestedTestCase.class).testEvents(); + tests.assertStatistics(stats -> stats.succeeded(1)); + assertThat(recorder).containsExactly(expected); + + // Reset tracking + resetTracking(); + + tests = executeTestsForClass(InstancePerClassEnclosingTestCase.class).testEvents(); + tests.assertStatistics(stats -> stats.succeeded(2)); + assertThat(recorder).containsExactly(expected); } @Test - void instancePerClass() { - Events tests = executeTestsForClass(AutoCloseInstancePerClassTestCase.class).testEvents(); + void fieldsAreProperlyClosedWithinTestClassHierarchy() { + Events tests = executeTestsForClass(SuperTestCase.class).testEvents(); + tests.assertStatistics(stats -> stats.succeeded(1)); + assertThat(recorder).containsExactly(// + // superTest() + "SuperTestCase.superClosable.close()", // + // Class-level cleanup + "SuperTestCase.superStaticClosable.close()" // + ); + + // Reset tracking + resetTracking(); + + tests = executeTestsForClass(SubTestCase.class).testEvents(); tests.assertStatistics(stats -> stats.succeeded(2)); + assertThat(recorder).containsExactly(// + // superTest() + "SubTestCase.subClosable.close()", // + "SuperTestCase.superClosable.close()", // + // subTest() + "SubTestCase.subClosable.close()", // + "SuperTestCase.superClosable.close()", // + // Class-level cleanup in subclass + "SubTestCase.subStaticClosable.close()", // + // Class-level cleanup in superclass + "SuperTestCase.superStaticClosable.close()" // + ); + } + + private static void assertFailingWithMessage(Events testEvents, String msg) { + testEvents// + .assertStatistics(stats -> stats.failed(1))// + .assertThatEvents().haveExactly(1, finishedWithFailure(message(msg))); + } + + private void assertMissingCloseMethod(Class testClass, String methodName) { + String msg = String.format("Cannot @AutoClose field %s.field because %s does not define method %s().", + testClass.getCanonicalName(), String.class.getName(), methodName); + Events tests = executeTestsForClass(testClass).testEvents(); + assertFailingWithMessage(tests, msg); + } + + interface TestInterface { + + @Test + default void test() { + } + } + + static class BlankCloseMethodNameTestCase implements TestInterface { + + @AutoClose("") + final String field = "blank"; + } + + static class PrimitiveFieldTestCase implements TestInterface { + + @AutoClose + final int x = 0; + } + + static class ArrayFieldTestCase implements TestInterface { + + @AutoClose + final int[] x = {}; + } + + static class NullCloseableFieldTestCase implements TestInterface { + + @AutoClose + final AutoCloseable field = null; + } + + static class NoCloseMethodTestCase implements TestInterface { + + @AutoClose + private final String field = ""; + } + + @Retention(RetentionPolicy.RUNTIME) + @AutoClose("shutdown") + @interface AutoShutdown { } - private static void assertFailingWithMessage(Events testEvent, String msg) { - testEvent.assertStatistics(stats -> stats.failed(1)).assertThatEvents().haveExactly(1, - finishedWithFailure(cause(message(actual -> actual.contains(msg))))); + static class NoShutdownMethodTestCase implements TestInterface { + + @AutoShutdown + private final String field = ""; } - static class AutoCloseTestCase { + @TestInstance(PER_METHOD) + static class InstancePerMethodTestCase { @AutoClose private static AutoCloseable staticClosable; + @AutoClose - private static AutoCloseable nullStatic; + private static final AutoCloseable nullStatic = null; @AutoClose - private final AutoCloseable closable = new AutoCloseSpy("afterEach-"); - @AutoClose("run") - private final Runnable runnable = new AutoCloseSpy("afterEach-"); + private final AutoCloseable closable = new AutoCloseSpy("closable"); + + @AutoClose(" run ") // intentionally contains extra whitespace. + private final Runnable runnable = new AutoCloseSpy("runnable"); + @AutoClose - private AutoCloseable nullField; + private final AutoCloseable nullField = null; @BeforeAll static void setup() { - staticClosable = new AutoCloseSpy("afterAll-"); + staticClosable = new AutoCloseSpy("staticClosable"); } @Test - void justPass() { - assertFields(); + void test1() { } @Test - void anotherPass() { - assertFields(); + void test2() { } + } - private void assertFields() { - assertNotNull(staticClosable); - assertNull(nullStatic); + @TestInstance(PER_CLASS) + static class InstancePerClassTestCase { + + static boolean closed = false; + + @AutoClose + final AutoCloseable field = () -> closed = true; - assertNotNull(closable); - assertNotNull(runnable); - assertNull(nullField); + @Test + void test1() { + assertThat(closed).isFalse(); } + @Test + void test2() { + assertThat(closed).isFalse(); + } } - static class AutoCloseNoCloseMethodFailingTestCase { + @TestInstance(PER_METHOD) + static class InstancePerMethodEnclosingTestCase implements TestInterface { @AutoClose - private final String field = "nothing to close()"; + static AutoCloseSpy enclosingStaticClosable; - @Test - void alwaysPass() { - assertNotNull(field); + @AutoClose + final AutoCloseable enclosingClosable = new AutoCloseSpy("enclosingClosable"); + + @BeforeAll + static void setup() { + enclosingStaticClosable = new AutoCloseSpy("enclosingStaticClosable"); } + @Nested + @TestInstance(PER_METHOD) + class NestedTestCase implements TestInterface { + + @AutoClose + static AutoCloseSpy nestedStaticClosable; + + @AutoClose + final AutoCloseable nestedClosable = new AutoCloseSpy("nestedClosable"); + + @BeforeAll + static void setup() { + nestedStaticClosable = new AutoCloseSpy("nestedStaticClosable"); + } + } } - static class AutoCloseNoShutdownMethodFailingTestCase { + @TestInstance(PER_CLASS) + static class InstancePerClassEnclosingTestCase implements TestInterface { - @AutoClose("shutdown") - private final String field = "nothing to shutdown()"; + @AutoClose + static AutoCloseSpy enclosingStaticClosable; - @Test - void alwaysPass() { - assertNotNull(field); + @AutoClose + final AutoCloseable enclosingClosable = new AutoCloseSpy("enclosingClosable"); + + @BeforeAll + static void setup() { + enclosingStaticClosable = new AutoCloseSpy("enclosingStaticClosable"); } + @Nested + @TestInstance(PER_CLASS) + class NestedTestCase implements TestInterface { + + @AutoClose + static AutoCloseSpy nestedStaticClosable; + + @AutoClose + final AutoCloseable nestedClosable = new AutoCloseSpy("nestedClosable"); + + @BeforeAll + static void setup() { + nestedStaticClosable = new AutoCloseSpy("nestedStaticClosable"); + } + } } - @TestInstance(PER_CLASS) - static class AutoCloseInstancePerClassTestCase { + static class SuperTestCase { - static boolean closed; + @AutoClose + static AutoCloseable superStaticClosable; @AutoClose - AutoCloseable field = () -> closed = true; + final AutoCloseable superClosable = new AutoCloseSpy("superClosable"); - @Test - void test1() { - assertFalse(closed); + @BeforeAll + // WARNING: if this method is named setup() AND the @BeforeAll method in + // SubTestCase is also named setup(), the latter will "hide" the former. + static void superSetup() { + superStaticClosable = new AutoCloseSpy("superStaticClosable"); } @Test - void test2() { - assertFalse(closed); + void superTest() { + } + } + + static class SubTestCase extends SuperTestCase { + + @AutoClose + static AutoCloseable subStaticClosable; + + @AutoClose + final AutoCloseable subClosable = new AutoCloseSpy("subClosable"); + + @BeforeAll + static void subSetup() { + subStaticClosable = new AutoCloseSpy("subStaticClosable"); } + @Test + void subTest() { + } } static class AutoCloseSpy implements AutoCloseable, Runnable { private final String prefix; - private String invokedMethod = ""; + private String invokedMethod = null; - public AutoCloseSpy(String prefix) { - this.prefix = prefix; + AutoCloseSpy(String prefix) { + Class callerClass = StackWalker.getInstance(RETAIN_CLASS_REFERENCE).getCallerClass(); + this.prefix = callerClass.getSimpleName() + "." + prefix + "."; } @Override public void run() { - checkIfAlreadyInvoked(); recordInvocation("run()"); } @Override public void close() { - checkIfAlreadyInvoked(); recordInvocation("close()"); } - private void checkIfAlreadyInvoked() { - if (!invokedMethod.isEmpty()) { - throw new IllegalStateException(); - } - } - private void recordInvocation(String methodName) { - invokedMethod = methodName; - recorder.add(prefix + methodName); + if (this.invokedMethod != null) { + throw new IllegalStateException("Already closed via " + this.invokedMethod); + } + this.invokedMethod = methodName; + recorder.add(this.prefix + this.invokedMethod); } - } } 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 4880e3f11ebe..180592fd09ec 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(); diff --git a/platform-tooling-support-tests/projects/jar-describe-module/junit-jupiter-api.expected.txt b/platform-tooling-support-tests/projects/jar-describe-module/junit-jupiter-api.expected.txt index 1fdcabd2d101..0b4810a6cc60 100644 --- a/platform-tooling-support-tests/projects/jar-describe-module/junit-jupiter-api.expected.txt +++ b/platform-tooling-support-tests/projects/jar-describe-module/junit-jupiter-api.expected.txt @@ -9,5 +9,4 @@ requires java.base mandated requires org.apiguardian.api static transitive requires org.junit.platform.commons transitive requires org.opentest4j transitive -qualified opens org.junit.jupiter.api to org.junit.platform.commons qualified opens org.junit.jupiter.api.condition to org.junit.platform.commons