Skip to content

Spike support for Kotlin suspending functions #4515

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 19 commits into from

Conversation

marcphilipp
Copy link
Member

@marcphilipp marcphilipp commented May 5, 2025

Overview

Issue: #1914

Given the function definition of suspend fun suspendingTest(message: String): Unit, the Kotlin compiler generates byte code that results in Java's reflection API seeing:

public final java.lang.Object suspendingTest(
  java.lang.String,kotlin.coroutines.Continuation<? super kotlin.Unit>)

The return type is java.lang.Object and an extra parameter of type kotlin.coroutines.Continuation<? super T> where T is the declared return type is added. Since Continuation<...> can be used as a method parameter for a regular function, its presence is insufficient to determine whether a method is a suspending function. Thus, a dependency on kotlin-reflect is needed so KFunction.isSuspend can be called.

If a suspending function is detected, it can be called using KCallables.callSuspend (also from kotlin-reflect) while wrapping it in runBlocking from kotlinx-coroutines.

To avoid the extra dependencies from being added to junit-jupiter-engine, the prototype in this PR adds a new junit-jupiter-kotlin artifact. The former defines a new MethodAdapterFactory interface that the latter implements via ServiceLoader. The returned MethodAdapter is used for validation (return type), parameter resolution (ignore the Continuation parameter), and invocation (via KCallables.callSuspend rather than ReflectionUtils).

The PR adds support for using suspend on @Test, @TestTemplate (including but not limited to @ParameterizedTest and @RepeatedTest), @TestFactory, and all lifecycle methods. Moreover, it also adds support for calling suspend methods via ExecutableInvoker which is used to support @After/@BeforeClassTemplateInvocationCallback lifecycle methods. See the KotlinSuspendFunctionsTests test class for a demo.

Open Questions

  • Can the extra junit-jupiter-kotlin artifact be avoided somehow without forcing Kotlin dependencies on plain-Java users and without forcing Kotlin users to declare extra dependencies on kotlin-reflect and kotlinx-coroutines?

I hereby agree to the terms of the JUnit Contributor License Agreement.


Definition of Done

Comment on lines +203 to +206
if (parameterTypes[parameterTypes.length - 1].getName().equals("kotlin.coroutines.Continuation")) {
// Kotlin suspend functions have a Continuation parameter at the end that should not be included
parameterTypes = Arrays.copyOf(parameterTypes, parameterTypes.length - 1);
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 This removes the Continuation parameter from display names because it's generated by the Kotlin compiler.

@@ -1,5 +1,5 @@
plugins {
id("junitbuild.kotlin-library-conventions")
id("junitbuild.java-library-conventions")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 We were not actually using Kotlin in this project.

@sbrannen
Copy link
Member

sbrannen commented May 5, 2025

  • Can the extra junit-jupiter-kotlin artifact be avoided somehow without forcing Kotlin dependencies on plain-Java users

Yes, Spring Framework provides transparent Kotlin support without Kotlin-specific Spring artifacts.

and without forcing Kotlin users to declare extra dependencies on kotlin-reflect and kotlinx-coroutines?

Probably not.

@marcphilipp
Copy link
Member Author

@sbrannen Thanks for the pointers!

I think we have two options:

  1. Make the Kotlin dependencies optional (like Spring does)
  2. Create an extra artifact/variant that carries the dependencies

As far as manual effort is concerned, (1) requires adding at least two dependencies (kotlin-reflect and kotlinx-coroutines) while (2) requires adding only one (junit-jupiter-kotlin or whichever name we decide for). If we add support for some other Kotlin feature, it might require additional dependencies which we could add to our artifact/variant in case of (2) without users having to modify their builds again. Moreover, I think (2) is cleaner and carries a smaller risk of running into ClassNotFoundErrors etc. at runtime.

Are there any other pros/cons?

@marcphilipp marcphilipp force-pushed the marc/1914-kotlin-suspend-support branch from 24b1f79 to 83aaa48 Compare May 5, 2025 12:36
@marcphilipp marcphilipp force-pushed the marc/1914-kotlin-suspend-support branch from 83aaa48 to 329f2d6 Compare May 5, 2025 12:40
@@ -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) //
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 This is already validated in junit-jupiter-engine so there's no need to do so again.

@jlink
Copy link
Contributor

jlink commented May 6, 2025

@sbrannen Thanks for the pointers!

I think we have two options:

1. Make the Kotlin dependencies optional (like Spring does)

2. Create an extra artifact/variant that carries the dependencies

As far as manual effort is concerned, (1) requires adding at least two dependencies (kotlin-reflect and kotlinx-coroutines) while (2) requires adding only one (junit-jupiter-kotlin or whichever name we decide for). If we add support for some other Kotlin feature, it might require additional dependencies which we could add to our artifact/variant in case of (2) without users having to modify their builds again. Moreover, I think (2) is cleaner and carries a smaller risk of running into ClassNotFoundErrors etc. at runtime.

Are there any other pros/cons?

IMO having an explicit artifact for Kotlin support is sending a clear message: JUnit Jupiter wants to support Kotlin. That's a win in my book. And it opens the door for further support, e.g. providing Kotlin-style API for some constructs.

override fun getReturnType() =
with(kotlinFunction.returnType.jvmErasure) {
if (this == Unit::class) {
Void.TYPE
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Void.TYPE
void.class


public MethodAdapterRegistry() {
// Load and instantiate factories eagerly to avoid GraalVM issue
this.factories = ServiceLoader.load(MethodAdapterFactory.class).stream() //
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ Use getDefaultClassLoader() here

@marcphilipp
Copy link
Member Author

Superseded by #4530.

@marcphilipp marcphilipp deleted the marc/1914-kotlin-suspend-support branch May 14, 2025 18:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support test methods with Kotlin suspend modifier
4 participants