diff --git a/documentation/src/docs/asciidoc/link-attributes.adoc b/documentation/src/docs/asciidoc/link-attributes.adoc index 4c9d02b679b5..5822bffb57aa 100644 --- a/documentation/src/docs/asciidoc/link-attributes.adoc +++ b/documentation/src/docs/asciidoc/link-attributes.adoc @@ -153,6 +153,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] :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 2f7dc8a4d1ec..8a07333dea11 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,7 +45,10 @@ 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 + <<../user-guide/index.adoc#writing-tests-built-in-extensions-AutoClose, User Guide>> for + details. [[release-notes-5.11.0-M1-junit-vintage]] diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index 4f8d59925fbf..809c0b4d5238 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` | 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 @@ -2709,3 +2710,26 @@ following precedence rules: 2. The default `TempDirFactory` configured via the configuration 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 +---- +include::{testDir}/example/AutoCloseDemo.java[tags=user_guide_example] +---- diff --git a/documentation/src/test/java/example/AutoCloseDemo.java b/documentation/src/test/java/example/AutoCloseDemo.java new file mode 100644 index 000000000000..1f89d9fd30f8 --- /dev/null +++ b/documentation/src/test/java/example/AutoCloseDemo.java @@ -0,0 +1,49 @@ +/* + * 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 example; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; + +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 { + + @AutoClose + Connection connection = getJdbcConnection("jdbc:mysql://localhost/testdb"); + + @Test + void usersTableHasEntries() throws SQLException { + ResultSet resultSet = connection.createStatement().executeQuery("SELECT * FROM users"); + + assertTrue(resultSet.next()); + } + + // ... + // end::user_guide_example[] + private static Connection getJdbcConnection(String url) { + try { + return DriverManager.getConnection(url); + } + catch (SQLException ex) { + throw new RuntimeException(ex); + } + } + +} 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..575b60d8c0fd --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AutoClose.java @@ -0,0 +1,59 @@ +/* + * 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.Documented; +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; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * The {@code AutoClose} annotation is used to automatically close resources + * used in tests. + * + *

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

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. + * + * @since 5.11 + * @see java.lang.annotation.Retention + * @see java.lang.annotation.Target + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@ExtendWith(AutoCloseExtension.class) +@API(status = API.Status.EXPERIMENTAL, since = "5.11") +@SuppressWarnings("exports") +public @interface AutoClose { + + /** + * Specifies the name of the method to invoke for closing the resource. + * + *

The default value is {@code close}. + * + * @return the method name for closing 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 new file mode 100644 index 000000000000..52bd5cd86b11 --- /dev/null +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/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.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 b6856c78a11e..e7a7668b7873 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,5 +23,6 @@ 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/test/java/org/junit/jupiter/engine/extension/AutoCloseTests.java b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/AutoCloseTests.java new file mode 100644 index 000000000000..47eef593b92a --- /dev/null +++ b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/AutoCloseTests.java @@ -0,0 +1,224 @@ +/* + * 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 java.util.Arrays.asList; +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.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; +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.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.engine.AbstractJupiterTestEngineTests; +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. + * + * @since 5.11 + */ +class AutoCloseTests extends AbstractJupiterTestEngineTests { + + private static final List recorder = new ArrayList<>(); + + @BeforeEach + void resetRecorder() { + 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 + } + + @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."; + + Events tests = executeTestsForClass(AutoCloseNoCloseMethodFailingTestCase.class).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."; + + Events tests = executeTestsForClass(AutoCloseNoShutdownMethodFailingTestCase.class).testEvents(); + assertFailingWithMessage(tests, msg); + } + + @Test + void spyPermitsOnlyASingleAction() { + AutoCloseSpy spy = new AutoCloseSpy(""); + + spy.close(); + + assertThrows(IllegalStateException.class, spy::close); + assertThrows(IllegalStateException.class, spy::run); + assertEquals(asList("close()"), recorder); + } + + @Test + void instancePerClass() { + Events tests = executeTestsForClass(AutoCloseInstancePerClassTestCase.class).testEvents(); + tests.assertStatistics(stats -> stats.succeeded(2)); + } + + 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 AutoCloseTestCase { + + @AutoClose + private static AutoCloseable staticClosable; + @AutoClose + private static AutoCloseable nullStatic; + + @AutoClose + private final AutoCloseable closable = new AutoCloseSpy("afterEach-"); + @AutoClose("run") + private final Runnable runnable = new AutoCloseSpy("afterEach-"); + @AutoClose + private AutoCloseable nullField; + + @BeforeAll + static void setup() { + staticClosable = new AutoCloseSpy("afterAll-"); + } + + @Test + void justPass() { + assertFields(); + } + + @Test + void anotherPass() { + assertFields(); + } + + private void assertFields() { + assertNotNull(staticClosable); + assertNull(nullStatic); + + assertNotNull(closable); + assertNotNull(runnable); + assertNull(nullField); + } + + } + + static class AutoCloseNoCloseMethodFailingTestCase { + + @AutoClose + private final String field = "nothing to close()"; + + @Test + void alwaysPass() { + assertNotNull(field); + } + + } + + static class AutoCloseNoShutdownMethodFailingTestCase { + + @AutoClose("shutdown") + private final String field = "nothing to shutdown()"; + + @Test + void alwaysPass() { + assertNotNull(field); + } + + } + + @TestInstance(PER_CLASS) + static class AutoCloseInstancePerClassTestCase { + + static boolean closed; + + @AutoClose + AutoCloseable field = () -> closed = true; + + @Test + void test1() { + assertFalse(closed); + } + + @Test + void test2() { + assertFalse(closed); + } + + } + + static class AutoCloseSpy implements AutoCloseable, Runnable { + + private final String prefix; + private String invokedMethod = ""; + + public AutoCloseSpy(String prefix) { + this.prefix = 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); + } + + } + +} 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 0b4810a6cc60..1fdcabd2d101 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,4 +9,5 @@ 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