From 3e0a5447f7177dcbe0bac4ec747e4aa50d0dc039 Mon Sep 17 00:00:00 2001
From: Marc Philipp <mail@marcphilipp.de>
Date: Mon, 17 Mar 2025 17:57:11 +0100
Subject: [PATCH 1/4] Pass `DiscoveryIssueReporter` to `LifecycleMethodUtils`

---
 .../descriptor/ClassBasedTestDescriptor.java  |  18 ++-
 .../descriptor/LifecycleMethodUtils.java      | 138 ++++++++++++------
 .../discovery/DiscoveryIssueReporter.java     |   1 +
 .../descriptor/LifecycleMethodUtilsTests.java | 109 +++++++++-----
 4 files changed, 181 insertions(+), 85 deletions(-)

diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java
index 2e97cc2d385f..dd7c41f40f52 100644
--- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java
@@ -166,8 +166,14 @@ public final JupiterEngineExecutionContext prepare(JupiterEngineExecutionContext
 			registerExtensionsFromConstructorParameters(registry, getTestClass());
 		}
 
-		this.beforeAllMethods = findBeforeAllMethods(getTestClass(), this.classInfo.lifecycle == Lifecycle.PER_METHOD);
-		this.afterAllMethods = findAfterAllMethods(getTestClass(), this.classInfo.lifecycle == Lifecycle.PER_METHOD);
+		this.beforeAllMethods = findBeforeAllMethods(getTestClass(), this.classInfo.lifecycle == Lifecycle.PER_METHOD,
+			issue -> {
+				throw new JUnitException(issue.message());
+			});
+		this.afterAllMethods = findAfterAllMethods(getTestClass(), this.classInfo.lifecycle == Lifecycle.PER_METHOD,
+			issue -> {
+				throw new JUnitException(issue.message());
+			});
 
 		this.beforeAllMethods.forEach(method -> registerExtensionsFromExecutableParameters(registry, method));
 		// Since registerBeforeEachMethodAdapters() and registerAfterEachMethodAdapters() also
@@ -466,13 +472,17 @@ private boolean isPerClassLifecycle(JupiterEngineExecutionContext context) {
 	}
 
 	private void registerBeforeEachMethodAdapters(ExtensionRegistrar registrar) {
-		List<Method> beforeEachMethods = findBeforeEachMethods(getTestClass());
+		List<Method> beforeEachMethods = findBeforeEachMethods(getTestClass(), issue -> {
+			throw new JUnitException(issue.message());
+		});
 		registerMethodsAsExtensions(beforeEachMethods, registrar, this::synthesizeBeforeEachMethodAdapter);
 	}
 
 	private void registerAfterEachMethodAdapters(ExtensionRegistrar registrar) {
 		// Make a local copy since findAfterEachMethods() returns an immutable list.
-		List<Method> afterEachMethods = new ArrayList<>(findAfterEachMethods(getTestClass()));
+		List<Method> afterEachMethods = new ArrayList<>(findAfterEachMethods(getTestClass(), issue -> {
+			throw new JUnitException(issue.message());
+		}));
 
 		// Since the bottom-up ordering of afterEachMethods will later be reversed when the
 		// synthesized AfterEachMethodAdapters are executed within TestMethodTestDescriptor,
diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtils.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtils.java
index 83f60a06ef9a..507660732a68 100644
--- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtils.java
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtils.java
@@ -11,19 +11,26 @@
 package org.junit.jupiter.engine.descriptor;
 
 import static org.junit.platform.commons.support.AnnotationSupport.findAnnotatedMethods;
-import static org.junit.platform.commons.util.ReflectionUtils.returnsPrimitiveVoid;
+import static org.junit.platform.commons.util.CollectionUtils.toUnmodifiableList;
 
 import java.lang.annotation.Annotation;
 import java.lang.reflect.Method;
 import java.util.List;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
 
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
-import org.junit.platform.commons.JUnitException;
 import org.junit.platform.commons.support.HierarchyTraversalMode;
 import org.junit.platform.commons.support.ModifierSupport;
+import org.junit.platform.commons.util.ReflectionUtils;
+import org.junit.platform.engine.DiscoveryIssue;
+import org.junit.platform.engine.DiscoveryIssue.Severity;
+import org.junit.platform.engine.support.descriptor.MethodSource;
+import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter;
 
 /**
  * Collection of utilities for working with test lifecycle methods.
@@ -36,68 +43,117 @@ private LifecycleMethodUtils() {
 		/* no-op */
 	}
 
-	static List<Method> findBeforeAllMethods(Class<?> testClass, boolean requireStatic) {
-		return findMethodsAndAssertStatic(testClass, requireStatic, BeforeAll.class, HierarchyTraversalMode.TOP_DOWN);
+	static List<Method> findBeforeAllMethods(Class<?> testClass, boolean requireStatic,
+			DiscoveryIssueReporter issueReporter) {
+		return findMethodsAndCheckStatic(testClass, requireStatic, BeforeAll.class, HierarchyTraversalMode.TOP_DOWN,
+			issueReporter);
 	}
 
-	static List<Method> findAfterAllMethods(Class<?> testClass, boolean requireStatic) {
-		return findMethodsAndAssertStatic(testClass, requireStatic, AfterAll.class, HierarchyTraversalMode.BOTTOM_UP);
+	static List<Method> findAfterAllMethods(Class<?> testClass, boolean requireStatic,
+			DiscoveryIssueReporter issueReporter) {
+		return findMethodsAndCheckStatic(testClass, requireStatic, AfterAll.class, HierarchyTraversalMode.BOTTOM_UP,
+			issueReporter);
 	}
 
-	static List<Method> findBeforeEachMethods(Class<?> testClass) {
-		return findMethodsAndAssertNonStatic(testClass, BeforeEach.class, HierarchyTraversalMode.TOP_DOWN);
+	static List<Method> findBeforeEachMethods(Class<?> testClass, DiscoveryIssueReporter issueReporter) {
+		return findMethodsAndCheckNonStatic(testClass, BeforeEach.class, HierarchyTraversalMode.TOP_DOWN,
+			issueReporter);
 	}
 
-	static List<Method> findAfterEachMethods(Class<?> testClass) {
-		return findMethodsAndAssertNonStatic(testClass, AfterEach.class, HierarchyTraversalMode.BOTTOM_UP);
+	static List<Method> findAfterEachMethods(Class<?> testClass, DiscoveryIssueReporter issueReporter) {
+		return findMethodsAndCheckNonStatic(testClass, AfterEach.class, HierarchyTraversalMode.BOTTOM_UP,
+			issueReporter);
 	}
 
-	private static List<Method> findMethodsAndAssertStatic(Class<?> testClass, boolean requireStatic,
-			Class<? extends Annotation> annotationType, HierarchyTraversalMode traversalMode) {
+	private static List<Method> findMethodsAndCheckStatic(Class<?> testClass, boolean requireStatic,
+			Class<? extends Annotation> annotationType, HierarchyTraversalMode traversalMode,
+			DiscoveryIssueReporter issueReporter) {
 
-		List<Method> methods = findMethodsAndCheckVoidReturnType(testClass, annotationType, traversalMode);
-		if (requireStatic) {
-			methods.forEach(method -> assertStatic(annotationType, method));
-		}
-		return methods;
+		return findMethodsAndCheckVoidReturnType(testClass, annotationType, traversalMode, issueReporter) //
+				.filter(requireStatic ? isStatic(annotationType, issueReporter) : __ -> true) //
+				.collect(toUnmodifiableList());
 	}
 
-	private static List<Method> findMethodsAndAssertNonStatic(Class<?> testClass,
-			Class<? extends Annotation> annotationType, HierarchyTraversalMode traversalMode) {
+	private static List<Method> findMethodsAndCheckNonStatic(Class<?> testClass,
+			Class<? extends Annotation> annotationType, HierarchyTraversalMode traversalMode,
+			DiscoveryIssueReporter issueReporter) {
 
-		List<Method> methods = findMethodsAndCheckVoidReturnType(testClass, annotationType, traversalMode);
-		methods.forEach(method -> assertNonStatic(annotationType, method));
-		return methods;
+		return findMethodsAndCheckVoidReturnType(testClass, annotationType, traversalMode, issueReporter) //
+				.filter(isNotStatic(annotationType, issueReporter)) //
+				.collect(toUnmodifiableList());
 	}
 
-	private static List<Method> findMethodsAndCheckVoidReturnType(Class<?> testClass,
-			Class<? extends Annotation> annotationType, HierarchyTraversalMode traversalMode) {
+	private static Stream<Method> findMethodsAndCheckVoidReturnType(Class<?> testClass,
+			Class<? extends Annotation> annotationType, HierarchyTraversalMode traversalMode,
+			DiscoveryIssueReporter issueReporter) {
 
-		List<Method> methods = findAnnotatedMethods(testClass, annotationType, traversalMode);
-		methods.forEach(method -> assertVoid(annotationType, method));
-		return methods;
+		return findAnnotatedMethods(testClass, annotationType, traversalMode).stream() //
+				.filter(returnsPrimitiveVoid(annotationType, issueReporter));
 	}
 
-	private static void assertStatic(Class<? extends Annotation> annotationType, Method method) {
-		if (ModifierSupport.isNotStatic(method)) {
-			throw new JUnitException(String.format(
+	private static Predicate<Method> isStatic(Class<? extends Annotation> annotationType,
+			DiscoveryIssueReporter issueReporter) {
+		return DiscoveryIssueReportingPredicate.from(issueReporter, ModifierSupport::isStatic, method -> {
+			String message = String.format(
 				"@%s method '%s' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).",
-				annotationType.getSimpleName(), method.toGenericString()));
-		}
+				annotationType.getSimpleName(), method.toGenericString());
+			return DiscoveryIssue.builder(Severity.ERROR, message).source(MethodSource.from(method)).build();
+		});
 	}
 
-	private static void assertNonStatic(Class<? extends Annotation> annotationType, Method method) {
-		if (ModifierSupport.isStatic(method)) {
-			throw new JUnitException(String.format("@%s method '%s' must not be static.",
-				annotationType.getSimpleName(), method.toGenericString()));
-		}
+	private static Predicate<Method> isNotStatic(Class<? extends Annotation> annotationType,
+			DiscoveryIssueReporter issueReporter) {
+		return DiscoveryIssueReportingPredicate.from(issueReporter, ModifierSupport::isNotStatic, method -> {
+			String message = String.format("@%s method '%s' must not be static.", annotationType.getSimpleName(),
+				method.toGenericString());
+			return DiscoveryIssue.builder(Severity.ERROR, message).source(MethodSource.from(method)).build();
+		});
 	}
 
-	private static void assertVoid(Class<? extends Annotation> annotationType, Method method) {
-		if (!returnsPrimitiveVoid(method)) {
-			throw new JUnitException(String.format("@%s method '%s' must not return a value.",
-				annotationType.getSimpleName(), method.toGenericString()));
+	private static Predicate<Method> returnsPrimitiveVoid(Class<? extends Annotation> annotationType,
+			DiscoveryIssueReporter issueReporter) {
+		return DiscoveryIssueReportingPredicate.from(issueReporter, ReflectionUtils::returnsPrimitiveVoid, method -> {
+			String message = String.format("@%s method '%s' must not return a value.", annotationType.getSimpleName(),
+				method.toGenericString());
+			return DiscoveryIssue.builder(Severity.ERROR, message).source(MethodSource.from(method)).build();
+		});
+	}
+
+	abstract static class DiscoveryIssueReportingPredicate<T> implements Predicate<T> {
+
+		static <T> DiscoveryIssueReportingPredicate<T> from(DiscoveryIssueReporter reporter, Predicate<T> predicate,
+				Function<T, DiscoveryIssue> issueBuilder) {
+			return new DiscoveryIssueReportingPredicate<T>(reporter) {
+				@Override
+				protected boolean checkCondition(T t) {
+					return predicate.test(t);
+				}
+
+				@Override
+				protected DiscoveryIssue createIssue(T t) {
+					return issueBuilder.apply(t);
+				}
+			};
+		}
+
+		private final DiscoveryIssueReporter reporter;
+
+		protected DiscoveryIssueReportingPredicate(DiscoveryIssueReporter reporter) {
+			this.reporter = reporter;
+		}
+
+		@Override
+		public final boolean test(T t) {
+			if (checkCondition(t)) {
+				return true;
+			}
+			reporter.reportIssue(createIssue(t));
+			return false;
 		}
+
+		protected abstract boolean checkCondition(T t);
+
+		protected abstract DiscoveryIssue createIssue(T t);
 	}
 
 }
diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/discovery/DiscoveryIssueReporter.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/discovery/DiscoveryIssueReporter.java
index 39a931891c09..a087b0502e35 100644
--- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/discovery/DiscoveryIssueReporter.java
+++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/discovery/DiscoveryIssueReporter.java
@@ -23,6 +23,7 @@
  * @see SelectorResolver.Context
  */
 @API(status = EXPERIMENTAL, since = "1.13")
+@FunctionalInterface
 public interface DiscoveryIssueReporter {
 
 	/**
diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtilsTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtilsTests.java
index 719f335679bf..55d754fa52e6 100644
--- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtilsTests.java
+++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtilsTests.java
@@ -10,15 +10,16 @@
 
 package org.junit.jupiter.engine.descriptor;
 
+import static java.util.function.Predicate.isEqual;
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.engine.descriptor.LifecycleMethodUtils.findAfterAllMethods;
 import static org.junit.jupiter.engine.descriptor.LifecycleMethodUtils.findAfterEachMethods;
 import static org.junit.jupiter.engine.descriptor.LifecycleMethodUtils.findBeforeAllMethods;
 import static org.junit.jupiter.engine.descriptor.LifecycleMethodUtils.findBeforeEachMethods;
+import static org.junit.platform.commons.util.FunctionUtils.where;
 
 import java.lang.reflect.Method;
+import java.util.ArrayList;
 import java.util.List;
 
 import org.junit.jupiter.api.AfterAll;
@@ -28,7 +29,9 @@
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestInstance;
 import org.junit.jupiter.api.TestInstance.Lifecycle;
-import org.junit.platform.commons.JUnitException;
+import org.junit.platform.engine.DiscoveryIssue;
+import org.junit.platform.engine.DiscoveryIssue.Severity;
+import org.junit.platform.engine.support.descriptor.MethodSource;
 
 /**
  * Unit tests for {@link LifecycleMethodUtils}.
@@ -37,94 +40,120 @@
  */
 class LifecycleMethodUtilsTests {
 
+	List<DiscoveryIssue> discoveryIssues = new ArrayList<>();
+
 	@Test
-	void findNonVoidBeforeAllMethodsWithStandardLifecycle() {
-		JUnitException exception = assertThrows(JUnitException.class,
-			() -> findBeforeAllMethods(TestCaseWithNonVoidLifecyleMethods.class, true));
-		assertEquals(
-			"@BeforeAll method 'java.lang.Double org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.cc()' must not return a value.",
-			exception.getMessage());
+	void findNonVoidBeforeAllMethodsWithStandardLifecycle() throws Exception {
+		var methods = findBeforeAllMethods(TestCaseWithNonVoidLifecyleMethods.class, true, discoveryIssues::add);
+		assertThat(methods).isEmpty();
+
+		var expectedIssue = DiscoveryIssue.builder(Severity.ERROR,
+			"@BeforeAll method 'java.lang.Double org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.cc()' must not return a value.") //
+				.source(MethodSource.from(TestCaseWithNonVoidLifecyleMethods.class.getDeclaredMethod("cc"))) //
+				.build();
+		assertThat(discoveryIssues).containsExactly(expectedIssue);
 	}
 
 	@Test
-	void findNonVoidAfterAllMethodsWithStandardLifecycle() {
-		JUnitException exception = assertThrows(JUnitException.class,
-			() -> findAfterAllMethods(TestCaseWithNonVoidLifecyleMethods.class, true));
-		assertEquals(
-			"@AfterAll method 'java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.dd()' must not return a value.",
-			exception.getMessage());
+	void findNonVoidAfterAllMethodsWithStandardLifecycle() throws Exception {
+		var methods = findAfterAllMethods(TestCaseWithNonVoidLifecyleMethods.class, true, discoveryIssues::add);
+		assertThat(methods).isEmpty();
+
+		var expectedIssue = DiscoveryIssue.builder(Severity.ERROR,
+			"@AfterAll method 'java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.dd()' must not return a value.") //
+				.source(MethodSource.from(TestCaseWithNonVoidLifecyleMethods.class.getDeclaredMethod("dd"))) //
+				.build();
+		assertThat(discoveryIssues).containsExactly(expectedIssue);
 	}
 
 	@Test
-	void findNonVoidBeforeEachMethodsWithStandardLifecycle() {
-		JUnitException exception = assertThrows(JUnitException.class,
-			() -> findBeforeEachMethods(TestCaseWithNonVoidLifecyleMethods.class));
-		assertEquals(
-			"@BeforeEach method 'java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.aa()' must not return a value.",
-			exception.getMessage());
+	void findNonVoidBeforeEachMethodsWithStandardLifecycle() throws Exception {
+		var methods = findBeforeEachMethods(TestCaseWithNonVoidLifecyleMethods.class, discoveryIssues::add);
+		assertThat(methods).isEmpty();
+
+		var expectedIssue = DiscoveryIssue.builder(Severity.ERROR,
+			"@BeforeEach method 'java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.aa()' must not return a value.") //
+				.source(MethodSource.from(TestCaseWithNonVoidLifecyleMethods.class.getDeclaredMethod("aa"))) //
+				.build();
+		assertThat(discoveryIssues).containsExactly(expectedIssue);
 	}
 
 	@Test
-	void findNonVoidAfterEachMethodsWithStandardLifecycle() {
-		JUnitException exception = assertThrows(JUnitException.class,
-			() -> findAfterEachMethods(TestCaseWithNonVoidLifecyleMethods.class));
-		assertEquals(
-			"@AfterEach method 'int org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.bb()' must not return a value.",
-			exception.getMessage());
+	void findNonVoidAfterEachMethodsWithStandardLifecycle() throws Exception {
+		var methods = findAfterEachMethods(TestCaseWithNonVoidLifecyleMethods.class, discoveryIssues::add);
+		assertThat(methods).isEmpty();
+
+		var expectedIssue = DiscoveryIssue.builder(Severity.ERROR,
+			"@AfterEach method 'int org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.bb()' must not return a value.") //
+				.source(MethodSource.from(TestCaseWithNonVoidLifecyleMethods.class.getDeclaredMethod("bb"))) //
+				.build();
+		assertThat(discoveryIssues).containsExactly(expectedIssue);
 	}
 
 	@Test
 	void findBeforeEachMethodsWithStandardLifecycle() {
-		List<Method> methods = findBeforeEachMethods(TestCaseWithStandardLifecycle.class);
+		List<Method> methods = findBeforeEachMethods(TestCaseWithStandardLifecycle.class, discoveryIssues::add);
 
 		assertThat(namesOf(methods)).containsExactlyInAnyOrder("nine", "ten");
+		assertThat(discoveryIssues).isEmpty();
 	}
 
 	@Test
 	void findAfterEachMethodsWithStandardLifecycle() {
-		List<Method> methods = findAfterEachMethods(TestCaseWithStandardLifecycle.class);
+		List<Method> methods = findAfterEachMethods(TestCaseWithStandardLifecycle.class, discoveryIssues::add);
 
 		assertThat(namesOf(methods)).containsExactlyInAnyOrder("eleven", "twelve");
 	}
 
 	@Test
 	void findBeforeAllMethodsWithStandardLifecycleAndWithoutRequiringStatic() {
-		List<Method> methods = findBeforeAllMethods(TestCaseWithStandardLifecycle.class, false);
+		List<Method> methods = findBeforeAllMethods(TestCaseWithStandardLifecycle.class, false, discoveryIssues::add);
 
 		assertThat(namesOf(methods)).containsExactly("one");
+		assertThat(discoveryIssues).isEmpty();
 	}
 
 	@Test
-	void findBeforeAllMethodsWithStandardLifecycleAndRequiringStatic() {
-		JUnitException exception = assertThrows(JUnitException.class,
-			() -> findBeforeAllMethods(TestCaseWithStandardLifecycle.class, true));
-		assertEquals(
-			"@BeforeAll method 'void org.junit.jupiter.engine.descriptor.TestCaseWithStandardLifecycle.one()' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).",
-			exception.getMessage());
+	void findBeforeAllMethodsWithStandardLifecycleAndRequiringStatic() throws Exception {
+		var methods = findBeforeAllMethods(TestCaseWithStandardLifecycle.class, true, discoveryIssues::add);
+		assertThat(methods).isEmpty();
+
+		var expectedIssue = DiscoveryIssue.builder(Severity.ERROR,
+			"@BeforeAll method 'void org.junit.jupiter.engine.descriptor.TestCaseWithStandardLifecycle.one()' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).") //
+				.source(MethodSource.from(TestCaseWithStandardLifecycle.class.getDeclaredMethod("one"))) //
+				.build();
+		assertThat(discoveryIssues).containsExactly(expectedIssue);
 	}
 
 	@Test
 	void findBeforeAllMethodsWithLifeCyclePerClassAndRequiringStatic() {
-		List<Method> methods = findBeforeAllMethods(TestCaseWithLifecyclePerClass.class, false);
+		List<Method> methods = findBeforeAllMethods(TestCaseWithLifecyclePerClass.class, false, discoveryIssues::add);
 
 		assertThat(namesOf(methods)).containsExactlyInAnyOrder("three", "four");
+		assertThat(discoveryIssues).isEmpty();
 	}
 
 	@Test
 	void findAfterAllMethodsWithStandardLifecycleAndWithoutRequiringStatic() {
-		List<Method> methods = findAfterAllMethods(TestCaseWithStandardLifecycle.class, false);
+		List<Method> methods = findAfterAllMethods(TestCaseWithStandardLifecycle.class, false, discoveryIssues::add);
 
 		assertThat(namesOf(methods)).containsExactlyInAnyOrder("five", "six");
+		assertThat(discoveryIssues).isEmpty();
 	}
 
 	@Test
 	void findAfterAllMethodsWithStandardLifecycleAndRequiringStatic() {
-		assertThrows(JUnitException.class, () -> findAfterAllMethods(TestCaseWithStandardLifecycle.class, true));
+		var methods = findAfterAllMethods(TestCaseWithStandardLifecycle.class, true, discoveryIssues::add);
+		assertThat(methods).isEmpty();
+
+		assertThat(discoveryIssues) //
+				.filteredOn(where(DiscoveryIssue::severity, isEqual(Severity.ERROR))) //
+				.isNotEmpty();
 	}
 
 	@Test
 	void findAfterAllMethodsWithLifeCyclePerClassAndRequiringStatic() {
-		List<Method> methods = findAfterAllMethods(TestCaseWithLifecyclePerClass.class, false);
+		List<Method> methods = findAfterAllMethods(TestCaseWithLifecyclePerClass.class, false, discoveryIssues::add);
 
 		assertThat(namesOf(methods)).containsExactlyInAnyOrder("seven", "eight");
 	}

From 050c13249e6c2b059c5ddbf8711df5d447655725 Mon Sep 17 00:00:00 2001
From: Marc Philipp <mail@marcphilipp.de>
Date: Mon, 17 Mar 2025 17:57:25 +0100
Subject: [PATCH 2/4] Report invalid lifecycle methods as discovery issues

Issue: #242
---
 .../descriptor/ClassBasedTestDescriptor.java  | 72 +++++++++++++------
 .../descriptor/LifecycleMethodUtils.java      | 45 ++++++++----
 .../engine/descriptor/Validatable.java        | 32 +++++++++
 .../discovery/DiscoverySelectorResolver.java  | 14 ++--
 .../src/nativeImage/initialize-at-build-time  |  1 +
 ...alidLifecycleMethodConfigurationTests.java | 62 ++++++----------
 ...stInstanceLifecycleConfigurationTests.java |  6 +-
 .../descriptor/LifecycleMethodUtilsTests.java | 16 +++--
 8 files changed, 158 insertions(+), 90 deletions(-)
 create mode 100644 junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/Validatable.java

diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java
index dd7c41f40f52..7186b34f3226 100644
--- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/ClassBasedTestDescriptor.java
@@ -71,10 +71,12 @@
 import org.junit.platform.commons.util.ReflectionUtils;
 import org.junit.platform.commons.util.StringUtils;
 import org.junit.platform.commons.util.UnrecoverableExceptions;
+import org.junit.platform.engine.DiscoveryIssue;
 import org.junit.platform.engine.TestDescriptor;
 import org.junit.platform.engine.TestTag;
 import org.junit.platform.engine.UniqueId;
 import org.junit.platform.engine.support.descriptor.ClassSource;
+import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter;
 import org.junit.platform.engine.support.hierarchical.ThrowableCollector;
 
 /**
@@ -84,21 +86,21 @@
  */
 @API(status = INTERNAL, since = "5.5")
 public abstract class ClassBasedTestDescriptor extends JupiterTestDescriptor
-		implements ResourceLockAware, TestClassAware {
+		implements ResourceLockAware, TestClassAware, Validatable {
 
 	private static final InterceptingExecutableInvoker executableInvoker = new InterceptingExecutableInvoker();
 
 	protected final ClassInfo classInfo;
 
+	private LifecycleMethods lifecycleMethods;
 	private TestInstanceFactory testInstanceFactory;
-	private List<Method> beforeAllMethods;
-	private List<Method> afterAllMethods;
 
 	ClassBasedTestDescriptor(UniqueId uniqueId, Class<?> testClass, Supplier<String> displayNameSupplier,
 			JupiterConfiguration configuration) {
 		super(uniqueId, testClass, displayNameSupplier, ClassSource.from(testClass), configuration);
 
 		this.classInfo = new ClassInfo(testClass, configuration);
+		this.lifecycleMethods = new LifecycleMethods(this.classInfo);
 	}
 
 	ClassBasedTestDescriptor(UniqueId uniqueId, Class<?> testClass, String displayName,
@@ -106,6 +108,7 @@ public abstract class ClassBasedTestDescriptor extends JupiterTestDescriptor
 		super(uniqueId, displayName, ClassSource.from(testClass), configuration);
 
 		this.classInfo = new ClassInfo(testClass, configuration);
+		this.lifecycleMethods = new LifecycleMethods(this.classInfo);
 	}
 
 	// --- TestClassAware ------------------------------------------------------
@@ -127,6 +130,15 @@ public final String getLegacyReportingName() {
 		return getTestClass().getName();
 	}
 
+	// --- Validatable ---------------------------------------------------------
+
+	@Override
+	public void validate(DiscoveryIssueReporter reporter) {
+		List<DiscoveryIssue> discoveryIssues = this.lifecycleMethods.discoveryIssues;
+		discoveryIssues.forEach(reporter::reportIssue);
+		discoveryIssues.clear();
+	}
+
 	// --- Node ----------------------------------------------------------------
 
 	@Override
@@ -166,16 +178,7 @@ public final JupiterEngineExecutionContext prepare(JupiterEngineExecutionContext
 			registerExtensionsFromConstructorParameters(registry, getTestClass());
 		}
 
-		this.beforeAllMethods = findBeforeAllMethods(getTestClass(), this.classInfo.lifecycle == Lifecycle.PER_METHOD,
-			issue -> {
-				throw new JUnitException(issue.message());
-			});
-		this.afterAllMethods = findAfterAllMethods(getTestClass(), this.classInfo.lifecycle == Lifecycle.PER_METHOD,
-			issue -> {
-				throw new JUnitException(issue.message());
-			});
-
-		this.beforeAllMethods.forEach(method -> registerExtensionsFromExecutableParameters(registry, method));
+		this.lifecycleMethods.beforeAll.forEach(method -> registerExtensionsFromExecutableParameters(registry, method));
 		// Since registerBeforeEachMethodAdapters() and registerAfterEachMethodAdapters() also
 		// invoke registerExtensionsFromExecutableParameters(), we invoke those methods before
 		// invoking registerExtensionsFromExecutableParameters() for @AfterAll methods,
@@ -183,7 +186,7 @@ public final JupiterEngineExecutionContext prepare(JupiterEngineExecutionContext
 		// on parameters in lifecycle methods.
 		registerBeforeEachMethodAdapters(registry);
 		registerAfterEachMethodAdapters(registry);
-		this.afterAllMethods.forEach(method -> registerExtensionsFromExecutableParameters(registry, method));
+		this.lifecycleMethods.afterAll.forEach(method -> registerExtensionsFromExecutableParameters(registry, method));
 		registerExtensionsFromInstanceFields(registry, getTestClass());
 
 		ThrowableCollector throwableCollector = createThrowableCollector();
@@ -258,6 +261,13 @@ public final void after(JupiterEngineExecutionContext context) {
 		}
 	}
 
+	@Override
+	public void cleanUp(JupiterEngineExecutionContext context) throws Exception {
+		super.cleanUp(context);
+		this.lifecycleMethods = null;
+		this.testInstanceFactory = null;
+	}
+
 	private TestInstanceFactory resolveTestInstanceFactory(ExtensionRegistry registry) {
 		List<TestInstanceFactory> factories = registry.getExtensions(TestInstanceFactory.class);
 
@@ -410,7 +420,7 @@ private void invokeBeforeAllMethods(JupiterEngineExecutionContext context) {
 		ThrowableCollector throwableCollector = context.getThrowableCollector();
 		Object testInstance = extensionContext.getTestInstance().orElse(null);
 
-		for (Method method : this.beforeAllMethods) {
+		for (Method method : this.lifecycleMethods.beforeAll) {
 			throwableCollector.execute(() -> {
 				try {
 					executableInvoker.invoke(method, testInstance, extensionContext, registry,
@@ -439,7 +449,7 @@ private void invokeAfterAllMethods(JupiterEngineExecutionContext context) {
 		ThrowableCollector throwableCollector = context.getThrowableCollector();
 		Object testInstance = extensionContext.getTestInstance().orElse(null);
 
-		this.afterAllMethods.forEach(method -> throwableCollector.execute(() -> {
+		this.lifecycleMethods.afterAll.forEach(method -> throwableCollector.execute(() -> {
 			try {
 				executableInvoker.invoke(method, testInstance, extensionContext, registry,
 					ReflectiveInterceptorCall.ofVoidMethod(InvocationInterceptor::interceptAfterAllMethod));
@@ -472,17 +482,13 @@ private boolean isPerClassLifecycle(JupiterEngineExecutionContext context) {
 	}
 
 	private void registerBeforeEachMethodAdapters(ExtensionRegistrar registrar) {
-		List<Method> beforeEachMethods = findBeforeEachMethods(getTestClass(), issue -> {
-			throw new JUnitException(issue.message());
-		});
-		registerMethodsAsExtensions(beforeEachMethods, registrar, this::synthesizeBeforeEachMethodAdapter);
+		registerMethodsAsExtensions(this.lifecycleMethods.beforeEach, registrar,
+			this::synthesizeBeforeEachMethodAdapter);
 	}
 
 	private void registerAfterEachMethodAdapters(ExtensionRegistrar registrar) {
 		// Make a local copy since findAfterEachMethods() returns an immutable list.
-		List<Method> afterEachMethods = new ArrayList<>(findAfterEachMethods(getTestClass(), issue -> {
-			throw new JUnitException(issue.message());
-		}));
+		List<Method> afterEachMethods = new ArrayList<>(this.lifecycleMethods.afterEach);
 
 		// Since the bottom-up ordering of afterEachMethods will later be reversed when the
 		// synthesized AfterEachMethodAdapters are executed within TestMethodTestDescriptor,
@@ -523,6 +529,7 @@ private void invokeMethodInExtensionContext(Method method, ExtensionContext cont
 	}
 
 	protected static class ClassInfo {
+
 		final Class<?> testClass;
 		final Set<TestTag> tags;
 		final Lifecycle lifecycle;
@@ -538,4 +545,23 @@ protected static class ClassInfo {
 		}
 	}
 
+	private static class LifecycleMethods {
+
+		private final List<DiscoveryIssue> discoveryIssues = new ArrayList<>();
+
+		private final List<Method> beforeAll;
+		private final List<Method> afterAll;
+		private final List<Method> beforeEach;
+		private final List<Method> afterEach;
+
+		LifecycleMethods(ClassInfo classInfo) {
+			Class<?> testClass = classInfo.testClass;
+			boolean requireStatic = classInfo.lifecycle == Lifecycle.PER_METHOD;
+			this.beforeAll = findBeforeAllMethods(testClass, requireStatic, discoveryIssues::add);
+			this.afterAll = findAfterAllMethods(testClass, requireStatic, discoveryIssues::add);
+			this.beforeEach = findBeforeEachMethods(testClass, discoveryIssues::add);
+			this.afterEach = findAfterEachMethods(testClass, discoveryIssues::add);
+		}
+	}
+
 }
diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtils.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtils.java
index 507660732a68..f277ab66a46b 100644
--- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtils.java
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtils.java
@@ -18,7 +18,6 @@
 import java.util.List;
 import java.util.function.Function;
 import java.util.function.Predicate;
-import java.util.stream.Stream;
 
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.AfterEach;
@@ -26,6 +25,7 @@
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.platform.commons.support.HierarchyTraversalMode;
 import org.junit.platform.commons.support.ModifierSupport;
+import org.junit.platform.commons.util.Preconditions;
 import org.junit.platform.commons.util.ReflectionUtils;
 import org.junit.platform.engine.DiscoveryIssue;
 import org.junit.platform.engine.DiscoveryIssue.Severity;
@@ -69,31 +69,31 @@ private static List<Method> findMethodsAndCheckStatic(Class<?> testClass, boolea
 			Class<? extends Annotation> annotationType, HierarchyTraversalMode traversalMode,
 			DiscoveryIssueReporter issueReporter) {
 
-		return findMethodsAndCheckVoidReturnType(testClass, annotationType, traversalMode, issueReporter) //
-				.filter(requireStatic ? isStatic(annotationType, issueReporter) : __ -> true) //
-				.collect(toUnmodifiableList());
+		Predicate<Method> additionalCondition = requireStatic ? isStatic(annotationType, issueReporter) : __ -> true;
+		return findMethodsAndCheckVoidReturnType(testClass, annotationType, traversalMode, issueReporter,
+			additionalCondition);
 	}
 
 	private static List<Method> findMethodsAndCheckNonStatic(Class<?> testClass,
 			Class<? extends Annotation> annotationType, HierarchyTraversalMode traversalMode,
 			DiscoveryIssueReporter issueReporter) {
 
-		return findMethodsAndCheckVoidReturnType(testClass, annotationType, traversalMode, issueReporter) //
-				.filter(isNotStatic(annotationType, issueReporter)) //
-				.collect(toUnmodifiableList());
+		return findMethodsAndCheckVoidReturnType(testClass, annotationType, traversalMode, issueReporter,
+			isNotStatic(annotationType, issueReporter));
 	}
 
-	private static Stream<Method> findMethodsAndCheckVoidReturnType(Class<?> testClass,
+	private static List<Method> findMethodsAndCheckVoidReturnType(Class<?> testClass,
 			Class<? extends Annotation> annotationType, HierarchyTraversalMode traversalMode,
-			DiscoveryIssueReporter issueReporter) {
+			DiscoveryIssueReporter issueReporter, Predicate<? super Method> additionalCondition) {
 
 		return findAnnotatedMethods(testClass, annotationType, traversalMode).stream() //
-				.filter(returnsPrimitiveVoid(annotationType, issueReporter));
+				.filter(allOf(returnsPrimitiveVoid(annotationType, issueReporter), additionalCondition)) //
+				.collect(toUnmodifiableList());
 	}
 
 	private static Predicate<Method> isStatic(Class<? extends Annotation> annotationType,
 			DiscoveryIssueReporter issueReporter) {
-		return DiscoveryIssueReportingPredicate.from(issueReporter, ModifierSupport::isStatic, method -> {
+		return DiscoveryIssueReportingPredicate.from(ModifierSupport::isStatic, issueReporter, method -> {
 			String message = String.format(
 				"@%s method '%s' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).",
 				annotationType.getSimpleName(), method.toGenericString());
@@ -103,7 +103,7 @@ private static Predicate<Method> isStatic(Class<? extends Annotation> annotation
 
 	private static Predicate<Method> isNotStatic(Class<? extends Annotation> annotationType,
 			DiscoveryIssueReporter issueReporter) {
-		return DiscoveryIssueReportingPredicate.from(issueReporter, ModifierSupport::isNotStatic, method -> {
+		return DiscoveryIssueReportingPredicate.from(ModifierSupport::isNotStatic, issueReporter, method -> {
 			String message = String.format("@%s method '%s' must not be static.", annotationType.getSimpleName(),
 				method.toGenericString());
 			return DiscoveryIssue.builder(Severity.ERROR, message).source(MethodSource.from(method)).build();
@@ -112,16 +112,31 @@ private static Predicate<Method> isNotStatic(Class<? extends Annotation> annotat
 
 	private static Predicate<Method> returnsPrimitiveVoid(Class<? extends Annotation> annotationType,
 			DiscoveryIssueReporter issueReporter) {
-		return DiscoveryIssueReportingPredicate.from(issueReporter, ReflectionUtils::returnsPrimitiveVoid, method -> {
+		return DiscoveryIssueReportingPredicate.from(ReflectionUtils::returnsPrimitiveVoid, issueReporter, method -> {
 			String message = String.format("@%s method '%s' must not return a value.", annotationType.getSimpleName(),
 				method.toGenericString());
 			return DiscoveryIssue.builder(Severity.ERROR, message).source(MethodSource.from(method)).build();
 		});
 	}
 
-	abstract static class DiscoveryIssueReportingPredicate<T> implements Predicate<T> {
+	@SafeVarargs
+	@SuppressWarnings("varargs")
+	private static <T> Predicate<T> allOf(Predicate<? super T>... predicates) {
+		Preconditions.notNull(predicates, "predicates must not be null");
+		Preconditions.notEmpty(predicates, "predicates must not be empty");
+		Preconditions.containsNoNullElements(predicates, "predicates must not contain null elements");
+		return value -> {
+			boolean result = true;
+			for (Predicate<? super T> predicate : predicates) {
+				result &= predicate.test(value);
+			}
+			return result;
+		};
+	}
+
+	private abstract static class DiscoveryIssueReportingPredicate<T> implements Predicate<T> {
 
-		static <T> DiscoveryIssueReportingPredicate<T> from(DiscoveryIssueReporter reporter, Predicate<T> predicate,
+		static <T> DiscoveryIssueReportingPredicate<T> from(Predicate<T> predicate, DiscoveryIssueReporter reporter,
 				Function<T, DiscoveryIssue> issueBuilder) {
 			return new DiscoveryIssueReportingPredicate<T>(reporter) {
 				@Override
diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/Validatable.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/Validatable.java
new file mode 100644
index 000000000000..b4c83e1944ad
--- /dev/null
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/Validatable.java
@@ -0,0 +1,32 @@
+/*
+ * 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.descriptor;
+
+import static org.apiguardian.api.API.Status.INTERNAL;
+
+import org.apiguardian.api.API;
+import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter;
+
+/**
+ * Interface for descriptors that can be validated during discovery.
+ *
+ * @since 5.13
+ */
+@API(status = INTERNAL, since = "5.13")
+public interface Validatable {
+
+	/**
+	 * Validate the state of this descriptor and report any issues found to the
+	 * supplied {@link DiscoveryIssueReporter}.
+	 */
+	void validate(DiscoveryIssueReporter reporter);
+
+}
diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/DiscoverySelectorResolver.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/DiscoverySelectorResolver.java
index 89a79c679dec..1198d6da5a6d 100644
--- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/DiscoverySelectorResolver.java
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/DiscoverySelectorResolver.java
@@ -16,6 +16,7 @@
 import org.junit.jupiter.engine.config.JupiterConfiguration;
 import org.junit.jupiter.engine.descriptor.JupiterEngineDescriptor;
 import org.junit.jupiter.engine.descriptor.JupiterTestDescriptor;
+import org.junit.jupiter.engine.descriptor.Validatable;
 import org.junit.jupiter.engine.discovery.predicates.IsTestClassWithTests;
 import org.junit.platform.engine.EngineDiscoveryRequest;
 import org.junit.platform.engine.TestDescriptor;
@@ -44,12 +45,15 @@ public class DiscoverySelectorResolver {
 				new ClassOrderingVisitor(getConfiguration(ctx)), //
 				new MethodOrderingVisitor(getConfiguration(ctx)), //
 				descriptor -> {
-					if (descriptor instanceof JupiterTestDescriptor) {
-						((JupiterTestDescriptor) descriptor).prunePriorToFiltering();
+					if (descriptor instanceof Validatable) {
+						((Validatable) descriptor).validate(ctx.getIssueReporter());
 					}
-				} //
-			)) //
-			.build();
+				})) //
+			.addTestDescriptorVisitor(ctx -> descriptor -> {
+				if (descriptor instanceof JupiterTestDescriptor) {
+					((JupiterTestDescriptor) descriptor).prunePriorToFiltering();
+				}
+			}).build();
 
 	private static JupiterConfiguration getConfiguration(InitializationContext<JupiterEngineDescriptor> context) {
 		return context.getEngineDescriptor().getConfiguration();
diff --git a/junit-jupiter-engine/src/nativeImage/initialize-at-build-time b/junit-jupiter-engine/src/nativeImage/initialize-at-build-time
index df2adf10a9ba..4e405ef9656f 100644
--- a/junit-jupiter-engine/src/nativeImage/initialize-at-build-time
+++ b/junit-jupiter-engine/src/nativeImage/initialize-at-build-time
@@ -6,6 +6,7 @@ org.junit.jupiter.engine.config.InstantiatingConfigurationParameterConverter
 org.junit.jupiter.engine.descriptor.ClassTestDescriptor
 org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor
 org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor$ClassInfo
+org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor$LifecycleMethods
 org.junit.jupiter.engine.descriptor.DynamicDescendantFilter
 org.junit.jupiter.engine.descriptor.ExclusiveResourceCollector$1
 org.junit.jupiter.engine.descriptor.JupiterEngineDescriptor
diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/InvalidLifecycleMethodConfigurationTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/InvalidLifecycleMethodConfigurationTests.java
index e1d7a47cfce6..a17b1fb8d4a6 100644
--- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/InvalidLifecycleMethodConfigurationTests.java
+++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/InvalidLifecycleMethodConfigurationTests.java
@@ -10,82 +10,64 @@
 
 package org.junit.jupiter.engine;
 
-import static org.junit.jupiter.api.Assertions.assertAll;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
+import static java.util.function.Predicate.isEqual;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.platform.commons.util.FunctionUtils.where;
+
+import java.lang.annotation.Annotation;
 
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
-import org.junit.platform.testkit.engine.EngineExecutionResults;
-import org.junit.platform.testkit.engine.Events;
+import org.junit.platform.engine.DiscoveryIssue;
+import org.junit.platform.engine.DiscoveryIssue.Severity;
 
 /**
  * Integration tests that verify proper handling of invalid configuration for
  * lifecycle methods in conjunction with the {@link JupiterTestEngine}.
  *
- * <p>In general, configuration errors should not be thrown until the
- * execution phase, thereby giving all containers a chance to execute.
- *
  * @since 5.0
  */
 class InvalidLifecycleMethodConfigurationTests extends AbstractJupiterTestEngineTests {
 
 	@Test
 	void executeValidTestCaseAlongsideTestCaseWithInvalidNonStaticBeforeAllDeclaration() {
-		assertContainerFailed(TestCaseWithInvalidNonStaticBeforeAllMethod.class);
+		assertReportsError(TestCaseWithInvalidNonStaticBeforeAllMethod.class, BeforeAll.class);
 	}
 
 	@Test
 	void executeValidTestCaseAlongsideTestCaseWithInvalidNonStaticAfterAllDeclaration() {
-		assertContainerFailed(TestCaseWithInvalidNonStaticAfterAllMethod.class);
+		assertReportsError(TestCaseWithInvalidNonStaticAfterAllMethod.class, AfterAll.class);
 	}
 
 	@Test
 	void executeValidTestCaseAlongsideTestCaseWithInvalidStaticBeforeEachDeclaration() {
-		assertContainerFailed(TestCaseWithInvalidStaticBeforeEachMethod.class);
+		assertReportsError(TestCaseWithInvalidStaticBeforeEachMethod.class, BeforeEach.class);
 	}
 
 	@Test
 	void executeValidTestCaseAlongsideTestCaseWithInvalidStaticAfterEachDeclaration() {
-		assertContainerFailed(TestCaseWithInvalidStaticAfterEachMethod.class);
+		assertReportsError(TestCaseWithInvalidStaticAfterEachMethod.class, AfterEach.class);
 	}
 
-	private void assertContainerFailed(Class<?> invalidTestClass) {
-		EngineExecutionResults executionResults = executeTests(selectClass(TestCase.class),
-			selectClass(invalidTestClass));
-		Events containers = executionResults.containerEvents();
-		Events tests = executionResults.testEvents();
-
-		// @formatter:off
-		assertAll(
-			() -> assertEquals(3, containers.started().count(), "# containers started"),
-			() -> assertEquals(1, tests.started().count(), "# tests started"),
-			() -> assertEquals(1, tests.succeeded().count(), "# tests succeeded"),
-			() -> assertEquals(0, tests.failed().count(), "# tests failed"),
-			() -> assertEquals(3, containers.finished().count(), "# containers finished"),
-			() -> assertEquals(1, containers.failed().count(), "# containers failed")
-		);
-		// @formatter:on
+	private void assertReportsError(Class<?> invalidTestClass, Class<? extends Annotation> annotationType) {
+		var results = discoverTestsForClass(invalidTestClass);
+
+		assertThat(results.getDiscoveryIssues()) //
+				.filteredOn(where(DiscoveryIssue::severity, isEqual(Severity.ERROR))) //
+				.extracting(DiscoveryIssue::message) //
+				.asString().contains("@%s method".formatted(annotationType.getSimpleName()));
 	}
 
 	// -------------------------------------------------------------------------
 
-	@SuppressWarnings("JUnitMalformedDeclaration")
-	static class TestCase {
-
-		@Test
-		void test() {
-		}
-	}
-
 	@SuppressWarnings("JUnitMalformedDeclaration")
 	static class TestCaseWithInvalidNonStaticBeforeAllMethod {
 
 		// must be static
-		@SuppressWarnings("JUnitMalformedDeclaration")
+		@SuppressWarnings("unused")
 		@BeforeAll
 		void beforeAll() {
 		}
@@ -99,7 +81,7 @@ void test() {
 	static class TestCaseWithInvalidNonStaticAfterAllMethod {
 
 		// must be static
-		@SuppressWarnings("JUnitMalformedDeclaration")
+		@SuppressWarnings("unused")
 		@AfterAll
 		void afterAll() {
 		}
@@ -113,7 +95,7 @@ void test() {
 	static class TestCaseWithInvalidStaticBeforeEachMethod {
 
 		// must NOT be static
-		@SuppressWarnings("JUnitMalformedDeclaration")
+		@SuppressWarnings("unused")
 		@BeforeEach
 		static void beforeEach() {
 		}
@@ -127,7 +109,7 @@ void test() {
 	static class TestCaseWithInvalidStaticAfterEachMethod {
 
 		// must NOT be static
-		@SuppressWarnings("JUnitMalformedDeclaration")
+		@SuppressWarnings("unused")
 		@AfterEach
 		static void afterEach() {
 		}
diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/TestInstanceLifecycleConfigurationTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/TestInstanceLifecycleConfigurationTests.java
index fdb7e512ce75..8090690f32f6 100644
--- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/TestInstanceLifecycleConfigurationTests.java
+++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/TestInstanceLifecycleConfigurationTests.java
@@ -73,7 +73,7 @@ void instancePerClassConfiguredViaSystemProperty() {
 		Class<?> testClass = AssumedInstancePerClassTestCase.class;
 
 		// Should fail by default...
-		performAssertions(testClass, 2, 1, 0);
+		performAssertions(testClass, 1, 1, 0);
 
 		// Should pass with the system property set
 		System.setProperty(KEY, PER_CLASS.name());
@@ -85,7 +85,7 @@ void instancePerClassConfiguredViaConfigParam() {
 		Class<?> testClass = AssumedInstancePerClassTestCase.class;
 
 		// Should fail by default...
-		performAssertions(testClass, 2, 1, 0);
+		performAssertions(testClass, 1, 1, 0);
 
 		// Should pass with the config param
 		performAssertions(testClass, singletonMap(KEY, PER_CLASS.name()), 2, 0, 1, "beforeAll", "test", "afterAll");
@@ -97,7 +97,7 @@ void instancePerClassConfiguredViaConfigParamThatOverridesSystemProperty() {
 
 		// Should fail with system property
 		System.setProperty(KEY, PER_METHOD.name());
-		performAssertions(testClass, 2, 1, 0);
+		performAssertions(testClass, 1, 1, 0);
 
 		// Should pass with the config param
 		performAssertions(testClass, singletonMap(KEY, PER_CLASS.name()), 2, 0, 1, "beforeAll", "test", "afterAll");
diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtilsTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtilsTests.java
index 55d754fa52e6..7e122432ad56 100644
--- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtilsTests.java
+++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtilsTests.java
@@ -47,11 +47,15 @@ void findNonVoidBeforeAllMethodsWithStandardLifecycle() throws Exception {
 		var methods = findBeforeAllMethods(TestCaseWithNonVoidLifecyleMethods.class, true, discoveryIssues::add);
 		assertThat(methods).isEmpty();
 
-		var expectedIssue = DiscoveryIssue.builder(Severity.ERROR,
+		var notVoidIssue = DiscoveryIssue.builder(Severity.ERROR,
 			"@BeforeAll method 'java.lang.Double org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.cc()' must not return a value.") //
 				.source(MethodSource.from(TestCaseWithNonVoidLifecyleMethods.class.getDeclaredMethod("cc"))) //
 				.build();
-		assertThat(discoveryIssues).containsExactly(expectedIssue);
+		var notStaticIssue = DiscoveryIssue.builder(Severity.ERROR,
+			"@BeforeAll method 'java.lang.Double org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.cc()' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).") //
+				.source(MethodSource.from(TestCaseWithNonVoidLifecyleMethods.class.getDeclaredMethod("cc"))) //
+				.build();
+		assertThat(discoveryIssues).containsExactlyInAnyOrder(notVoidIssue, notStaticIssue);
 	}
 
 	@Test
@@ -59,11 +63,15 @@ void findNonVoidAfterAllMethodsWithStandardLifecycle() throws Exception {
 		var methods = findAfterAllMethods(TestCaseWithNonVoidLifecyleMethods.class, true, discoveryIssues::add);
 		assertThat(methods).isEmpty();
 
-		var expectedIssue = DiscoveryIssue.builder(Severity.ERROR,
+		var notVoidIssue = DiscoveryIssue.builder(Severity.ERROR,
 			"@AfterAll method 'java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.dd()' must not return a value.") //
 				.source(MethodSource.from(TestCaseWithNonVoidLifecyleMethods.class.getDeclaredMethod("dd"))) //
 				.build();
-		assertThat(discoveryIssues).containsExactly(expectedIssue);
+		var notStaticIssue = DiscoveryIssue.builder(Severity.ERROR,
+			"@AfterAll method 'java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.dd()' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).") //
+				.source(MethodSource.from(TestCaseWithNonVoidLifecyleMethods.class.getDeclaredMethod("dd"))) //
+				.build();
+		assertThat(discoveryIssues).containsExactlyInAnyOrder(notVoidIssue, notStaticIssue);
 	}
 
 	@Test

From 49acd3fc1bfa43fa2495b197ca4ed5f47abb11b7 Mon Sep 17 00:00:00 2001
From: Marc Philipp <mail@marcphilipp.de>
Date: Tue, 18 Mar 2025 14:44:51 +0100
Subject: [PATCH 3/4] Generate deprecation issue for private lifecycle methods

Issue: #242
---
 .../descriptor/LifecycleMethodUtils.java      | 47 ++++++++---
 .../descriptor/LifecycleMethodUtilsTests.java | 79 +++++++++++--------
 2 files changed, 82 insertions(+), 44 deletions(-)

diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtils.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtils.java
index f277ab66a46b..99693164d075 100644
--- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtils.java
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtils.java
@@ -16,6 +16,7 @@
 import java.lang.annotation.Annotation;
 import java.lang.reflect.Method;
 import java.util.List;
+import java.util.function.Consumer;
 import java.util.function.Function;
 import java.util.function.Predicate;
 
@@ -87,6 +88,7 @@ private static List<Method> findMethodsAndCheckVoidReturnType(Class<?> testClass
 			DiscoveryIssueReporter issueReporter, Predicate<? super Method> additionalCondition) {
 
 		return findAnnotatedMethods(testClass, annotationType, traversalMode).stream() //
+				.peek(isNotPrivate(annotationType, issueReporter)) //
 				.filter(allOf(returnsPrimitiveVoid(annotationType, issueReporter), additionalCondition)) //
 				.collect(toUnmodifiableList());
 	}
@@ -97,7 +99,7 @@ private static Predicate<Method> isStatic(Class<? extends Annotation> annotation
 			String message = String.format(
 				"@%s method '%s' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).",
 				annotationType.getSimpleName(), method.toGenericString());
-			return DiscoveryIssue.builder(Severity.ERROR, message).source(MethodSource.from(method)).build();
+			return createIssue(Severity.ERROR, message, method);
 		});
 	}
 
@@ -106,7 +108,17 @@ private static Predicate<Method> isNotStatic(Class<? extends Annotation> annotat
 		return DiscoveryIssueReportingPredicate.from(ModifierSupport::isNotStatic, issueReporter, method -> {
 			String message = String.format("@%s method '%s' must not be static.", annotationType.getSimpleName(),
 				method.toGenericString());
-			return DiscoveryIssue.builder(Severity.ERROR, message).source(MethodSource.from(method)).build();
+			return createIssue(Severity.ERROR, message, method);
+		});
+	}
+
+	private static Consumer<Method> isNotPrivate(Class<? extends Annotation> annotationType,
+			DiscoveryIssueReporter issueReporter) {
+		return DiscoveryIssueReportingPredicate.from(ModifierSupport::isNotPrivate, issueReporter, method -> {
+			String message = String.format(
+				"@%s method '%s' should not be private. This will be disallowed in a future release.",
+				annotationType.getSimpleName(), method.toGenericString());
+			return createIssue(Severity.DEPRECATION, message, method);
 		});
 	}
 
@@ -115,10 +127,14 @@ private static Predicate<Method> returnsPrimitiveVoid(Class<? extends Annotation
 		return DiscoveryIssueReportingPredicate.from(ReflectionUtils::returnsPrimitiveVoid, issueReporter, method -> {
 			String message = String.format("@%s method '%s' must not return a value.", annotationType.getSimpleName(),
 				method.toGenericString());
-			return DiscoveryIssue.builder(Severity.ERROR, message).source(MethodSource.from(method)).build();
+			return createIssue(Severity.ERROR, message, method);
 		});
 	}
 
+	private static DiscoveryIssue createIssue(Severity severity, String message, Method method) {
+		return DiscoveryIssue.builder(severity, message).source(MethodSource.from(method)).build();
+	}
+
 	@SafeVarargs
 	@SuppressWarnings("varargs")
 	private static <T> Predicate<T> allOf(Predicate<? super T>... predicates) {
@@ -134,19 +150,19 @@ private static <T> Predicate<T> allOf(Predicate<? super T>... predicates) {
 		};
 	}
 
-	private abstract static class DiscoveryIssueReportingPredicate<T> implements Predicate<T> {
+	private abstract static class DiscoveryIssueReportingPredicate<T> implements Predicate<T>, Consumer<T> {
 
 		static <T> DiscoveryIssueReportingPredicate<T> from(Predicate<T> predicate, DiscoveryIssueReporter reporter,
 				Function<T, DiscoveryIssue> issueBuilder) {
 			return new DiscoveryIssueReportingPredicate<T>(reporter) {
 				@Override
-				protected boolean checkCondition(T t) {
-					return predicate.test(t);
+				protected boolean checkCondition(T value) {
+					return predicate.test(value);
 				}
 
 				@Override
-				protected DiscoveryIssue createIssue(T t) {
-					return issueBuilder.apply(t);
+				protected DiscoveryIssue createIssue(T value) {
+					return issueBuilder.apply(value);
 				}
 			};
 		}
@@ -158,17 +174,22 @@ protected DiscoveryIssueReportingPredicate(DiscoveryIssueReporter reporter) {
 		}
 
 		@Override
-		public final boolean test(T t) {
-			if (checkCondition(t)) {
+		public final boolean test(T value) {
+			if (checkCondition(value)) {
 				return true;
 			}
-			reporter.reportIssue(createIssue(t));
+			reporter.reportIssue(createIssue(value));
 			return false;
 		}
 
-		protected abstract boolean checkCondition(T t);
+		@Override
+		public void accept(T value) {
+			test(value);
+		}
+
+		protected abstract boolean checkCondition(T value);
 
-		protected abstract DiscoveryIssue createIssue(T t);
+		protected abstract DiscoveryIssue createIssue(T value);
 	}
 
 }
diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtilsTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtilsTests.java
index 7e122432ad56..2da2aa759d2e 100644
--- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtilsTests.java
+++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtilsTests.java
@@ -44,58 +44,78 @@ class LifecycleMethodUtilsTests {
 
 	@Test
 	void findNonVoidBeforeAllMethodsWithStandardLifecycle() throws Exception {
-		var methods = findBeforeAllMethods(TestCaseWithNonVoidLifecyleMethods.class, true, discoveryIssues::add);
+		var methods = findBeforeAllMethods(TestCaseWithInvalidLifecycleMethods.class, true, discoveryIssues::add);
 		assertThat(methods).isEmpty();
 
+		var methodSource = MethodSource.from(TestCaseWithInvalidLifecycleMethods.class.getDeclaredMethod("cc"));
 		var notVoidIssue = DiscoveryIssue.builder(Severity.ERROR,
-			"@BeforeAll method 'java.lang.Double org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.cc()' must not return a value.") //
-				.source(MethodSource.from(TestCaseWithNonVoidLifecyleMethods.class.getDeclaredMethod("cc"))) //
+			"@BeforeAll method 'private java.lang.Double org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.cc()' must not return a value.") //
+				.source(methodSource) //
 				.build();
 		var notStaticIssue = DiscoveryIssue.builder(Severity.ERROR,
-			"@BeforeAll method 'java.lang.Double org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.cc()' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).") //
-				.source(MethodSource.from(TestCaseWithNonVoidLifecyleMethods.class.getDeclaredMethod("cc"))) //
+			"@BeforeAll method 'private java.lang.Double org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.cc()' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).") //
+				.source(methodSource) //
 				.build();
-		assertThat(discoveryIssues).containsExactlyInAnyOrder(notVoidIssue, notStaticIssue);
+		var privateIssue = DiscoveryIssue.builder(Severity.DEPRECATION,
+			"@BeforeAll method 'private java.lang.Double org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.cc()' should not be private. This will be disallowed in a future release.") //
+				.source(methodSource) //
+				.build();
+		assertThat(discoveryIssues).containsExactlyInAnyOrder(notVoidIssue, notStaticIssue, privateIssue);
 	}
 
 	@Test
 	void findNonVoidAfterAllMethodsWithStandardLifecycle() throws Exception {
-		var methods = findAfterAllMethods(TestCaseWithNonVoidLifecyleMethods.class, true, discoveryIssues::add);
+		var methods = findAfterAllMethods(TestCaseWithInvalidLifecycleMethods.class, true, discoveryIssues::add);
 		assertThat(methods).isEmpty();
 
+		var methodSource = MethodSource.from(TestCaseWithInvalidLifecycleMethods.class.getDeclaredMethod("dd"));
 		var notVoidIssue = DiscoveryIssue.builder(Severity.ERROR,
-			"@AfterAll method 'java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.dd()' must not return a value.") //
-				.source(MethodSource.from(TestCaseWithNonVoidLifecyleMethods.class.getDeclaredMethod("dd"))) //
+			"@AfterAll method 'private java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.dd()' must not return a value.") //
+				.source(methodSource) //
 				.build();
 		var notStaticIssue = DiscoveryIssue.builder(Severity.ERROR,
-			"@AfterAll method 'java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.dd()' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).") //
-				.source(MethodSource.from(TestCaseWithNonVoidLifecyleMethods.class.getDeclaredMethod("dd"))) //
+			"@AfterAll method 'private java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.dd()' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).") //
+				.source(methodSource) //
+				.build();
+		var privateIssue = DiscoveryIssue.builder(Severity.DEPRECATION,
+			"@AfterAll method 'private java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.dd()' should not be private. This will be disallowed in a future release.") //
+				.source(methodSource) //
 				.build();
-		assertThat(discoveryIssues).containsExactlyInAnyOrder(notVoidIssue, notStaticIssue);
+		assertThat(discoveryIssues).containsExactlyInAnyOrder(notVoidIssue, notStaticIssue, privateIssue);
 	}
 
 	@Test
 	void findNonVoidBeforeEachMethodsWithStandardLifecycle() throws Exception {
-		var methods = findBeforeEachMethods(TestCaseWithNonVoidLifecyleMethods.class, discoveryIssues::add);
+		var methods = findBeforeEachMethods(TestCaseWithInvalidLifecycleMethods.class, discoveryIssues::add);
 		assertThat(methods).isEmpty();
 
-		var expectedIssue = DiscoveryIssue.builder(Severity.ERROR,
-			"@BeforeEach method 'java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.aa()' must not return a value.") //
-				.source(MethodSource.from(TestCaseWithNonVoidLifecyleMethods.class.getDeclaredMethod("aa"))) //
+		var methodSource = MethodSource.from(TestCaseWithInvalidLifecycleMethods.class.getDeclaredMethod("aa"));
+		var notVoidIssue = DiscoveryIssue.builder(Severity.ERROR,
+			"@BeforeEach method 'private java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.aa()' must not return a value.") //
+				.source(methodSource) //
 				.build();
-		assertThat(discoveryIssues).containsExactly(expectedIssue);
+		var privateIssue = DiscoveryIssue.builder(Severity.DEPRECATION,
+			"@BeforeEach method 'private java.lang.String org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.aa()' should not be private. This will be disallowed in a future release.") //
+				.source(methodSource) //
+				.build();
+		assertThat(discoveryIssues).containsExactlyInAnyOrder(notVoidIssue, privateIssue);
 	}
 
 	@Test
 	void findNonVoidAfterEachMethodsWithStandardLifecycle() throws Exception {
-		var methods = findAfterEachMethods(TestCaseWithNonVoidLifecyleMethods.class, discoveryIssues::add);
+		var methods = findAfterEachMethods(TestCaseWithInvalidLifecycleMethods.class, discoveryIssues::add);
 		assertThat(methods).isEmpty();
 
-		var expectedIssue = DiscoveryIssue.builder(Severity.ERROR,
-			"@AfterEach method 'int org.junit.jupiter.engine.descriptor.TestCaseWithNonVoidLifecyleMethods.bb()' must not return a value.") //
-				.source(MethodSource.from(TestCaseWithNonVoidLifecyleMethods.class.getDeclaredMethod("bb"))) //
+		var methodSource = MethodSource.from(TestCaseWithInvalidLifecycleMethods.class.getDeclaredMethod("bb"));
+		var notVoidIssue = DiscoveryIssue.builder(Severity.ERROR,
+			"@AfterEach method 'private int org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.bb()' must not return a value.") //
+				.source(methodSource) //
 				.build();
-		assertThat(discoveryIssues).containsExactly(expectedIssue);
+		var privateIssue = DiscoveryIssue.builder(Severity.DEPRECATION,
+			"@AfterEach method 'private int org.junit.jupiter.engine.descriptor.TestCaseWithInvalidLifecycleMethods.bb()' should not be private. This will be disallowed in a future release.") //
+				.source(methodSource) //
+				.build();
+		assertThat(discoveryIssues).containsExactlyInAnyOrder(notVoidIssue, privateIssue);
 	}
 
 	@Test
@@ -228,29 +248,26 @@ void eight() {
 
 }
 
-class TestCaseWithNonVoidLifecyleMethods {
+@SuppressWarnings("JUnitMalformedDeclaration")
+class TestCaseWithInvalidLifecycleMethods {
 
-	@SuppressWarnings("JUnitMalformedDeclaration")
 	@BeforeEach
-	String aa() {
+	private String aa() {
 		return null;
 	}
 
-	@SuppressWarnings("JUnitMalformedDeclaration")
 	@AfterEach
-	int bb() {
+	private int bb() {
 		return 1;
 	}
 
-	@SuppressWarnings("JUnitMalformedDeclaration")
 	@BeforeAll
-	Double cc() {
+	private Double cc() {
 		return null;
 	}
 
-	@SuppressWarnings("JUnitMalformedDeclaration")
 	@AfterAll
-	String dd() {
+	private String dd() {
 		return "";
 	}
 

From c68dd38b3499ac3fe60fe33691bdc56d2c4b72c3 Mon Sep 17 00:00:00 2001
From: Marc Philipp <mail@marcphilipp.de>
Date: Tue, 18 Mar 2025 15:27:21 +0100
Subject: [PATCH 4/4] Extract preliminary reusable `Condition` API from
 `LifecycleMethodUtils`

---
 .../descriptor/LifecycleMethodUtils.java      | 83 +++-------------
 .../DefaultDiscoveryIssueReporter.java        | 35 -------
 .../discovery/DiscoveryIssueReporter.java     | 94 +++++++++++++++++++
 .../EngineDiscoveryRequestResolver.java       |  2 +-
 4 files changed, 107 insertions(+), 107 deletions(-)
 delete mode 100644 junit-platform-engine/src/main/java/org/junit/platform/engine/support/discovery/DefaultDiscoveryIssueReporter.java

diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtils.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtils.java
index 99693164d075..a73d9fc2f491 100644
--- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtils.java
+++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/descriptor/LifecycleMethodUtils.java
@@ -12,13 +12,11 @@
 
 import static org.junit.platform.commons.support.AnnotationSupport.findAnnotatedMethods;
 import static org.junit.platform.commons.util.CollectionUtils.toUnmodifiableList;
+import static org.junit.platform.engine.support.discovery.DiscoveryIssueReporter.Condition.allOf;
 
 import java.lang.annotation.Annotation;
 import java.lang.reflect.Method;
 import java.util.List;
-import java.util.function.Consumer;
-import java.util.function.Function;
-import java.util.function.Predicate;
 
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.AfterEach;
@@ -26,12 +24,12 @@
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.platform.commons.support.HierarchyTraversalMode;
 import org.junit.platform.commons.support.ModifierSupport;
-import org.junit.platform.commons.util.Preconditions;
 import org.junit.platform.commons.util.ReflectionUtils;
 import org.junit.platform.engine.DiscoveryIssue;
 import org.junit.platform.engine.DiscoveryIssue.Severity;
 import org.junit.platform.engine.support.descriptor.MethodSource;
 import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter;
+import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter.Condition;
 
 /**
  * Collection of utilities for working with test lifecycle methods.
@@ -70,7 +68,7 @@ private static List<Method> findMethodsAndCheckStatic(Class<?> testClass, boolea
 			Class<? extends Annotation> annotationType, HierarchyTraversalMode traversalMode,
 			DiscoveryIssueReporter issueReporter) {
 
-		Predicate<Method> additionalCondition = requireStatic ? isStatic(annotationType, issueReporter) : __ -> true;
+		Condition<Method> additionalCondition = requireStatic ? isStatic(annotationType, issueReporter) : __ -> true;
 		return findMethodsAndCheckVoidReturnType(testClass, annotationType, traversalMode, issueReporter,
 			additionalCondition);
 	}
@@ -85,7 +83,7 @@ private static List<Method> findMethodsAndCheckNonStatic(Class<?> testClass,
 
 	private static List<Method> findMethodsAndCheckVoidReturnType(Class<?> testClass,
 			Class<? extends Annotation> annotationType, HierarchyTraversalMode traversalMode,
-			DiscoveryIssueReporter issueReporter, Predicate<? super Method> additionalCondition) {
+			DiscoveryIssueReporter issueReporter, Condition<? super Method> additionalCondition) {
 
 		return findAnnotatedMethods(testClass, annotationType, traversalMode).stream() //
 				.peek(isNotPrivate(annotationType, issueReporter)) //
@@ -93,9 +91,9 @@ private static List<Method> findMethodsAndCheckVoidReturnType(Class<?> testClass
 				.collect(toUnmodifiableList());
 	}
 
-	private static Predicate<Method> isStatic(Class<? extends Annotation> annotationType,
+	private static Condition<Method> isStatic(Class<? extends Annotation> annotationType,
 			DiscoveryIssueReporter issueReporter) {
-		return DiscoveryIssueReportingPredicate.from(ModifierSupport::isStatic, issueReporter, method -> {
+		return issueReporter.createReportingCondition(ModifierSupport::isStatic, method -> {
 			String message = String.format(
 				"@%s method '%s' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).",
 				annotationType.getSimpleName(), method.toGenericString());
@@ -103,18 +101,18 @@ private static Predicate<Method> isStatic(Class<? extends Annotation> annotation
 		});
 	}
 
-	private static Predicate<Method> isNotStatic(Class<? extends Annotation> annotationType,
+	private static Condition<Method> isNotStatic(Class<? extends Annotation> annotationType,
 			DiscoveryIssueReporter issueReporter) {
-		return DiscoveryIssueReportingPredicate.from(ModifierSupport::isNotStatic, issueReporter, method -> {
+		return issueReporter.createReportingCondition(ModifierSupport::isNotStatic, method -> {
 			String message = String.format("@%s method '%s' must not be static.", annotationType.getSimpleName(),
 				method.toGenericString());
 			return createIssue(Severity.ERROR, message, method);
 		});
 	}
 
-	private static Consumer<Method> isNotPrivate(Class<? extends Annotation> annotationType,
+	private static Condition<Method> isNotPrivate(Class<? extends Annotation> annotationType,
 			DiscoveryIssueReporter issueReporter) {
-		return DiscoveryIssueReportingPredicate.from(ModifierSupport::isNotPrivate, issueReporter, method -> {
+		return issueReporter.createReportingCondition(ModifierSupport::isNotPrivate, method -> {
 			String message = String.format(
 				"@%s method '%s' should not be private. This will be disallowed in a future release.",
 				annotationType.getSimpleName(), method.toGenericString());
@@ -122,9 +120,9 @@ private static Consumer<Method> isNotPrivate(Class<? extends Annotation> annotat
 		});
 	}
 
-	private static Predicate<Method> returnsPrimitiveVoid(Class<? extends Annotation> annotationType,
+	private static Condition<Method> returnsPrimitiveVoid(Class<? extends Annotation> annotationType,
 			DiscoveryIssueReporter issueReporter) {
-		return DiscoveryIssueReportingPredicate.from(ReflectionUtils::returnsPrimitiveVoid, issueReporter, method -> {
+		return issueReporter.createReportingCondition(ReflectionUtils::returnsPrimitiveVoid, method -> {
 			String message = String.format("@%s method '%s' must not return a value.", annotationType.getSimpleName(),
 				method.toGenericString());
 			return createIssue(Severity.ERROR, message, method);
@@ -135,61 +133,4 @@ private static DiscoveryIssue createIssue(Severity severity, String message, Met
 		return DiscoveryIssue.builder(severity, message).source(MethodSource.from(method)).build();
 	}
 
-	@SafeVarargs
-	@SuppressWarnings("varargs")
-	private static <T> Predicate<T> allOf(Predicate<? super T>... predicates) {
-		Preconditions.notNull(predicates, "predicates must not be null");
-		Preconditions.notEmpty(predicates, "predicates must not be empty");
-		Preconditions.containsNoNullElements(predicates, "predicates must not contain null elements");
-		return value -> {
-			boolean result = true;
-			for (Predicate<? super T> predicate : predicates) {
-				result &= predicate.test(value);
-			}
-			return result;
-		};
-	}
-
-	private abstract static class DiscoveryIssueReportingPredicate<T> implements Predicate<T>, Consumer<T> {
-
-		static <T> DiscoveryIssueReportingPredicate<T> from(Predicate<T> predicate, DiscoveryIssueReporter reporter,
-				Function<T, DiscoveryIssue> issueBuilder) {
-			return new DiscoveryIssueReportingPredicate<T>(reporter) {
-				@Override
-				protected boolean checkCondition(T value) {
-					return predicate.test(value);
-				}
-
-				@Override
-				protected DiscoveryIssue createIssue(T value) {
-					return issueBuilder.apply(value);
-				}
-			};
-		}
-
-		private final DiscoveryIssueReporter reporter;
-
-		protected DiscoveryIssueReportingPredicate(DiscoveryIssueReporter reporter) {
-			this.reporter = reporter;
-		}
-
-		@Override
-		public final boolean test(T value) {
-			if (checkCondition(value)) {
-				return true;
-			}
-			reporter.reportIssue(createIssue(value));
-			return false;
-		}
-
-		@Override
-		public void accept(T value) {
-			test(value);
-		}
-
-		protected abstract boolean checkCondition(T value);
-
-		protected abstract DiscoveryIssue createIssue(T value);
-	}
-
 }
diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/discovery/DefaultDiscoveryIssueReporter.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/discovery/DefaultDiscoveryIssueReporter.java
deleted file mode 100644
index 1aa686b29a4f..000000000000
--- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/discovery/DefaultDiscoveryIssueReporter.java
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * 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.platform.engine.support.discovery;
-
-import org.junit.platform.engine.DiscoveryIssue;
-import org.junit.platform.engine.EngineDiscoveryListener;
-import org.junit.platform.engine.UniqueId;
-
-/**
- * @since 1.13
- */
-class DefaultDiscoveryIssueReporter implements DiscoveryIssueReporter {
-
-	private final EngineDiscoveryListener discoveryListener;
-	private final UniqueId engineId;
-
-	DefaultDiscoveryIssueReporter(EngineDiscoveryListener discoveryListener, UniqueId engineId) {
-		this.discoveryListener = discoveryListener;
-		this.engineId = engineId;
-	}
-
-	@Override
-	public void reportIssue(DiscoveryIssue issue) {
-		this.discoveryListener.issueEncountered(this.engineId, issue);
-	}
-
-}
diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/discovery/DiscoveryIssueReporter.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/discovery/DiscoveryIssueReporter.java
index a087b0502e35..1f17d4128b5c 100644
--- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/discovery/DiscoveryIssueReporter.java
+++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/discovery/DiscoveryIssueReporter.java
@@ -12,8 +12,15 @@
 
 import static org.apiguardian.api.API.Status.EXPERIMENTAL;
 
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Predicate;
+
 import org.apiguardian.api.API;
+import org.junit.platform.commons.util.Preconditions;
 import org.junit.platform.engine.DiscoveryIssue;
+import org.junit.platform.engine.EngineDiscoveryListener;
+import org.junit.platform.engine.UniqueId;
 
 /**
  * {@code DiscoveryIssueReporter} defines the API for reporting
@@ -26,6 +33,20 @@
 @FunctionalInterface
 public interface DiscoveryIssueReporter {
 
+	/**
+	 * Create a new {@code DiscoveryIssueReporter} that reports issues to the
+	 * supplied {@link EngineDiscoveryListener} for the specified engine.
+	 *
+	 * @param engineDiscoveryListener the listener to report issues to; never
+	 * {@code null}
+	 * @param engineId the unique identifier of the engine; never {@code null}
+	 */
+	static DiscoveryIssueReporter create(EngineDiscoveryListener engineDiscoveryListener, UniqueId engineId) {
+		Preconditions.notNull(engineDiscoveryListener, "engineDiscoveryListener must not be null");
+		Preconditions.notNull(engineId, "engineId must not be null");
+		return issue -> engineDiscoveryListener.issueEncountered(engineId, issue);
+	}
+
 	/**
 	 * Build the supplied {@link DiscoveryIssue.Builder Builder} and report the
 	 * resulting {@link DiscoveryIssue}.
@@ -39,4 +60,77 @@ default void reportIssue(DiscoveryIssue.Builder builder) {
 	 */
 	void reportIssue(DiscoveryIssue issue);
 
+	/**
+	 * Create a {@link Condition} that reports a {@link DiscoveryIssue} when the
+	 * supplied {@link Predicate} is not met.
+	 *
+	 * @param predicate the predicate to test; never {@code null}
+	 * @param issueCreator the function to create the issue with; never {@code null}
+	 * @return a new {@code Condition}; never {@code null}
+	 */
+	default <T> Condition<T> createReportingCondition(Predicate<T> predicate,
+			Function<T, DiscoveryIssue> issueCreator) {
+		Preconditions.notNull(predicate, "predicate must not be null");
+		Preconditions.notNull(issueCreator, "issueCreator must not be null");
+		return value -> {
+			if (predicate.test(value)) {
+				return true;
+			}
+			else {
+				reportIssue(issueCreator.apply(value));
+				return false;
+			}
+		};
+	}
+
+	/**
+	 * A {@code Condition} is a union of {@link Predicate} and {@link Consumer}.
+	 *
+	 * <p>Instances of this type may be used as {@link Predicate Predicates} or
+	 * {@link Consumer Consumers}. For example, a {@code Condition} may be
+	 * passed to {@link java.util.stream.Stream#filter(Predicate)} if it is used
+	 * for filtering, or to {@link java.util.stream.Stream#peek(Consumer)} if it
+	 * is only used for reporting or other side effects.
+	 *
+	 * @see #createReportingCondition(Predicate, Function)
+	 */
+	@FunctionalInterface
+	interface Condition<T> extends Predicate<T>, Consumer<T> {
+
+		/**
+		 * Return a composed condition that represents a logical AND of the
+		 * supplied conditions without short-circuiting.
+		 *
+		 * <p><em>All</em> of the supplied conditions will be evaluated even if
+		 * one or more of them return {@code false} to ensure that all issues
+		 * are reported.
+		 *
+		 * @param conditions the conditions to compose; never {@code null}, not
+		 * empty, and must not contain any {@code null} elements
+		 * @return the composed condition; never {@code null}
+		 */
+		@SafeVarargs
+		@SuppressWarnings("varargs")
+		static <T> Condition<T> allOf(Condition<? super T>... conditions) {
+			Preconditions.notNull(conditions, "conditions must not be null");
+			Preconditions.notEmpty(conditions, "conditions must not be empty");
+			Preconditions.containsNoNullElements(conditions, "conditions must not contain null elements");
+			return value -> {
+				boolean result = true;
+				for (Condition<? super T> condition : conditions) {
+					result &= condition.test(value);
+				}
+				return result;
+			};
+		}
+
+		/**
+		 * Evaluate the {@code #test(Object)} method of this condition to
+		 * potentially report an issue.
+		 */
+		@Override
+		default void accept(T value) {
+			test(value);
+		}
+	}
 }
diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/discovery/EngineDiscoveryRequestResolver.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/discovery/EngineDiscoveryRequestResolver.java
index 431975615370..28e24d09a705 100644
--- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/discovery/EngineDiscoveryRequestResolver.java
+++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/discovery/EngineDiscoveryRequestResolver.java
@@ -110,7 +110,7 @@ private EngineDiscoveryRequestResolver(List<Function<InitializationContext<T>, S
 	public void resolve(EngineDiscoveryRequest request, T engineDescriptor) {
 		Preconditions.notNull(request, "request must not be null");
 		Preconditions.notNull(engineDescriptor, "engineDescriptor must not be null");
-		DiscoveryIssueReporter issueReporter = new DefaultDiscoveryIssueReporter(request.getDiscoveryListener(),
+		DiscoveryIssueReporter issueReporter = DiscoveryIssueReporter.create(request.getDiscoveryListener(),
 			engineDescriptor.getUniqueId());
 		InitializationContext<T> initializationContext = new DefaultInitializationContext<>(request, engineDescriptor,
 			issueReporter);