Repository: junit-team/junit-framework (2025-06-30T12:17:12Z)

Issue:
Top-level and static classes annotated with `@Nested` are no longer executed
While looking through the discovery issues of spring-modulith (to verify the fix for #4681), I noticed discovery warnings for top-level classes annotated with `@Nested`:

```java
@Nested
@EnableScenarios
@SpringBootTest(classes = { TestConfiguration.class, TransactionTemplateTestConfiguration.class })
class EnableScenarioIntegrationTests {

	@Configuration
	static class TransactionTemplateTestConfiguration {

		@Bean
		TransactionTemplate transactionTemplate() {
			return new TransactionTemplate(mock(PlatformTransactionManager.class));
		}
	}

	@Test // GH-190
	void injectsScenario(Scenario scenario) {
		assertThat(scenario).isNotNull();
	}
}
```

Source: https://github.com/spring-projects/spring-modulith/blob/7fabc528b4d2595fc646b08a6d71195c292a61cf/spring-modulith-test/src/test/java/org/springframework/modulith/test/EnableScenarioIntegrationTests.java

These were executed with 5.12.2 but are no longer executed since 5.13.0 and merely report a discovery issue with severity "warning":
```
WARNING: TestEngine with ID 'junit-jupiter' encountered a non-critical issue during test discovery:

(1) [WARNING] @Nested class 'com.acme.myproject.moduleB.ModuleBTest$WithoutMocksTest' must not be static. It will not be executed.
    Source: ClassSource [className = 'com.acme.myproject.moduleB.ModuleBTest$WithoutMocksTest', filePosition = null]
            at com.acme.myproject.moduleB.ModuleBTest$WithoutMocksTest.<no-method>(SourceFile:0)
```

Similarly, there are a few `static` nested classes annotated with `@Nested`:

```java
class ModuleBTest {

	@Nested
	static class WithoutMocksTest {

		...
	}
}
```

To restore backward compatibility, they should still be executed and produce a different warning.

## Steps to reproduce

1. Run a test class like the one above
2. Discovery issue is reported and test class is not executed

## Context

 - Used versions (Jupiter/Vintage/Platform): 5.13.2
 - Build Tool/IDE: Maven

## Deliverables

- [x] Ensure top-level and `static` classes annotated with `@Nested` are executed as standalone test classes again
- [x] Report discovery warning for such classes that recommends to remove the annotation or make the test class non-static (if it's not a top-level test class)



Discussion:
Cherry-picked to `releases/5.13.x` in f7e879c2095193e3f56a60e561c9cf2f4ef6c531



Patch:
diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/ClassSelectorResolver.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/ClassSelectorResolver.java
index 3294a1757a05..3515a5cfabd7 100644
--- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/ClassSelectorResolver.java
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/ClassSelectorResolver.java
@@ -15,6 +15,7 @@
 import static java.util.stream.Collectors.toCollection;
 import static java.util.stream.Collectors.toSet;
 import static org.junit.jupiter.engine.descriptor.NestedClassTestDescriptor.getEnclosingTestClasses;
+import static org.junit.jupiter.engine.discovery.predicates.TestClassPredicates.NestedClassInvalidityReason.NOT_INNER;
 import static org.junit.platform.commons.support.HierarchyTraversalMode.TOP_DOWN;
 import static org.junit.platform.commons.support.ReflectionSupport.findMethods;
 import static org.junit.platform.commons.util.FunctionUtils.where;
@@ -86,13 +87,22 @@ public Resolution resolve(ClassSelector selector, Context context) {
 
 		if (this.predicates.isAnnotatedWithNested.test(testClass)) {
 			// Class name filter is not applied to nested test classes
-			if (this.predicates.isValidNestedTestClass(testClass)) {
+			var invalidityReason = this.predicates.validateNestedTestClass(testClass);
+			if (invalidityReason == null) {
 				return toResolution(
 					context.addToParent(() -> DiscoverySelectors.selectClass(testClass.getEnclosingClass()),
 						parent -> Optional.of(newMemberClassTestDescriptor(parent, testClass))));
 			}
+			if (invalidityReason == NOT_INNER) {
+				return resolveStandaloneTestClass(context, testClass);
+			}
+			return unresolved();
 		}
-		else if (isAcceptedStandaloneTestClass(testClass)) {
+		return resolveStandaloneTestClass(context, testClass);
+	}
+
+	private Resolution resolveStandaloneTestClass(Context context, Class<?> testClass) {
+		if (isAcceptedStandaloneTestClass(testClass)) {
 			return toResolution(
 				context.addToParent(parent -> Optional.of(newStandaloneClassTestDescriptor(parent, testClass))));
 		}
diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/TestClassPredicates.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/TestClassPredicates.java
index 4867348f0acb..f44347380446 100644
--- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/TestClassPredicates.java
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/TestClassPredicates.java
@@ -27,6 +27,7 @@
 import java.util.function.Predicate;
 
 import org.apiguardian.api.API;
+import org.jspecify.annotations.Nullable;
 import org.junit.jupiter.api.ClassTemplate;
 import org.junit.jupiter.api.Nested;
 import org.junit.platform.commons.util.ReflectionUtils;
@@ -55,15 +56,16 @@ public class TestClassPredicates {
 		candidate) || looksLikeIntendedTestClass(candidate);
 	public final Predicate<Method> isTestOrTestFactoryOrTestTemplateMethod;
 
-	private final Condition<Class<?>> isValidNestedTestClass;
+	private final Condition<Class<?>> isNotPrivateUnlessAbstractNestedClass;
+	private final Condition<Class<?>> isInnerNestedClass;
 	private final Condition<Class<?>> isValidStandaloneTestClass;
 
 	public TestClassPredicates(DiscoveryIssueReporter issueReporter) {
 		this.isTestOrTestFactoryOrTestTemplateMethod = new IsTestMethod(issueReporter) //
 				.or(new IsTestFactoryMethod(issueReporter)) //
 				.or(new IsTestTemplateMethod(issueReporter));
-		this.isValidNestedTestClass = isNotPrivateUnlessAbstract("@Nested", issueReporter) //
-				.and(isInner(issueReporter));
+		this.isNotPrivateUnlessAbstractNestedClass = isNotPrivateUnlessAbstract("@Nested", issueReporter);
+		this.isInnerNestedClass = isInner(issueReporter);
 		this.isValidStandaloneTestClass = isNotPrivateUnlessAbstract("Test", issueReporter) //
 				.and(isNotLocal(issueReporter)) //
 				.and(isNotInnerUnlessAbstract(issueReporter)) //
@@ -84,8 +86,16 @@ private boolean looksLikeIntendedTestClass(Class<?> candidate, Set<Class<?>> see
 	}
 
 	public boolean isValidNestedTestClass(Class<?> candidate) {
-		return this.isValidNestedTestClass.check(candidate) //
-				&& isNotAbstract(candidate);
+		return validateNestedTestClass(candidate) == null;
+	}
+
+	public @Nullable NestedClassInvalidityReason validateNestedTestClass(Class<?> candidate) {
+		boolean isInner = this.isInnerNestedClass.check(candidate);
+		boolean isNotPrivateUnlessAbstract = this.isNotPrivateUnlessAbstractNestedClass.check(candidate);
+		if (isNotPrivateUnlessAbstract && isNotAbstract(candidate)) {
+			return isInner ? null : NestedClassInvalidityReason.NOT_INNER;
+		}
+		return NestedClassInvalidityReason.OTHER;
 	}
 
 	public boolean isValidStandaloneTestClass(Class<?> candidate) {
@@ -124,9 +134,13 @@ private static Condition<Class<?>> isNotLocal(DiscoveryIssueReporter issueReport
 	private static Condition<Class<?>> isInner(DiscoveryIssueReporter issueReporter) {
 		return issueReporter.createReportingCondition(ReflectionUtils::isInnerClass, testClass -> {
 			if (testClass.getEnclosingClass() == null) {
-				return createIssue("@Nested", testClass, "must not be a top-level class");
+				return createIssue("Top-level", testClass, "must not be annotated with @Nested",
+					"It will be executed anyway for backward compatibility. "
+							+ "You should remove the @Nested annotation to resolve this warning.");
 			}
-			return createIssue("@Nested", testClass, "must not be static");
+			return createIssue("@Nested", testClass, "must not be static",
+				"It will only be executed if discovered as a standalone test class. "
+						+ "You should remove the annotation or make it non-static to resolve this warning.");
 		});
 	}
 
@@ -141,8 +155,11 @@ private static Condition<Class<?>> isNotAnonymous(DiscoveryIssueReporter issueRe
 	}
 
 	private static DiscoveryIssue createIssue(String prefix, Class<?> testClass, String detailMessage) {
-		String message = "%s class '%s' %s. It will not be executed.".formatted(prefix, testClass.getName(),
-			detailMessage);
+		return createIssue(prefix, testClass, detailMessage, "It will not be executed.");
+	}
+
+	private static DiscoveryIssue createIssue(String prefix, Class<?> testClass, String detailMessage, String effect) {
+		String message = "%s class '%s' %s. %s".formatted(prefix, testClass.getName(), detailMessage, effect);
 		return DiscoveryIssue.builder(DiscoveryIssue.Severity.WARNING, message) //
 				.source(ClassSource.from(testClass)) //
 				.build();
@@ -151,4 +168,9 @@ private static DiscoveryIssue createIssue(String prefix, Class<?> testClass, Str
 	private static boolean isAnnotatedButNotComposed(Class<?> candidate, Class<? extends Annotation> annotationType) {
 		return !candidate.isAnnotation() && isAnnotated(candidate, annotationType);
 	}
+
+	@API(status = INTERNAL, since = "5.13.3")
+	public enum NestedClassInvalidityReason {
+		NOT_INNER, OTHER
+	}
 }


Test patch:
diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/NestedTestClassesTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/NestedTestClassesTests.java
index 68bd39c89366..70a844cd2639 100644
--- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/NestedTestClassesTests.java
+++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/NestedTestClassesTests.java
@@ -274,6 +274,40 @@ private void assertNestedCycle(Class<?> start, Class<?> from, Class<?> to) {
 				.haveExactly(1, finishedWithFailure(message(it -> it.contains(expectedMessage))));
 	}
 
+	@Test
+	void discoversButWarnsAboutTopLevelNestedTestClasses() {
+		var results = discoverTestsForClass(TopLevelNestedTestCase.class);
+
+		var engineDescriptor = results.getEngineDescriptor();
+		assertEquals(2, engineDescriptor.getDescendants().size(), "# resolved test descriptors");
+
+		var discoveryIssues = results.getDiscoveryIssues();
+		assertThat(discoveryIssues).hasSize(1);
+		assertThat(discoveryIssues.getFirst().message()) //
+				.isEqualTo(
+					"Top-level class '%s' must not be annotated with @Nested. "
+							+ "It will be executed anyway for backward compatibility. "
+							+ "You should remove the @Nested annotation to resolve this warning.",
+					TopLevelNestedTestCase.class.getName());
+	}
+
+	@Test
+	void discoversButWarnsAboutStaticNestedTestClasses() {
+		var results = discoverTestsForClass(StaticNestedTestCase.TestCase.class);
+
+		var engineDescriptor = results.getEngineDescriptor();
+		assertEquals(2, engineDescriptor.getDescendants().size(), "# resolved test descriptors");
+
+		var discoveryIssues = results.getDiscoveryIssues();
+		assertThat(discoveryIssues).hasSize(1);
+		assertThat(discoveryIssues.getFirst().message()) //
+				.isEqualTo(
+					"@Nested class '%s' must not be static. "
+							+ "It will only be executed if discovered as a standalone test class. "
+							+ "You should remove the annotation or make it non-static to resolve this warning.",
+					StaticNestedTestCase.TestCase.class.getName());
+	}
+
 	// -------------------------------------------------------------------
 
 	@SuppressWarnings("JUnitMalformedDeclaration")
diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/StaticNestedTestCase.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/StaticNestedTestCase.java
new file mode 100644
index 000000000000..a68851a90ada
--- /dev/null
+++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/StaticNestedTestCase.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2015-2025 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;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class StaticNestedTestCase {
+
+	@SuppressWarnings("JUnitMalformedDeclaration")
+	@Nested
+	static class TestCase {
+		@Test
+		void test() {
+		}
+	}
+
+}
diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/TopLevelNestedTestCase.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/TopLevelNestedTestCase.java
new file mode 100644
index 000000000000..da2b30026e9e
--- /dev/null
+++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/TopLevelNestedTestCase.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2015-2025 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;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+@Nested
+public class TopLevelNestedTestCase {
+
+	@Test
+	void test() {
+	}
+}
diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/discovery/DiscoveryTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/discovery/DiscoveryTests.java
index 045e4be726f7..02fa1eb5b01d 100644
--- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/discovery/DiscoveryTests.java
+++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/discovery/DiscoveryTests.java
@@ -305,8 +305,8 @@ void reportsWarningForInvalidNestedTestClass(LauncherDiscoveryRequest request) {
 				.isEqualTo("@Nested class '%s' must not be private. It will not be executed.",
 					InvalidTestCases.InvalidTestClassTestCase.Inner.class.getName());
 		assertThat(discoveryIssues.getLast().message()) //
-				.isEqualTo("@Nested class '%s' must not be static. It will not be executed.",
-					InvalidTestCases.InvalidTestClassTestCase.Inner.class.getName());
+				.startsWith("@Nested class '%s' must not be static.".formatted(
+					InvalidTestCases.InvalidTestClassTestCase.Inner.class.getName()));
 	}
 
 	static List<Named<LauncherDiscoveryRequest>> requestsForTestClassWithInvalidNestedTestClass() {
diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/discovery/predicates/TestClassPredicatesTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/discovery/predicates/TestClassPredicatesTests.java
index bacd663d33df..34d5f9ae541a 100644
--- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/discovery/predicates/TestClassPredicatesTests.java
+++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/discovery/predicates/TestClassPredicatesTests.java
@@ -254,7 +254,9 @@ void staticNestedClassEvaluatesToFalse() {
 			assertThat(predicates.isAnnotatedWithNestedAndValid).rejects(candidate);
 
 			var issue = DiscoveryIssue.builder(Severity.WARNING,
-				"@Nested class '%s' must not be static. It will not be executed.".formatted(candidate.getName())) //
+				"@Nested class '%s' must not be static. ".formatted(candidate.getName())
+						+ "It will only be executed if discovered as a standalone test class. "
+						+ "You should remove the annotation or make it non-static to resolve this warning.") //
 					.source(ClassSource.from(candidate)) //
 					.build();
 			assertThat(discoveryIssues.stream().distinct()).containsExactly(issue);
@@ -268,8 +270,9 @@ void topLevelClassEvaluatesToFalse() {
 			assertThat(predicates.isAnnotatedWithNestedAndValid).rejects(candidate);
 
 			var issue = DiscoveryIssue.builder(Severity.WARNING,
-				"@Nested class '%s' must not be a top-level class. It will not be executed.".formatted(
-					candidate.getName())) //
+				("Top-level class '%s' must not be annotated with @Nested. ".formatted(candidate.getName())
+						+ "It will be executed anyway for backward compatibility. "
+						+ "You should remove the @Nested annotation to resolve this warning.")) //
 					.source(ClassSource.from(candidate)) //
 					.build();
 			assertThat(discoveryIssues.stream().distinct()).containsExactly(issue);
@@ -312,7 +315,9 @@ class LocalClass {
 			assertThat(predicates.isAnnotatedWithNestedAndValid).rejects(candidate);
 
 			var issue = DiscoveryIssue.builder(Severity.WARNING,
-				"@Nested class '%s' must not be static. It will not be executed.".formatted(candidate.getName())) //
+				"@Nested class '%s' must not be static. ".formatted(candidate.getName())
+						+ "It will only be executed if discovered as a standalone test class. "
+						+ "You should remove the annotation or make it non-static to resolve this warning.") //
 					.source(ClassSource.from(candidate)) //
 					.build();
 			assertThat(discoveryIssues.stream().distinct()).containsExactly(issue);