Repository: junit-team/junit5 (language: java)

Issue:
`@TempDir` should fail fast if `TempDirFactory::createTempDirectory` does not return a directory
The contract of `TempDirFactory::createTempDirectory` mentions:

https://github.com/junit-team/junit5/blob/adf6d4332b8a74db3d43f90982fd87b0d5197b3c/junit-jupiter-api/src/main/java/org/junit/jupiter/api/io/TempDirFactory.java#L61

However, the non-nullability of the returned `Path` is not enforced.

For example, the following:

```java
@Test
void test(@TempDir(factory = Factory.class) Path tempDir) {
}

static class Factory implements TempDirFactory {
    @Override
    public Path createTempDirectory(AnnotatedElementContext elementContext, ExtensionContext extensionContext) throws Exception {
        return null;
    }
}
```

fails with:

```
Jul 26, 2024 5:39:46 PM org.junit.jupiter.engine.execution.JupiterEngineExecutionContext close
SEVERE: Caught exception while closing extension context: org.junit.jupiter.engine.descriptor.MethodExtensionContext@7364985f
java.lang.NullPointerException: Cannot invoke "java.nio.file.Path.getFileSystem()" because "path" is null
```

<details><summary>Stack trace</summary>
<p>

```
Jul 26, 2024 5:39:46 PM org.junit.jupiter.engine.execution.JupiterEngineExecutionContext close
SEVERE: Caught exception while closing extension context: org.junit.jupiter.engine.descriptor.MethodExtensionContext@7364985f
java.lang.NullPointerException: Cannot invoke "java.nio.file.Path.getFileSystem()" because "path" is null
	at java.base/java.nio.file.Files.provider(Files.java:104)
	at java.base/java.nio.file.Files.notExists(Files.java:2551)
	at org.junit.jupiter.engine.extension.TempDirectory$CloseablePath.deleteAllFilesAndDirectories(TempDirectory.java:322)
	at org.junit.jupiter.engine.extension.TempDirectory$CloseablePath.close(TempDirectory.java:310)
	at org.junit.jupiter.engine.descriptor.AbstractExtensionContext.lambda$static$0(AbstractExtensionContext.java:45)
	at org.junit.platform.engine.support.store.NamespacedHierarchicalStore$EvaluatedValue.close(NamespacedHierarchicalStore.java:333)
	at org.junit.platform.engine.support.store.NamespacedHierarchicalStore$EvaluatedValue.access$800(NamespacedHierarchicalStore.java:317)
	at org.junit.platform.engine.support.store.NamespacedHierarchicalStore.lambda$close$3(NamespacedHierarchicalStore.java:98)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.store.NamespacedHierarchicalStore.lambda$close$4(NamespacedHierarchicalStore.java:98)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:184)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
	at java.base/java.util.stream.SortedOps$RefSortingSink.end(SortedOps.java:395)
	at java.base/java.util.stream.Sink$ChainedReference.end(Sink.java:261)
	at java.base/java.util.stream.Sink$ChainedReference.end(Sink.java:261)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:510)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at org.junit.platform.engine.support.store.NamespacedHierarchicalStore.close(NamespacedHierarchicalStore.java:98)
	at org.junit.jupiter.engine.descriptor.AbstractExtensionContext.close(AbstractExtensionContext.java:87)
	at org.junit.jupiter.engine.execution.JupiterEngineExecutionContext.close(JupiterEngineExecutionContext.java:53)
	at org.junit.jupiter.engine.descriptor.JupiterTestDescriptor.cleanUp(JupiterTestDescriptor.java:224)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$cleanUp$1(TestMethodTestDescriptor.java:156)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.cleanUp(TestMethodTestDescriptor.java:156)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.cleanUp(TestMethodTestDescriptor.java:69)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$cleanUp$10(NodeTestTask.java:167)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.cleanUp(NodeTestTask.java:167)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:98)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
	at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141)
	at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
	at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141)
	at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95)
	at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:35)
	at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57)
	at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:54)
	at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:198)
	at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:169)
	at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:93)
	at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:58)
	at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:141)
	at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:57)
	at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:103)
	at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:85)
	at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:47)
	at org.junit.platform.launcher.core.SessionPerRequestLauncher.execute(SessionPerRequestLauncher.java:63)
	at com.intellij.junit5.JUnit5IdeaTestRunner.startRunnerWithArgs(JUnit5IdeaTestRunner.java:57)
	at com.intellij.rt.junit.IdeaTestRunner$Repeater$1.execute(IdeaTestRunner.java:38)
	at com.intellij.rt.execution.junit.TestsRepeater.repeat(TestsRepeater.java:11)
	at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:35)
	at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:232)
	at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:55)


java.lang.NullPointerException: Cannot invoke "java.nio.file.Path.getFileSystem()" because "path" is null

	at java.base/java.nio.file.Files.provider(Files.java:104)
	at java.base/java.nio.file.Files.notExists(Files.java:2551)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:184)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
	at java.base/java.util.stream.SortedOps$RefSortingSink.end(SortedOps.java:395)
	at java.base/java.util.stream.Sink$ChainedReference.end(Sink.java:261)
	at java.base/java.util.stream.Sink$ChainedReference.end(Sink.java:261)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:510)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
```

</p>
</details> 

While it's good that the test doesn't pass, I propose to catch the problem earlier, for example in the `CloseablePath` constructor:

https://github.com/junit-team/junit5/blob/adf6d4332b8a74db3d43f90982fd87b0d5197b3c/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TempDirectory.java#L287-L293

I'm happy to submit a PR if the proposal sounds reasonable.


Discussion / hints:
Detecting that as early as possible and preemptively failing with an exception sounds good to me. 👍 

Feel free to submit a PR!

Fix patch:
diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.0-RC1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.0-RC1.adoc
index e76d8c4020aa..fcf65fe59b3e 100644
--- a/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.0-RC1.adoc
+++ b/documentation/src/docs/asciidoc/release-notes/release-notes-5.11.0-RC1.adoc
@@ -60,7 +60,9 @@ repository on GitHub.
   `assertInstanceOf` methods introduced in `5.8` version.
 * New generators in `DynamicTest` that take a `Stream`/`Iterator` of `Named<Executable>`
   along with a convenient `NamedExecutable` interface that can simplify writing dynamic
-  tests, in particular in recent version of Java that support records.
+  tests, in particular in recent versions of Java that support records.
+* `@TempDir` now fails fast in case `TempDirFactory::createTempDirectory` returns
+  `null`, a file, or a symbolic link to a file.
 
 
 [[release-notes-5.11.0-RC1-junit-vintage]]
diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TempDirectory.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TempDirectory.java
index f165b2e55968..eb2148e544f6 100644
--- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TempDirectory.java
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/extension/TempDirectory.java
@@ -284,14 +284,19 @@ static class CloseablePath implements CloseableResource {
 		private final CleanupMode cleanupMode;
 		private final ExtensionContext extensionContext;
 
-		CloseablePath(TempDirFactory factory, CleanupMode cleanupMode, AnnotatedElementContext elementContext,
+		private CloseablePath(TempDirFactory factory, CleanupMode cleanupMode, AnnotatedElementContext elementContext,
 				ExtensionContext extensionContext) throws Exception {
-			this.dir = factory.createTempDirectory(elementContext, extensionContext);
+			this.dir = validateTempDirectory(factory.createTempDirectory(elementContext, extensionContext));
 			this.factory = factory;
 			this.cleanupMode = cleanupMode;
 			this.extensionContext = extensionContext;
 		}
 
+		private static Path validateTempDirectory(Path dir) {
+			Preconditions.condition(dir != null && Files.isDirectory(dir), "temp directory must be a directory");
+			return dir;
+		}
+
 		Path get() {
 			return dir;
 		}


Test patch:
diff --git a/documentation/src/test/java/example/TempDirectoryDemo.java b/documentation/src/test/java/example/TempDirectoryDemo.java
index 4998f939fd9b..758710000fb3 100644
--- a/documentation/src/test/java/example/TempDirectoryDemo.java
+++ b/documentation/src/test/java/example/TempDirectoryDemo.java
@@ -137,7 +137,7 @@ static class JimfsTempDirFactory implements TempDirFactory {
 			@Override
 			public Path createTempDirectory(AnnotatedElementContext elementContext, ExtensionContext extensionContext)
 					throws IOException {
-				return Files.createTempDirectory(fileSystem.getPath("/"), "junit");
+				return Files.createTempDirectory(fileSystem.getPath("/"), "junit-");
 			}
 
 			@Override
diff --git a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/CloseablePathCleanupTests.java b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/CloseablePathCleanupTests.java
deleted file mode 100644
index 2ed3e370b58a..000000000000
--- a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/CloseablePathCleanupTests.java
+++ /dev/null
@@ -1,117 +0,0 @@
-/*
- * 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 java.nio.file.Files.deleteIfExists;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.io.CleanupMode.ALWAYS;
-import static org.junit.jupiter.api.io.CleanupMode.NEVER;
-import static org.junit.jupiter.api.io.CleanupMode.ON_SUCCESS;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.spy;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-import java.io.IOException;
-import java.util.Optional;
-
-import org.junit.jupiter.api.AfterEach;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.DisplayName;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.AnnotatedElementContext;
-import org.junit.jupiter.api.extension.ExtensionContext;
-import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
-import org.junit.jupiter.api.io.CleanupMode;
-import org.junit.jupiter.api.io.TempDir;
-import org.junit.jupiter.api.io.TempDirFactory;
-import org.junit.jupiter.engine.AbstractJupiterTestEngineTests;
-import org.junit.jupiter.engine.execution.NamespaceAwareStore;
-import org.junit.platform.engine.support.store.NamespacedHierarchicalStore;
-
-/**
- * Integration tests for cleanup of the {@link TempDirectory} when the {@link CleanupMode} is
- * set to {@link CleanupMode#ALWAYS}, {@link CleanupMode#NEVER}, or {@link CleanupMode#ON_SUCCESS}.
- *
- * @since 5.9
- *
- * @see TempDir
- * @see CleanupMode
- */
-class CloseablePathCleanupTests extends AbstractJupiterTestEngineTests {
-
-	private final AnnotatedElementContext elementContext = mock();
-	private final ExtensionContext extensionContext = mock();
-	private final TempDirFactory factory = spy(TempDirFactory.Standard.INSTANCE);
-
-	private TempDirectory.CloseablePath closeablePath;
-
-	@BeforeEach
-	void setUpExtensionContext() {
-		var store = new NamespaceAwareStore(new NamespacedHierarchicalStore<>(null), Namespace.GLOBAL);
-		when(extensionContext.getStore(any())).thenReturn(store);
-	}
-
-	@AfterEach
-	void cleanupTempDirectory() throws IOException {
-		deleteIfExists(closeablePath.get());
-	}
-
-	@Test
-	@DisplayName("is cleaned up for a cleanup mode of ALWAYS")
-	void always() throws IOException {
-		closeablePath = TempDirectory.createTempDir(factory, ALWAYS, elementContext, extensionContext);
-		assertThat(closeablePath.get()).exists();
-
-		closeablePath.close();
-		assertThat(closeablePath.get()).doesNotExist();
-		verify(factory).close();
-	}
-
-	@Test
-	@DisplayName("is not cleaned up for a cleanup mode of NEVER")
-	void never() throws IOException {
-		closeablePath = TempDirectory.createTempDir(factory, NEVER, elementContext, extensionContext);
-		assertThat(closeablePath.get()).exists();
-
-		closeablePath.close();
-		assertThat(closeablePath.get()).exists();
-		verify(factory).close();
-	}
-
-	@Test
-	@DisplayName("is not cleaned up for a cleanup mode of ON_SUCCESS, if there is an exception")
-	void onSuccessWithException() throws IOException {
-		when(extensionContext.getExecutionException()).thenReturn(Optional.of(new Exception()));
-
-		closeablePath = TempDirectory.createTempDir(factory, ON_SUCCESS, elementContext, extensionContext);
-		assertThat(closeablePath.get()).exists();
-
-		closeablePath.close();
-		assertThat(closeablePath.get()).exists();
-		verify(factory).close();
-	}
-
-	@Test
-	@DisplayName("is cleaned up for a cleanup mode of ON_SUCCESS, if there is no exception")
-	void onSuccessWithNoException() throws IOException {
-		when(extensionContext.getExecutionException()).thenReturn(Optional.empty());
-
-		closeablePath = TempDirectory.createTempDir(factory, ON_SUCCESS, elementContext, extensionContext);
-		assertThat(closeablePath.get()).exists();
-
-		closeablePath.close();
-		assertThat(closeablePath.get()).doesNotExist();
-		verify(factory).close();
-	}
-
-}
diff --git a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/CloseablePathTests.java b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/CloseablePathTests.java
new file mode 100644
index 000000000000..a2bfc075ee59
--- /dev/null
+++ b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/CloseablePathTests.java
@@ -0,0 +1,236 @@
+/*
+ * 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 java.nio.file.Files.createDirectory;
+import static java.nio.file.Files.createFile;
+import static java.nio.file.Files.createSymbolicLink;
+import static java.nio.file.Files.createTempDirectory;
+import static java.nio.file.Files.delete;
+import static java.nio.file.Files.deleteIfExists;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.junit.jupiter.api.io.CleanupMode.ALWAYS;
+import static org.junit.jupiter.api.io.CleanupMode.DEFAULT;
+import static org.junit.jupiter.api.io.CleanupMode.NEVER;
+import static org.junit.jupiter.api.io.CleanupMode.ON_SUCCESS;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Optional;
+
+import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.AnnotatedElementContext;
+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.io.CleanupMode;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.api.io.TempDirFactory;
+import org.junit.jupiter.engine.AbstractJupiterTestEngineTests;
+import org.junit.jupiter.engine.execution.NamespaceAwareStore;
+import org.junit.platform.commons.PreconditionViolationException;
+import org.junit.platform.engine.support.store.NamespacedHierarchicalStore;
+
+/**
+ * Integration tests for the creation and cleanup of the {@link TempDirectory}.
+ *
+ * @since 5.9
+ */
+@DisplayName("Temporary directory")
+class CloseablePathTests extends AbstractJupiterTestEngineTests {
+
+	private final AnnotatedElementContext elementContext = mock();
+	private final ExtensionContext extensionContext = mock();
+
+	private TempDirectory.CloseablePath closeablePath;
+
+	@BeforeEach
+	void setUpExtensionContext() {
+		var store = new NamespaceAwareStore(new NamespacedHierarchicalStore<>(null), Namespace.GLOBAL);
+		when(extensionContext.getStore(any())).thenReturn(store);
+	}
+
+	/**
+	 * Integration tests for the creation of the {@link TempDirectory} based on the different result
+	 * that {@link TempDirFactory#createTempDirectory(AnnotatedElementContext, ExtensionContext)} may provide.
+	 *
+	 * @since 5.11
+	 *
+	 * @see TempDirFactory
+	 */
+	@Nested
+	@DisplayName("creation")
+	class Creation {
+
+		private Path root;
+
+		@BeforeEach
+		void setUpRootFolder() throws IOException {
+			root = createTempDirectory("root");
+		}
+
+		@AfterEach
+		void cleanupRoot() throws IOException {
+			delete(root);
+		}
+
+		@Test
+		@DisplayName("succeeds if the factory returns a directory")
+		void factoryReturnsDirectory() throws Exception {
+			TempDirFactory factory = (elementContext, extensionContext) -> createDirectory(root.resolve("directory"));
+
+			closeablePath = TempDirectory.createTempDir(factory, DEFAULT, elementContext, extensionContext);
+			assertThat(closeablePath.get()).isDirectory();
+
+			delete(closeablePath.get());
+		}
+
+		@Test
+		@DisplayName("succeeds if the factory returns a symbolic link to a directory")
+		void factoryReturnsSymbolicLinkToDirectory() throws Exception {
+			Path directory = createDirectory(root.resolve("directory"));
+			TempDirFactory factory = (elementContext,
+					extensionContext) -> createSymbolicLink(root.resolve("symbolicLink"), directory);
+
+			closeablePath = TempDirectory.createTempDir(factory, DEFAULT, elementContext, extensionContext);
+			assertThat(closeablePath.get()).isDirectory();
+
+			delete(closeablePath.get());
+			delete(directory);
+		}
+
+		@Test
+		@DisplayName("fails if the factory returns null")
+		void factoryReturnsNull() {
+			TempDirFactory factory = (elementContext, extensionContext) -> null;
+
+			assertThatExtensionConfigurationExceptionIsThrownBy(
+				() -> TempDirectory.createTempDir(factory, DEFAULT, elementContext, extensionContext));
+		}
+
+		@Test
+		@DisplayName("fails if the factory returns a file")
+		void factoryReturnsFile() throws IOException {
+			Path file = createFile(root.resolve("file"));
+			TempDirFactory factory = (elementContext, extensionContext) -> file;
+
+			assertThatExtensionConfigurationExceptionIsThrownBy(
+				() -> TempDirectory.createTempDir(factory, DEFAULT, elementContext, extensionContext));
+
+			delete(file);
+		}
+
+		@Test
+		@DisplayName("fails if the factory returns a symbolic link to a file")
+		void factoryReturnsSymbolicLinkToFile() throws IOException {
+			Path file = createFile(root.resolve("file"));
+			Path symbolicLink = createSymbolicLink(root.resolve("symbolicLink"), file);
+			TempDirFactory factory = (elementContext, extensionContext) -> symbolicLink;
+
+			assertThatExtensionConfigurationExceptionIsThrownBy(
+				() -> TempDirectory.createTempDir(factory, DEFAULT, elementContext, extensionContext));
+
+			delete(symbolicLink);
+			delete(file);
+		}
+
+		private static void assertThatExtensionConfigurationExceptionIsThrownBy(ThrowingCallable callable) {
+			assertThatExceptionOfType(ExtensionConfigurationException.class)//
+					.isThrownBy(callable)//
+					.withMessage("Failed to create default temp directory")//
+					.withCauseInstanceOf(PreconditionViolationException.class)//
+					.havingCause().withMessage("temp directory must be a directory");
+		}
+
+	}
+
+	/**
+	 * Integration tests for cleanup of the {@link TempDirectory} when the {@link CleanupMode} is
+	 * set to {@link CleanupMode#ALWAYS}, {@link CleanupMode#NEVER}, or {@link CleanupMode#ON_SUCCESS}.
+	 *
+	 * @since 5.9
+	 *
+	 * @see TempDir
+	 * @see CleanupMode
+	 */
+	@Nested
+	@DisplayName("cleanup")
+	class Cleanup {
+
+		private final TempDirFactory factory = spy(TempDirFactory.Standard.INSTANCE);
+
+		@AfterEach
+		void cleanupTempDirectory() throws IOException {
+			deleteIfExists(closeablePath.get());
+		}
+
+		@Test
+		@DisplayName("is done for a cleanup mode of ALWAYS")
+		void always() throws IOException {
+			closeablePath = TempDirectory.createTempDir(factory, ALWAYS, elementContext, extensionContext);
+			assertThat(closeablePath.get()).isDirectory();
+
+			closeablePath.close();
+			assertThat(closeablePath.get()).doesNotExist();
+			verify(factory).close();
+		}
+
+		@Test
+		@DisplayName("is not done for a cleanup mode of NEVER")
+		void never() throws IOException {
+			closeablePath = TempDirectory.createTempDir(factory, NEVER, elementContext, extensionContext);
+			assertThat(closeablePath.get()).isDirectory();
+
+			closeablePath.close();
+			assertThat(closeablePath.get()).exists();
+			verify(factory).close();
+		}
+
+		@Test
+		@DisplayName("is not done for a cleanup mode of ON_SUCCESS, if there is an exception")
+		void onSuccessWithException() throws IOException {
+			when(extensionContext.getExecutionException()).thenReturn(Optional.of(new Exception()));
+
+			closeablePath = TempDirectory.createTempDir(factory, ON_SUCCESS, elementContext, extensionContext);
+			assertThat(closeablePath.get()).isDirectory();
+
+			closeablePath.close();
+			assertThat(closeablePath.get()).exists();
+			verify(factory).close();
+		}
+
+		@Test
+		@DisplayName("is done for a cleanup mode of ON_SUCCESS, if there is no exception")
+		void onSuccessWithNoException() throws IOException {
+			when(extensionContext.getExecutionException()).thenReturn(Optional.empty());
+
+			closeablePath = TempDirectory.createTempDir(factory, ON_SUCCESS, elementContext, extensionContext);
+			assertThat(closeablePath.get()).isDirectory();
+
+			closeablePath.close();
+			assertThat(closeablePath.get()).doesNotExist();
+			verify(factory).close();
+		}
+
+	}
+
+}
diff --git a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryPerContextTests.java b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryPerContextTests.java
index 49b28d2dcc3a..c47bb9112815 100644
--- a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryPerContextTests.java
+++ b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryPerContextTests.java
@@ -54,14 +54,15 @@
 import org.junit.jupiter.api.extension.ParameterResolutionException;
 import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.api.io.TempDirFactory;
+import org.junit.jupiter.api.io.TempDirFactory.Standard;
 import org.junit.jupiter.engine.AbstractJupiterTestEngineTests;
+import org.junit.platform.commons.PreconditionViolationException;
 import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
 import org.junit.platform.testkit.engine.EngineExecutionResults;
-import org.junit.platform.testkit.engine.Events;
 
 /**
  * Integration tests for the legacy behavior of the {@link TempDirectory}
- * extension to create a single temp directory per context, i.e. test class or
+ * extension to create a single temp directory per context, i.e., test class or
  * method.
  *
  * @since 5.4
@@ -74,6 +75,13 @@ protected EngineExecutionResults executeTestsForClass(Class<?> testClass) {
 		return executeTests(requestBuilder(testClass).build());
 	}
 
+	private EngineExecutionResults executeTestsForClassWithDefaultFactory(Class<?> testClass,
+			Class<? extends TempDirFactory> factoryClass) {
+		return executeTests(requestBuilder(testClass) //
+				.configurationParameter(TempDir.DEFAULT_FACTORY_PROPERTY_NAME, factoryClass.getName()) //
+				.build());
+	}
+
 	@SuppressWarnings("deprecation")
 	private static LauncherDiscoveryRequestBuilder requestBuilder(Class<?> testClass) {
 		return request() //
@@ -295,25 +303,20 @@ void resolvesSeparateTempDirWhenAnnotationIsUsedOnAfterAllMethodParameterOnly()
 	@TestMethodOrder(OrderAnnotation.class)
 	class DefaultFactory {
 
-		private Events executeTestsForClassWithDefaultFactory(Class<?> testClass,
-				Class<? extends TempDirFactory> factoryClass) {
-			return TempDirectoryPerContextTests.super.executeTests(requestBuilder(testClass) //
-					.configurationParameter(TempDir.DEFAULT_FACTORY_PROPERTY_NAME, factoryClass.getName()) //
-					.build()).testEvents();
-		}
-
 		@Test
 		@DisplayName("set to Jupiter's default")
 		void supportsStandardDefaultFactory() {
-			executeTestsForClassWithDefaultFactory(StandardDefaultFactoryTestCase.class, TempDirFactory.Standard.class) //
-					.assertStatistics(stats -> stats.started(1).succeeded(1));
+			var results = executeTestsForClassWithDefaultFactory(StandardDefaultFactoryTestCase.class, Standard.class);
+
+			results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(1));
 		}
 
 		@Test
 		@DisplayName("set to custom factory")
 		void supportsCustomDefaultFactory() {
-			executeTestsForClassWithDefaultFactory(NonStandardDefaultFactoryTestCase.class, Factory.class) //
-					.assertStatistics(stats -> stats.started(1).succeeded(1));
+			var results = executeTestsForClassWithDefaultFactory(CustomDefaultFactoryTestCase.class, Factory.class);
+
+			results.testEvents().assertStatistics(stats -> stats.started(1).succeeded(1));
 		}
 
 		private static class Factory implements TempDirFactory {
@@ -359,8 +362,7 @@ void onlySupportsParametersOfTypePathAndFile() {
 			var results = executeTestsForClass(InvalidTestCase.class);
 
 			// @formatter:off
-			TempDirectoryPerContextTests.assertSingleFailedTest(results,
-				instanceOf(ParameterResolutionException.class),
+			assertSingleFailedTest(results, instanceOf(ParameterResolutionException.class),
 				message(m -> m.matches("Failed to resolve parameter \\[java.lang.String .+] in method \\[.+]: .+")),
 				cause(
 					instanceOf(ExtensionConfigurationException.class),
@@ -389,14 +391,13 @@ void doesNotSupportTempDirAnnotationOnConstructorParameterWithTestInstancePerCla
 		}
 
 		@Test
-		@DisplayName("when @TempDir factory is not Standard")
+		@DisplayName("when non-default @TempDir factory is set")
 		@Order(32)
-		void onlySupportsStandardTempDirFactory() {
-			var results = executeTestsForClass(NonStandardFactoryTestCase.class);
+		void doesNotSupportNonDefaultTempDirFactory() {
+			var results = executeTestsForClass(NonDefaultFactoryTestCase.class);
 
 			// @formatter:off
-			TempDirectoryPerContextTests.assertSingleFailedTest(results,
-					instanceOf(ParameterResolutionException.class),
+			assertSingleFailedTest(results, instanceOf(ParameterResolutionException.class),
 					message(m -> m.matches("Failed to resolve parameter \\[.+] in method \\[.+]: .+")),
 					cause(
 							instanceOf(ExtensionConfigurationException.class),
@@ -405,6 +406,35 @@ void onlySupportsStandardTempDirFactory() {
 			// @formatter:on
 		}
 
+		@Test
+		@DisplayName("when default @TempDir factory does not return directory")
+		@Order(33)
+		void doesNotSupportCustomDefaultTempDirFactoryNotReturningDirectory() {
+			var results = executeTestsForClassWithDefaultFactory(
+				CustomDefaultFactoryNotReturningDirectoryTestCase.class, FactoryNotReturningDirectory.class);
+
+			// @formatter:off
+			assertSingleFailedTest(results, instanceOf(ParameterResolutionException.class),
+					message(m -> m.matches("Failed to resolve parameter \\[.+] in method \\[.+]: .+")),
+					cause(
+							instanceOf(ExtensionConfigurationException.class),
+							message("Failed to create default temp directory"),
+							cause(
+									instanceOf(PreconditionViolationException.class),
+									message("temp directory must be a directory")
+							)
+					));
+			// @formatter:on
+		}
+
+		private static class FactoryNotReturningDirectory implements TempDirFactory {
+
+			@Override
+			public Path createTempDirectory(AnnotatedElementContext elementContext, ExtensionContext extensionContext) {
+				return null;
+			}
+		}
+
 	}
 
 	@Nested
@@ -685,7 +715,7 @@ static class AnnotationOnConstructorParameterWithTestInstancePerClassTestCase
 		}
 	}
 
-	static class NonStandardFactoryTestCase {
+	static class NonDefaultFactoryTestCase {
 
 		@Test
 		void test(@SuppressWarnings("unused") @TempDir(factory = Factory.class) Path tempDir) {
@@ -712,7 +742,7 @@ void test(@TempDir Path tempDir1, @TempDir Path tempDir2) {
 
 	}
 
-	static class NonStandardDefaultFactoryTestCase {
+	static class CustomDefaultFactoryTestCase {
 
 		@Test
 		void test(@TempDir Path tempDir1, @TempDir Path tempDir2) {
@@ -721,6 +751,15 @@ void test(@TempDir Path tempDir1, @TempDir Path tempDir2) {
 
 	}
 
+	static class CustomDefaultFactoryNotReturningDirectoryTestCase {
+
+		@Test
+		void test(@SuppressWarnings("unused") @TempDir Path tempDir) {
+			// never called
+		}
+
+	}
+
 	static class AnnotationOnBeforeAllMethodParameterTestCase extends BaseSharedTempDirParameterInjectionTestCase {
 
 		@BeforeAll
diff --git a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryPerDeclarationTests.java b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryPerDeclarationTests.java
index 153146a782af..c49cb101784c 100644
--- a/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryPerDeclarationTests.java
+++ b/junit-jupiter-engine/src/test/java/org/junit/jupiter/engine/extension/TempDirectoryPerDeclarationTests.java
@@ -83,6 +83,7 @@
 import org.junit.jupiter.engine.AbstractJupiterTestEngineTests;
 import org.junit.jupiter.engine.Constants;
 import org.junit.jupiter.engine.extension.TempDirectory.FileOperations;
+import org.junit.platform.commons.PreconditionViolationException;
 import org.junit.platform.engine.reporting.ReportEntry;
 import org.junit.platform.testkit.engine.EngineExecutionResults;
 
@@ -95,6 +96,14 @@
 @DisplayName("TempDirectory extension (per declaration)")
 class TempDirectoryPerDeclarationTests extends AbstractJupiterTestEngineTests {
 
+	private EngineExecutionResults executeTestsForClassWithDefaultFactory(Class<?> testClass,
+			Class<? extends TempDirFactory> factoryClass) {
+		return executeTests(request() //
+				.selectors(selectClass(testClass)) //
+				.configurationParameter(TempDir.DEFAULT_FACTORY_PROPERTY_NAME, factoryClass.getName()) //
+				.build());
+	}
+
 	@BeforeEach
 	@AfterEach
 	void resetStaticVariables() {
@@ -272,8 +281,7 @@ void onlySupportsParametersOfTypePathAndFile() {
 			var results = executeTestsForClass(InvalidTestCase.class);
 
 			// @formatter:off
-			TempDirectoryPerDeclarationTests.assertSingleFailedTest(results,
-				instanceOf(ParameterResolutionException.class),
+			assertSingleFailedTest(results, instanceOf(ParameterResolutionException.class),
 				message(m -> m.matches("Failed to resolve parameter \\[java.lang.String .+] in method \\[.+]: .+")),
 				cause(
 					instanceOf(ExtensionConfigurationException.class),
@@ -301,6 +309,55 @@ void doesNotSupportTempDirAnnotationOnConstructorParameterWithTestInstancePerCla
 				"@TempDir is not supported on constructor parameters. Please use field injection instead.");
 		}
 
+		@Test
+		@DisplayName("when @TempDir factory does not return directory")
+		@Order(32)
+		void doesNotSupportTempDirFactoryNotReturningDirectory() {
+			var results = executeTestsForClass(FactoryNotReturningDirectoryTestCase.class);
+
+			// @formatter:off
+			assertSingleFailedTest(results, instanceOf(ParameterResolutionException.class),
+					message(m -> m.matches("Failed to resolve parameter \\[.+] in method \\[.+]: .+")),
+					cause(
+							instanceOf(ExtensionConfigurationException.class),
+							message("Failed to create default temp directory"),
+							cause(
+									instanceOf(PreconditionViolationException.class),
+									message("temp directory must be a directory")
+							)
+					));
+			// @formatter:on
+		}
+
+		@Test
+		@DisplayName("when default @TempDir factory does not return directory")
+		@Order(33)
+		void doesNotSupportCustomDefaultTempDirFactoryReturningNull() {
+			var results = executeTestsForClassWithDefaultFactory(
+				CustomDefaultFactoryNotReturningDirectoryTestCase.class, FactoryNotReturningDirectory.class);
+
+			// @formatter:off
+			assertSingleFailedTest(results, instanceOf(ParameterResolutionException.class),
+					message(m -> m.matches("Failed to resolve parameter \\[.+] in method \\[.+]: .+")),
+					cause(
+							instanceOf(ExtensionConfigurationException.class),
+							message("Failed to create default temp directory"),
+							cause(
+									instanceOf(PreconditionViolationException.class),
+									message("temp directory must be a directory")
+							)
+					));
+			// @formatter:on
+		}
+
+		private static class FactoryNotReturningDirectory implements TempDirFactory {
+
+			@Override
+			public Path createTempDirectory(AnnotatedElementContext elementContext, ExtensionContext extensionContext) {
+				return null;
+			}
+		}
+
 	}
 
 	@Nested
@@ -379,14 +436,6 @@ void supportsFactoryWithCustomMetaAnnotation() {
 	@TestMethodOrder(OrderAnnotation.class)
 	class DefaultFactory {
 
-		private EngineExecutionResults executeTestsForClassWithDefaultFactory(Class<?> testClass,
-				Class<? extends TempDirFactory> factoryClass) {
-			return TempDirectoryPerDeclarationTests.super.executeTests(request() //
-					.selectors(selectClass(testClass)) //
-					.configurationParameter(TempDir.DEFAULT_FACTORY_PROPERTY_NAME, factoryClass.getName()) //
-					.build());
-		}
-
 		@Test
 		@DisplayName("set to Jupiter's default")
 		void supportsStandardDefaultFactory() {
@@ -1369,6 +1418,23 @@ public Path createTempDirectory(AnnotatedElementContext elementContext, Extensio
 
 	}
 
+	static class FactoryNotReturningDirectoryTestCase {
+
+		@Test
+		void test(@SuppressWarnings("unused") @TempDir(factory = Factory.class) Path tempDir) {
+			// never called
+		}
+
+		private static class Factory implements TempDirFactory {
+
+			@Override
+			public Path createTempDirectory(AnnotatedElementContext elementContext, ExtensionContext extensionContext) {
+				return null;
+			}
+		}
+
+	}
+
 	static class StandardDefaultFactoryTestCase {
 
 		@Test
@@ -1402,4 +1468,13 @@ void test(@TempDir Path tempDir1, @TempDir(factory = Standard.class) Path tempDi
 
 	}
 
+	static class CustomDefaultFactoryNotReturningDirectoryTestCase {
+
+		@Test
+		void test(@SuppressWarnings("unused") @TempDir Path tempDir) {
+			// never called
+		}
+
+	}
+
 }