Skip to content
Merged
2 changes: 2 additions & 0 deletions documentation/documentation.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ dependencies {
testImplementation(projects.junitPlatformTestkit)
testImplementation(projects.junitVintageEngine)
testImplementation(kotlin("stdlib"))
testRuntimeOnly(libs.kotlinx.coroutines)
testRuntimeOnly(kotlin("reflect"))

toolsImplementation(projects.junitPlatformCommons)
toolsImplementation(libs.classgraph)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ repository on GitHub.
[[release-notes-6.0.0-M1-junit-jupiter-new-features-and-improvements]]
==== New Features and Improvements

*
* Add support for using Kotlin's `suspend` modifier on test and lifecycle methods.


[[release-notes-6.0.0-M1-junit-vintage]]
Expand Down
18 changes: 17 additions & 1 deletion documentation/src/docs/asciidoc/user-guide/writing-tests.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ lifecycle methods. For further information on runtime semantics, see
<<extensions-execution-order-wrapping-behavior>>.

[source,java,indent=0]
.A standard test class
.A standard Java test class
----
include::{testDir}/example/StandardTests.java[tags=user_guide]
----
Expand All @@ -285,6 +285,22 @@ following example.
include::{testRelease21Dir}/example/MyFirstJUnitJupiterRecordTests.java[tags=user_guide]
----

Test and lifecycle methods may be written in Kotlin and may optionally use the `suspend`
keyword for testing code using coroutines.

[source,kotlin]
.A test class written in Kotlin
----
include::{kotlinTestDir}/example/KotlinCoroutinesDemo.kt[tags=user_guide]
----

NOTE: Using suspending functions as test or lifecycle methods requires
https://central.sonatype.com/artifact/org.jetbrains.kotlin/kotlin-stdlib[`kotlin-stdlib`],
https://central.sonatype.com/artifact/org.jetbrains.kotlin/kotlin-reflect[`kotlin-reflect`],
and
https://central.sonatype.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core[`kotlinx-coroutines-core`]
to be present on the classpath or module path.

[[writing-tests-display-names]]
=== Display Names

Expand Down
36 changes: 36 additions & 0 deletions documentation/src/test/kotlin/example/KotlinCoroutinesDemo.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* 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 example

// tag::user_guide[]
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

// end::user_guide[]
// tag::user_guide[]
@Suppress("JUnitMalformedDeclaration")
class KotlinCoroutinesDemo {
@BeforeEach
fun regularSetUp() {
}

@BeforeEach
suspend fun coroutineSetUp() {
}

@Test
fun regularTest() {
}

@Test
suspend fun coroutineTest() {
}
}
// end::user_guide[]
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import static org.apiguardian.api.API.Status.STABLE;
import static org.junit.platform.commons.support.AnnotationSupport.findAnnotation;
import static org.junit.platform.commons.support.ModifierSupport.isStatic;
import static org.junit.platform.commons.util.KotlinReflectionUtils.getKotlinSuspendingFunctionParameterTypes;
import static org.junit.platform.commons.util.KotlinReflectionUtils.isKotlinSuspendingFunction;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
Expand Down Expand Up @@ -195,7 +197,10 @@ default String generateDisplayNameForMethod(List<Class<?>> enclosingInstanceType
*/
static String parameterTypesAsString(Method method) {
Preconditions.notNull(method, "Method must not be null");
return '(' + ClassUtils.nullSafeToString(Class::getSimpleName, method.getParameterTypes()) + ')';
var parameterTypes = isKotlinSuspendingFunction(method) //
? getKotlinSuspendingFunctionParameterTypes(method) //
: method.getParameterTypes();
return '(' + ClassUtils.nullSafeToString(Class::getSimpleName, parameterTypes) + ')';
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

package org.junit.jupiter.engine.descriptor;

import static org.junit.jupiter.engine.support.MethodReflectionUtils.getReturnType;
import static org.junit.platform.commons.support.AnnotationSupport.findAnnotatedMethods;
import static org.junit.platform.commons.support.AnnotationSupport.findAnnotation;
import static org.junit.platform.commons.util.CollectionUtils.toUnmodifiableList;
Expand All @@ -30,7 +31,6 @@
import org.junit.jupiter.api.extension.ClassTemplateInvocationLifecycleMethod;
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;
Expand Down Expand Up @@ -177,7 +177,7 @@ private static Condition<Method> isNotPrivateWarning(DiscoveryIssueReporter issu

private static Condition<Method> returnsPrimitiveVoid(DiscoveryIssueReporter issueReporter,
Function<Method, String> annotationNameProvider) {
return issueReporter.createReportingCondition(ReflectionUtils::returnsPrimitiveVoid, method -> {
return issueReporter.createReportingCondition(method -> getReturnType(method) == void.class, method -> {
String message = String.format("@%s method '%s' must not return a value.",
annotationNameProvider.apply(method), method.toGenericString());
return createIssue(Severity.ERROR, message, method);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
package org.junit.jupiter.engine.discovery.predicates;

import static org.apiguardian.api.API.Status.INTERNAL;
import static org.junit.jupiter.engine.support.MethodReflectionUtils.getGenericReturnType;
import static org.junit.jupiter.engine.support.MethodReflectionUtils.getReturnType;
import static org.junit.platform.commons.util.CollectionUtils.isConvertibleToStream;

import java.lang.annotation.Annotation;
Expand Down Expand Up @@ -53,7 +55,7 @@ private static DiscoveryIssueReporter.Condition<Method> hasCompatibleReturnType(
}

private static boolean isCompatible(Method method, DiscoveryIssueReporter issueReporter) {
Class<?> returnType = method.getReturnType();
Class<?> returnType = getReturnType(method);
if (DynamicNode.class.isAssignableFrom(returnType) || DynamicNode[].class.isAssignableFrom(returnType)) {
return true;
}
Expand All @@ -66,7 +68,7 @@ private static boolean isCompatible(Method method, DiscoveryIssueReporter issueR
}

private static boolean isCompatibleContainerType(Method method, DiscoveryIssueReporter issueReporter) {
Type genericReturnType = method.getGenericReturnType();
Type genericReturnType = getGenericReturnType(method);

if (genericReturnType instanceof ParameterizedType) {
Type[] typeArguments = ((ParameterizedType) genericReturnType).getActualTypeArguments();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

package org.junit.jupiter.engine.discovery.predicates;

import static org.junit.jupiter.engine.support.MethodReflectionUtils.getReturnType;
import static org.junit.platform.commons.support.AnnotationSupport.isAnnotated;

import java.lang.annotation.Annotation;
Expand All @@ -18,7 +19,6 @@
import java.util.function.Predicate;

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;
Expand Down Expand Up @@ -71,7 +71,7 @@ private static Condition<Method> isNotAbstract(Class<? extends Annotation> annot

protected static Condition<Method> hasVoidReturnType(Class<? extends Annotation> annotationType,
DiscoveryIssueReporter issueReporter) {
return issueReporter.createReportingCondition(ReflectionUtils::returnsPrimitiveVoid,
return issueReporter.createReportingCondition(method -> getReturnType(method) == void.class,
method -> createIssue(annotationType, method, "must not return a value"));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import org.junit.jupiter.api.extension.ExecutableInvoker;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.engine.extension.ExtensionRegistry;
import org.junit.jupiter.engine.support.MethodReflectionUtils;
import org.junit.platform.commons.util.ReflectionUtils;

/**
Expand Down Expand Up @@ -48,7 +49,7 @@ public <T> T invoke(Constructor<T> constructor, Object outerInstance) {
public Object invoke(Method method, Object target) {
Object[] arguments = resolveParameters(method, Optional.ofNullable(target), extensionContext,
extensionRegistry);
return ReflectionUtils.invokeMethod(method, target, arguments);
return MethodReflectionUtils.invoke(method, target, arguments);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import org.junit.jupiter.api.extension.InvocationInterceptor.Invocation;
import org.junit.jupiter.api.extension.ReflectiveInvocationContext;
import org.junit.platform.commons.support.ReflectionSupport;
import org.junit.jupiter.engine.support.MethodReflectionUtils;

class MethodInvocation<T> implements Invocation<T>, ReflectiveInvocationContext<Method> {

Expand Down Expand Up @@ -57,7 +57,8 @@ public List<Object> getArguments() {
@Override
@SuppressWarnings("unchecked")
public T proceed() {
return (T) ReflectionSupport.invokeMethod(this.method, this.target.orElse(null), this.arguments);
var actualTarget = this.target.orElse(null);
return (T) MethodReflectionUtils.invoke(this.method, actualTarget, this.arguments);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
import static org.apiguardian.api.API.Status.INTERNAL;
import static org.junit.platform.commons.util.KotlinReflectionUtils.getKotlinSuspendingFunctionParameters;
import static org.junit.platform.commons.util.KotlinReflectionUtils.isKotlinSuspendingFunction;
import static org.junit.platform.commons.util.ReflectionUtils.isAssignableTo;

import java.lang.reflect.Constructor;
Expand Down Expand Up @@ -61,7 +63,10 @@ public class ParameterResolutionUtils {
public static Object[] resolveParameters(Method method, Optional<Object> target, ExtensionContext extensionContext,
ExtensionRegistry extensionRegistry) {

return resolveParameters(method, target, Optional.empty(), extensionContext, extensionRegistry);
return resolveParameters(method, target, Optional.empty(), __ -> extensionContext, extensionRegistry,
isKotlinSuspendingFunction(method) //
? getKotlinSuspendingFunctionParameters(method) //
: method.getParameters());
}

/**
Expand Down Expand Up @@ -90,9 +95,16 @@ public static Object[] resolveParameters(Executable executable, Optional<Object>
Optional<Object> outerInstance, ExtensionContextSupplier extensionContext,
ExtensionRegistry extensionRegistry) {

return resolveParameters(executable, target, outerInstance, extensionContext, extensionRegistry,
executable.getParameters());
}

private static Object[] resolveParameters(Executable executable, Optional<Object> target,
Optional<Object> outerInstance, ExtensionContextSupplier extensionContext,
ExtensionRegistry extensionRegistry, Parameter[] parameters) {

Preconditions.notNull(target, "target must not be null");

Parameter[] parameters = executable.getParameters();
Object[] values = new Object[parameters.length];
int start = 0;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* 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.support;

import static org.apiguardian.api.API.Status.INTERNAL;
import static org.junit.platform.commons.util.KotlinReflectionUtils.getKotlinSuspendingFunctionGenericReturnType;
import static org.junit.platform.commons.util.KotlinReflectionUtils.getKotlinSuspendingFunctionReturnType;
import static org.junit.platform.commons.util.KotlinReflectionUtils.invokeKotlinSuspendingFunction;
import static org.junit.platform.commons.util.KotlinReflectionUtils.isKotlinSuspendingFunction;

import java.lang.reflect.Method;
import java.lang.reflect.Type;

import org.apiguardian.api.API;
import org.junit.platform.commons.support.ReflectionSupport;

@API(status = INTERNAL, since = "6.0")
public class MethodReflectionUtils {

public static Class<?> getReturnType(Method method) {
return isKotlinSuspendingFunction(method) //
? getKotlinSuspendingFunctionReturnType(method) //
: method.getReturnType();
}

public static Type getGenericReturnType(Method method) {
return isKotlinSuspendingFunction(method) //
? getKotlinSuspendingFunctionGenericReturnType(method) //
: method.getGenericReturnType();
}

public static Object invoke(Method method, Object target, Object[] arguments) {
if (isKotlinSuspendingFunction(method)) {
return invokeKotlinSuspendingFunction(method, target, arguments);
}
return ReflectionSupport.invokeMethod(method, target, arguments);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@

import static java.util.Collections.emptyList;
import static java.util.Collections.reverse;
import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD;
import static org.junit.platform.commons.support.AnnotationSupport.findAnnotatedMethods;
import static org.junit.platform.commons.support.AnnotationSupport.findAnnotation;
import static org.junit.platform.commons.support.AnnotationSupport.isAnnotated;
Expand All @@ -34,7 +33,6 @@
import org.junit.jupiter.params.provider.Arguments;
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;

class ParameterizedClassContext implements ParameterizedDeclarationContext<ClassTemplateInvocationContext> {
Expand Down Expand Up @@ -155,9 +153,6 @@ private static <A extends Annotation> List<ArgumentSetLifecycleMethod> findLifec
List<Method> methods = findAnnotatedMethods(testClass, annotationType, traversalMode);

return methods.stream() //
.filter(ModifierSupport::isNotPrivate) //
.filter(testInstanceLifecycle == PER_METHOD ? ModifierSupport::isStatic : __ -> true) //
.filter(ReflectionUtils::returnsPrimitiveVoid) //
.map(method -> {
A annotation = getAnnotation(method, annotationType);
if (injectArgumentsPredicate.test(annotation)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
import static org.junit.platform.commons.support.AnnotationSupport.findAnnotation;
import static org.junit.platform.commons.support.AnnotationSupport.isAnnotated;
import static org.junit.platform.commons.support.ReflectionSupport.makeAccessible;
import static org.junit.platform.commons.util.KotlinReflectionUtils.getKotlinSuspendingFunctionParameters;
import static org.junit.platform.commons.util.KotlinReflectionUtils.isKotlinSuspendingFunction;
import static org.junit.platform.commons.util.ReflectionUtils.isInnerClass;

import java.lang.annotation.Annotation;
Expand Down Expand Up @@ -107,6 +109,9 @@ static ResolverFacade create(Constructor<?> constructor, ParameterizedClass anno
}

static ResolverFacade create(Method method, Annotation annotation) {
if (isKotlinSuspendingFunction(method)) {
return create(method, annotation, 0, getKotlinSuspendingFunctionParameters(method));
}
return create(method, annotation, 0);
}

Expand All @@ -124,9 +129,13 @@ static ResolverFacade create(Method method, Annotation annotation) {
* </ol>
*/
private static ResolverFacade create(Executable executable, Annotation annotation, int indexOffset) {
return create(executable, annotation, indexOffset, executable.getParameters());
}

private static ResolverFacade create(Executable executable, Annotation annotation, int indexOffset,
java.lang.reflect.Parameter[] parameters) {
NavigableMap<Integer, ExecutableParameterDeclaration> indexedParameters = new TreeMap<>();
NavigableMap<Integer, ExecutableParameterDeclaration> aggregatorParameters = new TreeMap<>();
java.lang.reflect.Parameter[] parameters = executable.getParameters();
for (int index = indexOffset; index < parameters.length; index++) {
ExecutableParameterDeclaration declaration = new ExecutableParameterDeclaration(parameters[index], index,
indexOffset);
Expand Down
Loading