diff --git a/documentation/documentation.gradle.kts b/documentation/documentation.gradle.kts index abad9ece66f7..e079ac81867e 100644 --- a/documentation/documentation.gradle.kts +++ b/documentation/documentation.gradle.kts @@ -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) diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-6.0.0-M1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-6.0.0-M1.adoc index 22cfd490dd0c..cae381374e60 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-6.0.0-M1.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-6.0.0-M1.adoc @@ -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]] diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index a1f1243a987b..6e87e90af1fe 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -271,7 +271,7 @@ lifecycle methods. For further information on runtime semantics, see <>. [source,java,indent=0] -.A standard test class +.A standard Java test class ---- include::{testDir}/example/StandardTests.java[tags=user_guide] ---- @@ -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 diff --git a/documentation/src/test/kotlin/example/KotlinCoroutinesDemo.kt b/documentation/src/test/kotlin/example/KotlinCoroutinesDemo.kt new file mode 100644 index 000000000000..993f75388025 --- /dev/null +++ b/documentation/src/test/kotlin/example/KotlinCoroutinesDemo.kt @@ -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[] diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/DisplayNameGenerator.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/DisplayNameGenerator.java index d7714236f56e..1ff2974c06d1 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/DisplayNameGenerator.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/DisplayNameGenerator.java @@ -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; @@ -195,7 +197,10 @@ default String generateDisplayNameForMethod(List> 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) + ')'; } /** 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 8d629e82fbf4..1d897c3c27c4 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 @@ -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; @@ -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; @@ -177,7 +177,7 @@ private static Condition isNotPrivateWarning(DiscoveryIssueReporter issu private static Condition returnsPrimitiveVoid(DiscoveryIssueReporter issueReporter, Function 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); diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestFactoryMethod.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestFactoryMethod.java index 144ec6db621c..07fbcc51a80c 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestFactoryMethod.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestFactoryMethod.java @@ -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; @@ -53,7 +55,7 @@ private static DiscoveryIssueReporter.Condition 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; } @@ -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(); diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestableMethod.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestableMethod.java index 498d04e62992..089fef2e4bbc 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestableMethod.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/discovery/predicates/IsTestableMethod.java @@ -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; @@ -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; @@ -71,7 +71,7 @@ private static Condition isNotAbstract(Class annot protected static Condition hasVoidReturnType(Class 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")); } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/DefaultExecutableInvoker.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/DefaultExecutableInvoker.java index 226db46cf82d..122c122030eb 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/DefaultExecutableInvoker.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/DefaultExecutableInvoker.java @@ -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; /** @@ -48,7 +49,7 @@ public T invoke(Constructor 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); } } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/MethodInvocation.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/MethodInvocation.java index fb3f2e0c2810..5d42c754d1ba 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/MethodInvocation.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/MethodInvocation.java @@ -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 implements Invocation, ReflectiveInvocationContext { @@ -57,7 +57,8 @@ public List 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); } } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/ParameterResolutionUtils.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/ParameterResolutionUtils.java index 65a4ddc0d38d..92f9cd7c7bf7 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/ParameterResolutionUtils.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/execution/ParameterResolutionUtils.java @@ -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; @@ -61,7 +63,10 @@ public class ParameterResolutionUtils { public static Object[] resolveParameters(Method method, Optional 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()); } /** @@ -90,9 +95,16 @@ public static Object[] resolveParameters(Executable executable, Optional Optional outerInstance, ExtensionContextSupplier extensionContext, ExtensionRegistry extensionRegistry) { + return resolveParameters(executable, target, outerInstance, extensionContext, extensionRegistry, + executable.getParameters()); + } + + private static Object[] resolveParameters(Executable executable, Optional target, + Optional 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; diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/support/MethodReflectionUtils.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/support/MethodReflectionUtils.java new file mode 100644 index 000000000000..1a9d753d305e --- /dev/null +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/support/MethodReflectionUtils.java @@ -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); + } +} diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedClassContext.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedClassContext.java index f73fff4e1e23..58b022d3f17a 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedClassContext.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ParameterizedClassContext.java @@ -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; @@ -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 { @@ -155,9 +153,6 @@ private static List findLifec List 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)) { diff --git a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ResolverFacade.java b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ResolverFacade.java index a1cad8e98419..2ca2590df654 100644 --- a/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ResolverFacade.java +++ b/junit-jupiter-params/src/main/java/org/junit/jupiter/params/ResolverFacade.java @@ -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; @@ -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); } @@ -124,9 +129,13 @@ static ResolverFacade create(Method method, Annotation annotation) { * */ 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 indexedParameters = new TreeMap<>(); NavigableMap 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); diff --git a/junit-platform-commons/junit-platform-commons.gradle.kts b/junit-platform-commons/junit-platform-commons.gradle.kts index 57f328bc14c6..3ba44841ada3 100644 --- a/junit-platform-commons/junit-platform-commons.gradle.kts +++ b/junit-platform-commons/junit-platform-commons.gradle.kts @@ -1,5 +1,7 @@ +import junitbuild.extensions.javaModuleName + plugins { - id("junitbuild.java-library-conventions") + id("junitbuild.kotlin-library-conventions") `java-test-fixtures` } @@ -9,8 +11,30 @@ dependencies { api(platform(projects.junitBom)) compileOnlyApi(libs.apiguardian) + + compileOnly(kotlin("stdlib")) + compileOnly(kotlin("reflect")) + compileOnly(libs.kotlinx.coroutines) } tasks.compileJava { options.compilerArgs.add("-Xlint:-module") // due to qualified exports + val moduleName = javaModuleName + val mainOutput = files(sourceSets.main.get().output) + options.compilerArgumentProviders.add(CommandLineArgumentProvider { + listOf("--patch-module", "${moduleName}=${mainOutput.asPath}") + }) +} + +tasks.jar { + bundle { + val importAPIGuardian: String by extra + bnd(""" + Import-Package: \ + $importAPIGuardian,\ + kotlin.*;resolution:="optional",\ + kotlinx.*;resolution:="optional",\ + * + """) + } } diff --git a/junit-platform-commons/src/main/java/module-info.java b/junit-platform-commons/src/main/java/module-info.java index 87897929f227..2cb4016429a3 100644 --- a/junit-platform-commons/src/main/java/module-info.java +++ b/junit-platform-commons/src/main/java/module-info.java @@ -18,6 +18,10 @@ requires java.management; // needed by RuntimeUtils to determine input arguments requires static transitive org.apiguardian.api; + requires static kotlin.stdlib; + requires static kotlin.reflect; + requires static kotlinx.coroutines.core; + exports org.junit.platform.commons; exports org.junit.platform.commons.annotation; exports org.junit.platform.commons.function; diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/KotlinReflectionUtils.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/KotlinReflectionUtils.java new file mode 100644 index 000000000000..8d897ed77751 --- /dev/null +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/KotlinReflectionUtils.java @@ -0,0 +1,113 @@ +/* + * 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.commons.util; + +import static org.apiguardian.api.API.Status.INTERNAL; +import static org.junit.platform.commons.util.ReflectionUtils.tryToLoadClass; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.lang.reflect.Type; + +import org.apiguardian.api.API; +import org.junit.platform.commons.function.Try; + +/** + * Internal Kotlin-specific reflection utilities + * + * @since 6.0 + */ +@API(status = INTERNAL, since = "6.0") +public class KotlinReflectionUtils { + + private static final Class kotlinMetadata; + private static final Class kotlinCoroutineContinuation; + private static final boolean kotlinReflectPresent; + private static final boolean kotlinxCoroutinesPresent; + + static { + var metadata = tryToLoadKotlinMetadataClass(); + kotlinMetadata = metadata.toOptional().orElse(null); + kotlinCoroutineContinuation = metadata // + .andThen(__ -> tryToLoadClass("kotlin.coroutines.Continuation")) // + .toOptional() // + .orElse(null); + kotlinReflectPresent = metadata.andThen(__ -> tryToLoadClass("kotlin.reflect.jvm.ReflectJvmMapping")) // + .toOptional() // + .isPresent(); + kotlinxCoroutinesPresent = metadata.andThen(__ -> tryToLoadClass("kotlinx.coroutines.BuildersKt")) // + .toOptional() // + .isPresent(); + } + + @SuppressWarnings("unchecked") + private static Try> tryToLoadKotlinMetadataClass() { + return tryToLoadClass("kotlin.Metadata") // + .andThenTry(it -> (Class) it); + } + + public static boolean isKotlinSuspendingFunction(Method method) { + if (kotlinCoroutineContinuation != null && isKotlinType(method.getDeclaringClass())) { + int parameterCount = method.getParameterCount(); + return parameterCount > 0 // + && method.getParameterTypes()[parameterCount - 1] == kotlinCoroutineContinuation; + } + return false; + } + + private static boolean isKotlinType(Class clazz) { + return kotlinMetadata != null // + && clazz.getDeclaredAnnotation(kotlinMetadata) != null; + } + + public static Class getKotlinSuspendingFunctionReturnType(Method method) { + requireKotlinReflect(method); + return KotlinReflectionUtilsKt.getReturnType(method); + } + + public static Type getKotlinSuspendingFunctionGenericReturnType(Method method) { + requireKotlinReflect(method); + return KotlinReflectionUtilsKt.getGenericReturnType(method); + } + + public static Parameter[] getKotlinSuspendingFunctionParameters(Method method) { + requireKotlinReflect(method); + return KotlinReflectionUtilsKt.getParameters(method); + } + + public static Class[] getKotlinSuspendingFunctionParameterTypes(Method method) { + requireKotlinReflect(method); + return KotlinReflectionUtilsKt.getParameterTypes(method); + } + + public static Object invokeKotlinSuspendingFunction(Method method, Object target, Object[] args) { + requireKotlinReflect(method); + requireKotlinxCoroutines(method); + return KotlinReflectionUtilsKt.invoke(method, target, args); + } + + private static void requireKotlinReflect(Method method) { + requireDependency(method, kotlinReflectPresent, "org.jetbrains.kotlin:kotlin-reflect"); + } + + private static void requireKotlinxCoroutines(Method method) { + requireDependency(method, kotlinxCoroutinesPresent, "org.jetbrains.kotlinx:kotlinx-coroutines-core"); + } + + private static void requireDependency(Method method, boolean condition, String dependencyNotation) { + Preconditions.condition(condition, + () -> ("Kotlin suspending function [%s] requires %s to be on the classpath or module path. " + + "Please add a corresponding dependency.").formatted(method.toGenericString(), + dependencyNotation)); + } + +} diff --git a/junit-platform-commons/src/main/kotlin/org/junit/platform/commons/util/KotlinReflectionUtils.kt b/junit-platform-commons/src/main/kotlin/org/junit/platform/commons/util/KotlinReflectionUtils.kt new file mode 100644 index 000000000000..4bd3fe0d53ce --- /dev/null +++ b/junit-platform-commons/src/main/kotlin/org/junit/platform/commons/util/KotlinReflectionUtils.kt @@ -0,0 +1,59 @@ +/* + * 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 + */ +@file:JvmName("KotlinReflectionUtilsKt") + +package org.junit.platform.commons.util + +import kotlinx.coroutines.runBlocking +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Method +import kotlin.reflect.full.callSuspend +import kotlin.reflect.full.valueParameters +import kotlin.reflect.jvm.javaType +import kotlin.reflect.jvm.jvmErasure +import kotlin.reflect.jvm.kotlinFunction + +internal fun getReturnType(method: Method): Class = + with(method.kotlinFunction!!.returnType.jvmErasure) { + if (this == Unit::class) { + Void.TYPE + } else { + java + } + } + +internal fun getGenericReturnType(method: Method) = method.kotlinFunction!!.returnType.javaType + +internal fun getParameterTypes(method: Method) = + method.kotlinFunction!! + .parameters + .map { it.type.jvmErasure.java } + .toTypedArray() + +internal fun getParameters(method: Method) = + method.kotlinFunction!!.valueParameters.size.let { + if (it > 0) { + method.parameters.copyOf(it) + } else { + emptyArray() + } + } + +internal fun invoke( + method: Method, + target: Any?, + vararg args: Any? +) = runBlocking { + try { + method.kotlinFunction!!.callSuspend(target, *args) + } catch (e: InvocationTargetException) { + throw e.targetException + } +} diff --git a/junit-platform-console-standalone/junit-platform-console-standalone.gradle.kts b/junit-platform-console-standalone/junit-platform-console-standalone.gradle.kts index 65d6c003fde8..4bdc51103c64 100644 --- a/junit-platform-console-standalone/junit-platform-console-standalone.gradle.kts +++ b/junit-platform-console-standalone/junit-platform-console-standalone.gradle.kts @@ -59,6 +59,7 @@ tasks { Import-Package: \ $importAPIGuardian,\ kotlin.*;resolution:="optional",\ + kotlinx.*;resolution:="optional",\ * # Disable the APIGuardian plugin since everything was already # processed, again because this is an aggregate jar diff --git a/jupiter-tests/jupiter-tests.gradle.kts b/jupiter-tests/jupiter-tests.gradle.kts index 061920f4d96d..dadeecfea33b 100644 --- a/jupiter-tests/jupiter-tests.gradle.kts +++ b/jupiter-tests/jupiter-tests.gradle.kts @@ -25,6 +25,8 @@ dependencies { testImplementation(testFixtures(projects.junitJupiterEngine)) testImplementation(testFixtures(projects.junitPlatformLauncher)) testImplementation(testFixtures(projects.junitPlatformReporting)) + + testRuntimeOnly(kotlin("reflect")) } tasks { diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/AbstractJupiterTestEngineTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/AbstractJupiterTestEngineTests.java index 35a2e8941a73..d28bd80b4b27 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/AbstractJupiterTestEngineTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/AbstractJupiterTestEngineTests.java @@ -10,6 +10,7 @@ package org.junit.jupiter.engine; +import static kotlin.jvm.JvmClassMappingKt.getJavaClass; import static org.junit.jupiter.api.Assertions.fail; import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; import static org.junit.platform.engine.discovery.DiscoverySelectors.selectMethod; @@ -29,6 +30,8 @@ import org.junit.platform.testkit.engine.EngineExecutionResults; import org.junit.platform.testkit.engine.EngineTestKit; +import kotlin.reflect.KClass; + /** * Abstract base class for tests involving the {@link JupiterTestEngine}. * @@ -38,6 +41,10 @@ public abstract class AbstractJupiterTestEngineTests { private final JupiterTestEngine engine = new JupiterTestEngine(); + protected EngineExecutionResults executeTestsForClass(KClass testClass) { + return executeTestsForClass(getJavaClass(testClass)); + } + protected EngineExecutionResults executeTestsForClass(Class testClass) { return executeTests(selectClass(testClass)); } diff --git a/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/KotlinSuspendFunctionsTests.kt b/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/KotlinSuspendFunctionsTests.kt new file mode 100644 index 000000000000..c5b0c742c186 --- /dev/null +++ b/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/KotlinSuspendFunctionsTests.kt @@ -0,0 +1,190 @@ +/* + * 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.api + +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DynamicTest.dynamicTest +import org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS +import org.junit.jupiter.engine.AbstractJupiterTestEngineTests +import org.junit.jupiter.params.AfterParameterizedClassInvocation +import org.junit.jupiter.params.BeforeParameterizedClassInvocation +import org.junit.jupiter.params.Parameter +import org.junit.jupiter.params.ParameterizedClass +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource +import org.junit.platform.engine.reporting.ReportEntry +import org.junit.platform.testkit.engine.EngineExecutionResults +import java.util.stream.Stream + +class KotlinSuspendFunctionsTests : AbstractJupiterTestEngineTests() { + @Test + fun suspendingTestMethodsAreSupported() { + val results = executeTestsForClass(TestMethodTestCase::class) + assertAllTestsPassed(results, 1) + assertThat(getPublishedEvents(results)).containsExactly("test") + } + + @Test + fun suspendingTestTemplateMethodsAreSupported() { + val results = executeTestsForClass(TestTemplateTestCase::class) + assertAllTestsPassed(results, 2) + assertThat(getPublishedEvents(results)).containsExactly("foo", "bar") + } + + @Test + fun suspendingTestFactoryMethodsAreSupported() { + val results = executeTestsForClass(TestFactoryTestCase::class) + assertAllTestsPassed(results, 2) + assertThat(getPublishedEvents(results)).containsExactly("test", "foo", "bar") + } + + @Test + fun suspendingLifecycleMethodsAreSupported() { + val results = executeTestsForClass(LifecycleMethodsTestCase::class) + assertAllTestsPassed(results, 1) + assertThat(getPublishedEvents(results)).containsExactly("beforeAll", "beforeEach", "test", "afterEach", "afterAll") + } + + @Test + fun suspendingParameterizedLifecycleMethodsAreSupported() { + val results = executeTestsForClass(ParameterizedLifecycleMethodsTestCase::class) + assertAllTestsPassed(results, 2) + assertThat( + getPublishedEvents(results) + ).containsExactly("beforeInvocation[1]", "test[1]", "afterInvocation[1]", "beforeInvocation[2]", "test[2]", "afterInvocation[2]") + } + + private fun assertAllTestsPassed( + results: EngineExecutionResults, + numTests: Long + ) { + results.testEvents().assertStatistics { + it.started(numTests).succeeded(numTests) + } + } + + private fun getPublishedEvents(results: EngineExecutionResults) = + results + .allEvents() + .reportingEntryPublished() // + .map { it.getRequiredPayload(ReportEntry::class.java) } // + .map(ReportEntry::getKeyValuePairs) + .map { it["value"] } + + @Suppress("JUnitMalformedDeclaration") + class TestMethodTestCase { + @Test + suspend fun test(reporter: TestReporter) { + suspendingPublish(reporter, "test") + } + } + + @Suppress("JUnitMalformedDeclaration") + class TestTemplateTestCase { + @ParameterizedTest + @ValueSource(strings = ["foo", "bar"]) + suspend fun test( + message: String, + reporter: TestReporter + ) { + suspendingPublish(reporter, message) + } + } + + class TestFactoryTestCase { + @TestFactory + suspend fun test(reporter: TestReporter): Stream { + suspendingPublish(reporter, "test") + return Stream.of("foo", "bar").map { + dynamicTest(it) { + runBlocking { + suspendingPublish(reporter, it) + } + } + } + } + } + + @Suppress("JUnitMalformedDeclaration") + @TestInstance(PER_CLASS) + class LifecycleMethodsTestCase { + @BeforeAll + suspend fun beforeAll(reporter: TestReporter) { + suspendingPublish(reporter, "beforeAll") + } + + @BeforeEach + suspend fun beforeEach(reporter: TestReporter) { + suspendingPublish(reporter, "beforeEach") + } + + @Test + suspend fun test(reporter: TestReporter) { + suspendingPublish(reporter, "test") + } + + @AfterEach + suspend fun afterEach(reporter: TestReporter) { + suspendingPublish(reporter, "afterEach") + } + + @AfterAll + suspend fun afterAll(reporter: TestReporter) { + suspendingPublish(reporter, "afterAll") + } + } + + @Suppress("JUnitMalformedDeclaration", "RedundantSuspendModifier") + @ParameterizedClass + @ValueSource(ints = [1, 2]) + @TestInstance(PER_CLASS) + class ParameterizedLifecycleMethodsTestCase { + @BeforeParameterizedClassInvocation + suspend fun beforeInvocation() {} + + @BeforeParameterizedClassInvocation + suspend fun beforeInvocation( + parameter: Int, + reporter: TestReporter + ) { + suspendingPublish(reporter, "beforeInvocation[$parameter]") + } + + @AfterParameterizedClassInvocation + suspend fun afterInvocation() {} + + @AfterParameterizedClassInvocation + suspend fun afterInvocation( + parameter: Int, + reporter: TestReporter + ) { + suspendingPublish(reporter, "afterInvocation[$parameter]") + } + + @Parameter + var parameter: Int = 0 + + @Test + suspend fun test(reporter: TestReporter) { + suspendingPublish(reporter, "test[$parameter]") + } + } + + @Suppress("RedundantSuspendModifier") + companion object { + suspend fun suspendingPublish( + reporter: TestReporter, + message: String + ) { + reporter.publishEntry(message) + } + } +} diff --git a/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-commons.expected.txt b/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-commons.expected.txt index 040ca4b3525e..0022a3df70bc 100644 --- a/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-commons.expected.txt +++ b/platform-tooling-support-tests/projects/jar-describe-module/junit-platform-commons.expected.txt @@ -8,6 +8,9 @@ exports org.junit.platform.commons.support.scanning requires java.base mandated requires java.logging requires java.management +requires kotlin.reflect static +requires kotlin.stdlib static +requires kotlinx.coroutines.core static requires org.apiguardian.api static transitive uses org.junit.platform.commons.support.scanning.ClasspathScanner qualified exports org.junit.platform.commons.logging to org.junit.jupiter.api org.junit.jupiter.engine org.junit.jupiter.migrationsupport org.junit.jupiter.params org.junit.platform.console org.junit.platform.engine org.junit.platform.launcher org.junit.platform.reporting org.junit.platform.suite.api org.junit.platform.suite.engine org.junit.platform.testkit org.junit.vintage.engine diff --git a/platform-tooling-support-tests/projects/kotlin-coroutines/build.gradle.kts b/platform-tooling-support-tests/projects/kotlin-coroutines/build.gradle.kts new file mode 100644 index 000000000000..602d9fadc900 --- /dev/null +++ b/platform-tooling-support-tests/projects/kotlin-coroutines/build.gradle.kts @@ -0,0 +1,37 @@ +plugins { + kotlin("jvm") version "2.1.20" +} + +val junitVersion: String by project + +repositories { + maven { url = uri(file(System.getProperty("maven.repo"))) } + mavenCentral() +} + +dependencies { + testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + + if (!project.hasProperty("withoutKotlinReflect")) { + testImplementation(kotlin("reflect")) + } + + if (!project.hasProperty("withoutKotlinxCoroutines")) { + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + } +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +tasks.test { + useJUnitPlatform() + testLogging { + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } + systemProperty("junit.platform.stacktrace.pruning.enabled", "false") +} diff --git a/platform-tooling-support-tests/projects/kotlin-coroutines/settings.gradle.kts b/platform-tooling-support-tests/projects/kotlin-coroutines/settings.gradle.kts new file mode 100644 index 000000000000..2e3f2dee51af --- /dev/null +++ b/platform-tooling-support-tests/projects/kotlin-coroutines/settings.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" +} + +rootProject.name = "kotlin-coroutines" diff --git a/platform-tooling-support-tests/projects/kotlin-coroutines/src/test/kotlin/com/example/project/SuspendFunctionTests.kt b/platform-tooling-support-tests/projects/kotlin-coroutines/src/test/kotlin/com/example/project/SuspendFunctionTests.kt new file mode 100644 index 000000000000..6c0e8a9b9149 --- /dev/null +++ b/platform-tooling-support-tests/projects/kotlin-coroutines/src/test/kotlin/com/example/project/SuspendFunctionTests.kt @@ -0,0 +1,20 @@ +/* + * 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 com.example.project + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.fail + +class SuspendFunctionTests { + @Test + suspend fun test() { + fail("expected") + } +} diff --git a/platform-tooling-support-tests/src/archUnit/java/platform/tooling/support/tests/ArchUnitTests.java b/platform-tooling-support-tests/src/archUnit/java/platform/tooling/support/tests/ArchUnitTests.java index abaaee787a29..c54641f46604 100644 --- a/platform-tooling-support-tests/src/archUnit/java/platform/tooling/support/tests/ArchUnitTests.java +++ b/platform-tooling-support-tests/src/archUnit/java/platform/tooling/support/tests/ArchUnitTests.java @@ -17,6 +17,7 @@ import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAnyPackage; import static com.tngtech.archunit.core.domain.JavaClass.Predicates.simpleName; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.simpleNameEndingWith; import static com.tngtech.archunit.core.domain.JavaModifier.PUBLIC; import static com.tngtech.archunit.core.domain.properties.HasModifiers.Predicates.modifier; import static com.tngtech.archunit.core.domain.properties.HasName.Predicates.name; @@ -61,6 +62,8 @@ class ArchUnitTests { .and(TOP_LEVEL_CLASSES) // .and(not(ANONYMOUS_CLASSES)) // .and(not(describe("are Kotlin SAM type implementations", simpleName("")))) // + .and(not(describe("are Kotlin-generated classes that contain only top-level functions", + simpleNameEndingWith("Kt")))) // .and(not(describe("are shadowed", resideInAnyPackage("..shadow..")))) // .should().beAnnotatedWith(API.class); diff --git a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/KotlinCoroutinesTests.java b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/KotlinCoroutinesTests.java new file mode 100644 index 000000000000..94f95d669a3e --- /dev/null +++ b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/KotlinCoroutinesTests.java @@ -0,0 +1,83 @@ +/* + * 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 platform.tooling.support.tests; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static platform.tooling.support.tests.Projects.copyToWorkspace; + +import java.io.IOException; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.platform.tests.process.OutputFiles; +import org.junit.platform.tests.process.ProcessResult; +import org.opentest4j.TestAbortedException; + +import platform.tooling.support.Helper; +import platform.tooling.support.MavenRepo; +import platform.tooling.support.ProcessStarters; + +/** + * @since 6.0 + */ +class KotlinCoroutinesTests { + + @Test + void failsExpectedlyWhenAllOptionalDependenciesArePresent(@TempDir Path workspace, + @FilePrefix("gradle") OutputFiles outputFiles) throws Exception { + var result = runBuild(workspace, outputFiles); + + assertEquals(1, result.exitCode(), "result=" + result); + assertThat(result.stdOut()).contains("AssertionFailedError: expected"); + assertThat(result.stdErr()).contains("BUILD FAILED"); + } + + @Test + void failsWithHelpfulErrorMessageWhenKotlinxCoroutinesIsMissing(@TempDir Path workspace, + @FilePrefix("gradle") OutputFiles outputFiles) throws Exception { + var result = runBuild(workspace, outputFiles, "-PwithoutKotlinxCoroutines"); + + assertEquals(1, result.exitCode(), "result=" + result); + assertThat(result.stdOut()).contains("PreconditionViolationException: Kotlin suspending function " + + "[public final java.lang.Object com.example.project.SuspendFunctionTests.test(kotlin.coroutines.Continuation)] " + + "requires org.jetbrains.kotlinx:kotlinx-coroutines-core to be on the classpath or module path. " + + "Please add a corresponding dependency."); + assertThat(result.stdErr()).contains("BUILD FAILED"); + } + + @Test + void failsWithHelpfulErrorMessageWhenKotlinReflectIsMissing(@TempDir Path workspace, + @FilePrefix("gradle") OutputFiles outputFiles) throws Exception { + var result = runBuild(workspace, outputFiles, "-PwithoutKotlinReflect"); + + assertEquals(1, result.exitCode(), "result=" + result); + assertThat(result.stdOut()).contains("PreconditionViolationException: Kotlin suspending function " + + "[public final java.lang.Object com.example.project.SuspendFunctionTests.test(kotlin.coroutines.Continuation)] " + + "requires org.jetbrains.kotlin:kotlin-reflect to be on the classpath or module path. " + + "Please add a corresponding dependency."); + assertThat(result.stdErr()).contains("BUILD FAILED"); + } + + private static ProcessResult runBuild(Path workspace, OutputFiles outputFiles, String... extraArgs) + throws InterruptedException, IOException { + + return ProcessStarters.gradlew() // + .workingDir(copyToWorkspace(Projects.KOTLIN_COROUTINES, workspace)) // + .addArguments("-Dmaven.repo=" + MavenRepo.dir()) // + .addArguments("build", "--no-daemon", "--stacktrace", "--no-build-cache", "--warning-mode=fail") // + .addArguments(extraArgs).putEnvironment("JDK17", + Helper.getJavaHome(17).orElseThrow(TestAbortedException::new).toString()) // + .redirectOutput(outputFiles) // + .startAndWait(); + } +} diff --git a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/Projects.java b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/Projects.java index b3edd252e4fa..63b4588d2650 100644 --- a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/Projects.java +++ b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/Projects.java @@ -22,6 +22,7 @@ public class Projects { public static final String GRADLE_MISSING_ENGINE = "gradle-missing-engine"; public static final String JAR_DESCRIBE_MODULE = "jar-describe-module"; public static final String JUPITER_STARTER = "jupiter-starter"; + public static final String KOTLIN_COROUTINES = "kotlin-coroutines"; public static final String MAVEN_SUREFIRE_COMPATIBILITY = "maven-surefire-compatibility"; public static final String MULTI_RELEASE_JAR = "multi-release-jar"; public static final String REFLECTION_TESTS = "reflection-tests";