Skip to content

Commit dd2d20a

Browse files
committed
POC for using inline value classes as method arguments
Issue: #5081
1 parent b7f0e4e commit dd2d20a

File tree

4 files changed

+188
-13
lines changed

4 files changed

+188
-13
lines changed

junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/support/MethodReflectionUtils.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,19 @@
1313
import static org.apiguardian.api.API.Status.INTERNAL;
1414
import static org.junit.platform.commons.util.KotlinReflectionUtils.getKotlinSuspendingFunctionGenericReturnType;
1515
import static org.junit.platform.commons.util.KotlinReflectionUtils.getKotlinSuspendingFunctionReturnType;
16+
import static org.junit.platform.commons.util.KotlinReflectionUtils.invokeKotlinFunction;
1617
import static org.junit.platform.commons.util.KotlinReflectionUtils.invokeKotlinSuspendingFunction;
1718
import static org.junit.platform.commons.util.KotlinReflectionUtils.isKotlinSuspendingFunction;
19+
import static org.junit.platform.commons.util.KotlinReflectionUtils.isKotlinType;
1820

1921
import java.lang.reflect.Method;
2022
import java.lang.reflect.Type;
23+
import java.util.Arrays;
2124

2225
import org.apiguardian.api.API;
2326
import org.jspecify.annotations.Nullable;
2427
import org.junit.platform.commons.support.ReflectionSupport;
28+
import org.junit.platform.commons.util.KotlinReflectionUtils;
2529

2630
@API(status = INTERNAL, since = "6.0")
2731
public class MethodReflectionUtils {
@@ -42,9 +46,17 @@ public static Type getGenericReturnType(Method method) {
4246
if (isKotlinSuspendingFunction(method)) {
4347
return invokeKotlinSuspendingFunction(method, target, arguments);
4448
}
49+
if (isKotlinType(method.getDeclaringClass()) && hasInlineTypeArgument(arguments)) {
50+
return invokeKotlinFunction(method, target, arguments);
51+
}
4552
return ReflectionSupport.invokeMethod(method, target, arguments);
4653
}
4754

55+
private static boolean hasInlineTypeArgument(@Nullable Object[] arguments) {
56+
return arguments.length > 0 //
57+
&& Arrays.stream(arguments).anyMatch(KotlinReflectionUtils::isInstanceOfInlineType);
58+
}
59+
4860
private MethodReflectionUtils() {
4961
}
5062
}
Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
import kotlin.reflect.KParameter;
3838
import kotlin.reflect.jvm.ReflectJvmMapping;
3939

40-
class KotlinSuspendingFunctionUtils {
40+
class KotlinFunctionUtils {
4141

4242
static Class<?> getReturnType(Method method) {
4343
var returnType = getJavaClass(getJvmErasure(getKotlinFunction(method).getReturnType()));
@@ -67,17 +67,35 @@ static Class<?>[] getParameterTypes(Method method) {
6767
return Arrays.stream(method.getParameterTypes()).limit(parameterCount - 1).toArray(Class<?>[]::new);
6868
}
6969

70-
static @Nullable Object invoke(Method method, @Nullable Object target, @Nullable Object[] args) {
70+
static @Nullable Object invokeKotlinFunction(Method method, @Nullable Object target, @Nullable Object[] args) {
7171
try {
72-
return invoke(getKotlinFunction(method), target, args);
72+
return invokeKotlinFunction(getKotlinFunction(method), target, args);
7373
}
7474
catch (InterruptedException e) {
7575
throw throwAsUncheckedException(e);
7676
}
7777
}
7878

79-
private static <T> @Nullable T invoke(KFunction<T> function, @Nullable Object target, @Nullable Object[] args)
80-
throws InterruptedException {
79+
private static <T extends @Nullable Object> T invokeKotlinFunction(KFunction<T> function, @Nullable Object target,
80+
@Nullable Object[] args) throws InterruptedException {
81+
if (!isAccessible(function)) {
82+
setAccessible(function, true);
83+
}
84+
return function.callBy(toArgumentMap(target, args, function));
85+
}
86+
87+
static @Nullable Object invokeKotlinSuspendingFunction(Method method, @Nullable Object target,
88+
@Nullable Object[] args) {
89+
try {
90+
return invokeKotlinSuspendingFunction(getKotlinFunction(method), target, args);
91+
}
92+
catch (InterruptedException e) {
93+
throw throwAsUncheckedException(e);
94+
}
95+
}
96+
97+
private static <T extends @Nullable Object> T invokeKotlinSuspendingFunction(KFunction<T> function,
98+
@Nullable Object target, @Nullable Object[] args) throws InterruptedException {
8199
if (!isAccessible(function)) {
82100
setAccessible(function, true);
83101
}
@@ -113,6 +131,6 @@ private static KFunction<?> getKotlinFunction(Method method) {
113131
() -> "Failed to get Kotlin function for method: " + method);
114132
}
115133

116-
private KotlinSuspendingFunctionUtils() {
134+
private KotlinFunctionUtils() {
117135
}
118136
}

junit-platform-commons/src/main/java/org/junit/platform/commons/util/KotlinReflectionUtils.java

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@ public class KotlinReflectionUtils {
3737
private static final String DEFAULT_IMPLS_CLASS_NAME = "DefaultImpls";
3838

3939
private static final @Nullable Class<? extends Annotation> kotlinMetadata;
40+
private static final @Nullable Class<? extends Annotation> jvmInline;
4041
private static final @Nullable Class<?> kotlinCoroutineContinuation;
4142
private static final boolean kotlinReflectPresent;
4243
private static final boolean kotlinxCoroutinesPresent;
4344

4445
static {
4546
var metadata = tryToLoadKotlinMetadataClass();
4647
kotlinMetadata = metadata.toOptional().orElse(null);
48+
jvmInline = tryToLoadJvmInlineClass().toOptional().orElse(null);
4749
kotlinCoroutineContinuation = metadata //
4850
.andThen(__ -> tryToLoadClass("kotlin.coroutines.Continuation")) //
4951
.toOptional() //
@@ -62,6 +64,12 @@ private static Try<Class<? extends Annotation>> tryToLoadKotlinMetadataClass() {
6264
.andThenTry(it -> (Class<? extends Annotation>) it);
6365
}
6466

67+
@SuppressWarnings("unchecked")
68+
private static Try<Class<? extends Annotation>> tryToLoadJvmInlineClass() {
69+
return tryToLoadClass("kotlin.jvm.JvmInline") //
70+
.andThenTry(it -> (Class<? extends Annotation>) it);
71+
}
72+
6573
/**
6674
* @since 6.0
6775
*/
@@ -117,36 +125,48 @@ private static Class<?>[] copyWithoutFirst(Class<?>[] values) {
117125
return result;
118126
}
119127

120-
private static boolean isKotlinType(Class<?> clazz) {
128+
public static boolean isKotlinType(Class<?> clazz) {
121129
return kotlinMetadata != null //
122130
&& clazz.getDeclaredAnnotation(kotlinMetadata) != null;
123131
}
124132

125133
public static Class<?> getKotlinSuspendingFunctionReturnType(Method method) {
126134
requireKotlinReflect(method);
127-
return KotlinSuspendingFunctionUtils.getReturnType(method);
135+
return KotlinFunctionUtils.getReturnType(method);
128136
}
129137

130138
public static Type getKotlinSuspendingFunctionGenericReturnType(Method method) {
131139
requireKotlinReflect(method);
132-
return KotlinSuspendingFunctionUtils.getGenericReturnType(method);
140+
return KotlinFunctionUtils.getGenericReturnType(method);
133141
}
134142

135143
public static Parameter[] getKotlinSuspendingFunctionParameters(Method method) {
136144
requireKotlinReflect(method);
137-
return KotlinSuspendingFunctionUtils.getParameters(method);
145+
return KotlinFunctionUtils.getParameters(method);
138146
}
139147

140148
public static Class<?>[] getKotlinSuspendingFunctionParameterTypes(Method method) {
141149
requireKotlinReflect(method);
142-
return KotlinSuspendingFunctionUtils.getParameterTypes(method);
150+
return KotlinFunctionUtils.getParameterTypes(method);
143151
}
144152

145153
public static @Nullable Object invokeKotlinSuspendingFunction(Method method, @Nullable Object target,
146154
@Nullable Object[] args) {
147155
requireKotlinReflect(method);
148156
requireKotlinxCoroutines(method);
149-
return KotlinSuspendingFunctionUtils.invoke(method, target, args);
157+
return KotlinFunctionUtils.invokeKotlinSuspendingFunction(method, target, args);
158+
}
159+
160+
public static boolean isInstanceOfInlineType(@Nullable Object value) {
161+
return jvmInline != null //
162+
&& value != null //
163+
&& value.getClass().getDeclaredAnnotation(jvmInline) != null;
164+
}
165+
166+
public static @Nullable Object invokeKotlinFunction(Method method, @Nullable Object target,
167+
@Nullable Object... args) {
168+
requireKotlinReflect(method);
169+
return KotlinFunctionUtils.invokeKotlinFunction(method, target, args);
150170
}
151171

152172
private static void requireKotlinReflect(Method method) {
@@ -159,7 +179,7 @@ private static void requireKotlinxCoroutines(Method method) {
159179

160180
private static void requireDependency(Method method, boolean condition, String dependencyNotation) {
161181
Preconditions.condition(condition,
162-
() -> ("Kotlin suspending function [%s] requires %s to be on the classpath or module path. "
182+
() -> ("Kotlin function [%s] requires %s to be on the classpath or module path. "
163183
+ "Please add a corresponding dependency.").formatted(method.toGenericString(),
164184
dependencyNotation));
165185
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* Copyright 2015-2025 the original author or authors.
3+
*
4+
* All rights reserved. This program and the accompanying materials are
5+
* made available under the terms of the Eclipse Public License v2.0 which
6+
* accompanies this distribution and is available at
7+
*
8+
* https://www.eclipse.org/legal/epl-v20.html
9+
*/
10+
package org.junit.jupiter.api.kotlin
11+
12+
import org.junit.jupiter.api.Assertions.assertEquals
13+
import org.junit.jupiter.api.Test
14+
import org.junit.jupiter.api.assertThrows
15+
import org.junit.jupiter.params.ParameterizedTest
16+
import org.junit.jupiter.params.provider.Arguments
17+
import org.junit.jupiter.params.provider.MethodSource
18+
19+
class ResultTest {
20+
/**
21+
* This test passes.
22+
* Good pass: .getOrThrow() returns the expected type and value.
23+
*/
24+
@Test
25+
fun normal() {
26+
val result: Result<String> = Result.success("something")
27+
val actual = result.getOrThrow()
28+
assertEquals("something", actual)
29+
}
30+
31+
/**
32+
* This test passes.
33+
* Good pass: the cast is invalid and therefore .getOrThrow() should throw as it does.
34+
*/
35+
@Test
36+
fun cast() {
37+
val result: Result<String> = Result.success("something")
38+
39+
@Suppress("UNCHECKED_CAST")
40+
val castResult = result as Result<Result<String>>
41+
assertThrows<ClassCastException> {
42+
val actual = castResult.getOrThrow()
43+
}
44+
}
45+
46+
/**
47+
* This test passes.
48+
* Good pass: direct calling the method returns the right type.
49+
* This to me proves that the issue is somewhere inside @ParameterizedTest handling.
50+
*/
51+
@Test
52+
fun direct() {
53+
val args = valueProviderFull()
54+
55+
@Suppress("UNCHECKED_CAST")
56+
val result: Result<String> = args.single().get().single() as Result<String>
57+
val actual = result.getOrThrow()
58+
assertEquals("something", actual)
59+
}
60+
61+
/**
62+
* This test passes.
63+
* Good pass: the type of the parameter matches the type of the value provided as the argument from method source.
64+
*/
65+
@MethodSource("valueProviderRaw")
66+
@ParameterizedTest
67+
fun parameterizedRaw(value: String) {
68+
val result: Result<String> = Result.success(value)
69+
val actual = result.getOrThrow()
70+
assertEquals("something", actual)
71+
}
72+
73+
/**
74+
* This test errors with:
75+
* > java.lang.ClassCastException: class kotlin.Result cannot be cast to class java.lang.String.
76+
* This test should pass, because the argument from the method source is a Result<String>.
77+
*/
78+
@MethodSource("valueProviderFull")
79+
@ParameterizedTest
80+
fun parameterizedFull(result: Result<String>) {
81+
val actual = result.getOrThrow()
82+
assertEquals("something", actual)
83+
}
84+
85+
/**
86+
* This test passes.
87+
* This test should fail when trying to call `castResult.getOrThrow()`.
88+
*/
89+
@MethodSource("valueProviderFull")
90+
@ParameterizedTest
91+
fun parameterizedFullCast(result: Result<String>) {
92+
@Suppress("UNCHECKED_CAST")
93+
val castResult = result as Result<Result<String>>
94+
val actual = castResult.getOrThrow()
95+
assertEquals(Result.success("something"), actual)
96+
assertEquals("something", actual.getOrThrow())
97+
}
98+
99+
/**
100+
* This test passes.
101+
* This test should fail when trying to call `result.getOrThrow()`,
102+
* because the provided argument is a Result<String>.
103+
*/
104+
@MethodSource("valueProviderFull")
105+
@ParameterizedTest
106+
fun parameterizedFullMistyped(result: Result<Result<String>>) {
107+
val actual = result.getOrThrow()
108+
assertEquals(Result.success("something"), actual)
109+
assertEquals("something", actual.getOrThrow())
110+
}
111+
112+
companion object {
113+
@JvmStatic
114+
fun valueProviderRaw() =
115+
listOf(
116+
Arguments.of("something")
117+
)
118+
119+
@JvmStatic
120+
fun valueProviderFull() =
121+
listOf(
122+
Arguments.of(Result.success("something"))
123+
)
124+
}
125+
}

0 commit comments

Comments
 (0)