From dd2d20adafc2f252cba1a54df5ee2a221e71ea48 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 23 Oct 2025 10:36:48 +0200 Subject: [PATCH 1/2] POC for using inline value classes as method arguments Issue: #5081 --- .../engine/support/MethodReflectionUtils.java | 12 ++ ...ionUtils.java => KotlinFunctionUtils.java} | 30 ++++- .../commons/util/KotlinReflectionUtils.java | 34 ++++- .../junit/jupiter/api/kotlin/ResultTest.kt | 125 ++++++++++++++++++ 4 files changed, 188 insertions(+), 13 deletions(-) rename junit-platform-commons/src/main/java/org/junit/platform/commons/util/{KotlinSuspendingFunctionUtils.java => KotlinFunctionUtils.java} (77%) create mode 100644 jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/ResultTest.kt 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 index 02c8ca228ff6..174cbcc2e5b7 100644 --- 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 @@ -13,15 +13,19 @@ 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.invokeKotlinFunction; import static org.junit.platform.commons.util.KotlinReflectionUtils.invokeKotlinSuspendingFunction; import static org.junit.platform.commons.util.KotlinReflectionUtils.isKotlinSuspendingFunction; +import static org.junit.platform.commons.util.KotlinReflectionUtils.isKotlinType; import java.lang.reflect.Method; import java.lang.reflect.Type; +import java.util.Arrays; import org.apiguardian.api.API; import org.jspecify.annotations.Nullable; import org.junit.platform.commons.support.ReflectionSupport; +import org.junit.platform.commons.util.KotlinReflectionUtils; @API(status = INTERNAL, since = "6.0") public class MethodReflectionUtils { @@ -42,9 +46,17 @@ public static Type getGenericReturnType(Method method) { if (isKotlinSuspendingFunction(method)) { return invokeKotlinSuspendingFunction(method, target, arguments); } + if (isKotlinType(method.getDeclaringClass()) && hasInlineTypeArgument(arguments)) { + return invokeKotlinFunction(method, target, arguments); + } return ReflectionSupport.invokeMethod(method, target, arguments); } + private static boolean hasInlineTypeArgument(@Nullable Object[] arguments) { + return arguments.length > 0 // + && Arrays.stream(arguments).anyMatch(KotlinReflectionUtils::isInstanceOfInlineType); + } + private MethodReflectionUtils() { } } diff --git a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/KotlinSuspendingFunctionUtils.java b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/KotlinFunctionUtils.java similarity index 77% rename from junit-platform-commons/src/main/java/org/junit/platform/commons/util/KotlinSuspendingFunctionUtils.java rename to junit-platform-commons/src/main/java/org/junit/platform/commons/util/KotlinFunctionUtils.java index fe2f348fea31..9d75be425d92 100644 --- a/junit-platform-commons/src/main/java/org/junit/platform/commons/util/KotlinSuspendingFunctionUtils.java +++ b/junit-platform-commons/src/main/java/org/junit/platform/commons/util/KotlinFunctionUtils.java @@ -37,7 +37,7 @@ import kotlin.reflect.KParameter; import kotlin.reflect.jvm.ReflectJvmMapping; -class KotlinSuspendingFunctionUtils { +class KotlinFunctionUtils { static Class getReturnType(Method method) { var returnType = getJavaClass(getJvmErasure(getKotlinFunction(method).getReturnType())); @@ -67,17 +67,35 @@ static Class[] getParameterTypes(Method method) { return Arrays.stream(method.getParameterTypes()).limit(parameterCount - 1).toArray(Class[]::new); } - static @Nullable Object invoke(Method method, @Nullable Object target, @Nullable Object[] args) { + static @Nullable Object invokeKotlinFunction(Method method, @Nullable Object target, @Nullable Object[] args) { try { - return invoke(getKotlinFunction(method), target, args); + return invokeKotlinFunction(getKotlinFunction(method), target, args); } catch (InterruptedException e) { throw throwAsUncheckedException(e); } } - private static @Nullable T invoke(KFunction function, @Nullable Object target, @Nullable Object[] args) - throws InterruptedException { + private static T invokeKotlinFunction(KFunction function, @Nullable Object target, + @Nullable Object[] args) throws InterruptedException { + if (!isAccessible(function)) { + setAccessible(function, true); + } + return function.callBy(toArgumentMap(target, args, function)); + } + + static @Nullable Object invokeKotlinSuspendingFunction(Method method, @Nullable Object target, + @Nullable Object[] args) { + try { + return invokeKotlinSuspendingFunction(getKotlinFunction(method), target, args); + } + catch (InterruptedException e) { + throw throwAsUncheckedException(e); + } + } + + private static T invokeKotlinSuspendingFunction(KFunction function, + @Nullable Object target, @Nullable Object[] args) throws InterruptedException { if (!isAccessible(function)) { setAccessible(function, true); } @@ -113,6 +131,6 @@ private static KFunction getKotlinFunction(Method method) { () -> "Failed to get Kotlin function for method: " + method); } - private KotlinSuspendingFunctionUtils() { + private KotlinFunctionUtils() { } } 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 index 626f3247c552..fb69651de0cd 100644 --- 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 @@ -37,6 +37,7 @@ public class KotlinReflectionUtils { private static final String DEFAULT_IMPLS_CLASS_NAME = "DefaultImpls"; private static final @Nullable Class kotlinMetadata; + private static final @Nullable Class jvmInline; private static final @Nullable Class kotlinCoroutineContinuation; private static final boolean kotlinReflectPresent; private static final boolean kotlinxCoroutinesPresent; @@ -44,6 +45,7 @@ public class KotlinReflectionUtils { static { var metadata = tryToLoadKotlinMetadataClass(); kotlinMetadata = metadata.toOptional().orElse(null); + jvmInline = tryToLoadJvmInlineClass().toOptional().orElse(null); kotlinCoroutineContinuation = metadata // .andThen(__ -> tryToLoadClass("kotlin.coroutines.Continuation")) // .toOptional() // @@ -62,6 +64,12 @@ private static Try> tryToLoadKotlinMetadataClass() { .andThenTry(it -> (Class) it); } + @SuppressWarnings("unchecked") + private static Try> tryToLoadJvmInlineClass() { + return tryToLoadClass("kotlin.jvm.JvmInline") // + .andThenTry(it -> (Class) it); + } + /** * @since 6.0 */ @@ -117,36 +125,48 @@ private static Class[] copyWithoutFirst(Class[] values) { return result; } - private static boolean isKotlinType(Class clazz) { + public static boolean isKotlinType(Class clazz) { return kotlinMetadata != null // && clazz.getDeclaredAnnotation(kotlinMetadata) != null; } public static Class getKotlinSuspendingFunctionReturnType(Method method) { requireKotlinReflect(method); - return KotlinSuspendingFunctionUtils.getReturnType(method); + return KotlinFunctionUtils.getReturnType(method); } public static Type getKotlinSuspendingFunctionGenericReturnType(Method method) { requireKotlinReflect(method); - return KotlinSuspendingFunctionUtils.getGenericReturnType(method); + return KotlinFunctionUtils.getGenericReturnType(method); } public static Parameter[] getKotlinSuspendingFunctionParameters(Method method) { requireKotlinReflect(method); - return KotlinSuspendingFunctionUtils.getParameters(method); + return KotlinFunctionUtils.getParameters(method); } public static Class[] getKotlinSuspendingFunctionParameterTypes(Method method) { requireKotlinReflect(method); - return KotlinSuspendingFunctionUtils.getParameterTypes(method); + return KotlinFunctionUtils.getParameterTypes(method); } public static @Nullable Object invokeKotlinSuspendingFunction(Method method, @Nullable Object target, @Nullable Object[] args) { requireKotlinReflect(method); requireKotlinxCoroutines(method); - return KotlinSuspendingFunctionUtils.invoke(method, target, args); + return KotlinFunctionUtils.invokeKotlinSuspendingFunction(method, target, args); + } + + public static boolean isInstanceOfInlineType(@Nullable Object value) { + return jvmInline != null // + && value != null // + && value.getClass().getDeclaredAnnotation(jvmInline) != null; + } + + public static @Nullable Object invokeKotlinFunction(Method method, @Nullable Object target, + @Nullable Object... args) { + requireKotlinReflect(method); + return KotlinFunctionUtils.invokeKotlinFunction(method, target, args); } private static void requireKotlinReflect(Method method) { @@ -159,7 +179,7 @@ private static void requireKotlinxCoroutines(Method method) { 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. " + () -> ("Kotlin function [%s] requires %s to be on the classpath or module path. " + "Please add a corresponding dependency.").formatted(method.toGenericString(), dependencyNotation)); } diff --git a/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/ResultTest.kt b/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/ResultTest.kt new file mode 100644 index 000000000000..b23dc9e62ff0 --- /dev/null +++ b/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/ResultTest.kt @@ -0,0 +1,125 @@ +/* + * 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.kotlin + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +class ResultTest { + /** + * This test passes. + * Good pass: .getOrThrow() returns the expected type and value. + */ + @Test + fun normal() { + val result: Result = Result.success("something") + val actual = result.getOrThrow() + assertEquals("something", actual) + } + + /** + * This test passes. + * Good pass: the cast is invalid and therefore .getOrThrow() should throw as it does. + */ + @Test + fun cast() { + val result: Result = Result.success("something") + + @Suppress("UNCHECKED_CAST") + val castResult = result as Result> + assertThrows { + val actual = castResult.getOrThrow() + } + } + + /** + * This test passes. + * Good pass: direct calling the method returns the right type. + * This to me proves that the issue is somewhere inside @ParameterizedTest handling. + */ + @Test + fun direct() { + val args = valueProviderFull() + + @Suppress("UNCHECKED_CAST") + val result: Result = args.single().get().single() as Result + val actual = result.getOrThrow() + assertEquals("something", actual) + } + + /** + * This test passes. + * Good pass: the type of the parameter matches the type of the value provided as the argument from method source. + */ + @MethodSource("valueProviderRaw") + @ParameterizedTest + fun parameterizedRaw(value: String) { + val result: Result = Result.success(value) + val actual = result.getOrThrow() + assertEquals("something", actual) + } + + /** + * This test errors with: + * > java.lang.ClassCastException: class kotlin.Result cannot be cast to class java.lang.String. + * This test should pass, because the argument from the method source is a Result. + */ + @MethodSource("valueProviderFull") + @ParameterizedTest + fun parameterizedFull(result: Result) { + val actual = result.getOrThrow() + assertEquals("something", actual) + } + + /** + * This test passes. + * This test should fail when trying to call `castResult.getOrThrow()`. + */ + @MethodSource("valueProviderFull") + @ParameterizedTest + fun parameterizedFullCast(result: Result) { + @Suppress("UNCHECKED_CAST") + val castResult = result as Result> + val actual = castResult.getOrThrow() + assertEquals(Result.success("something"), actual) + assertEquals("something", actual.getOrThrow()) + } + + /** + * This test passes. + * This test should fail when trying to call `result.getOrThrow()`, + * because the provided argument is a Result. + */ + @MethodSource("valueProviderFull") + @ParameterizedTest + fun parameterizedFullMistyped(result: Result>) { + val actual = result.getOrThrow() + assertEquals(Result.success("something"), actual) + assertEquals("something", actual.getOrThrow()) + } + + companion object { + @JvmStatic + fun valueProviderRaw() = + listOf( + Arguments.of("something") + ) + + @JvmStatic + fun valueProviderFull() = + listOf( + Arguments.of(Result.success("something")) + ) + } +} From 5729ba635c1164e5c2c10cf4fb8f0bed7e3c270e Mon Sep 17 00:00:00 2001 From: Ogu1208 Date: Sat, 8 Nov 2025 22:22:50 +0900 Subject: [PATCH 2/2] Add inline value class test --- .../api/kotlin/CustomInlineValueClassTest.kt | 39 +++++++++++++++ .../kotlin/MultipleInlineValueClassTest.kt | 47 +++++++++++++++++++ .../kotlin/NullableInlineValueClassTest.kt | 38 +++++++++++++++ .../junit/jupiter/api/kotlin/ValueClasses.kt | 7 +++ 4 files changed, 131 insertions(+) create mode 100644 jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/CustomInlineValueClassTest.kt create mode 100644 jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/MultipleInlineValueClassTest.kt create mode 100644 jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/NullableInlineValueClassTest.kt create mode 100644 jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/ValueClasses.kt diff --git a/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/CustomInlineValueClassTest.kt b/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/CustomInlineValueClassTest.kt new file mode 100644 index 000000000000..56ff02afc40c --- /dev/null +++ b/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/CustomInlineValueClassTest.kt @@ -0,0 +1,39 @@ +package org.junit.jupiter.api.kotlin + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +/** + * Tests for custom inline value classes. + * + * Currently, disabled: The POC only supports kotlin.Result. + * Support for arbitrary inline value classes needs to be added. + * + * @see Issue #5081 + */ +@Disabled("POC only supports kotlin.Result, not custom inline value classes") +class CustomInlineValueClassTest { + + @MethodSource("userIdProvider") + @ParameterizedTest + fun testUserId(userId: UserId) { + assertEquals(123L, userId.value) + } + + @MethodSource("emailProvider") + @ParameterizedTest + fun testEmail(email: Email) { + assertEquals("test@example.com", email.value) + } + + companion object { + @JvmStatic + fun userIdProvider() = listOf(Arguments.of(UserId(123L))) + + @JvmStatic + fun emailProvider() = listOf(Arguments.of(Email("test@example.com"))) + } +} \ No newline at end of file diff --git a/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/MultipleInlineValueClassTest.kt b/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/MultipleInlineValueClassTest.kt new file mode 100644 index 000000000000..e1db2369fe4a --- /dev/null +++ b/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/MultipleInlineValueClassTest.kt @@ -0,0 +1,47 @@ +package org.junit.jupiter.api.kotlin + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +class MultipleInlineValueClassTest { + + @MethodSource("mixedProvider") + @ParameterizedTest + fun testMultipleValueClasses( + userId: UserId, + email: Email, + result: Result + ) { + assertEquals(100L, userId.value) + assertEquals("user@test.com", email.value) + assertEquals("data", result.getOrThrow()) + } + + @MethodSource("normalAndValueClassProvider") + @ParameterizedTest + fun testMixedParameters( + normalString: String, + userId: UserId + ) { + assertEquals("normal", normalString) + assertEquals(200L, userId.value) + } + + companion object { + @JvmStatic + fun mixedProvider() = listOf( + Arguments.of( + UserId(100L), + Email("user@test.com"), + Result.success("data") + ) + ) + + @JvmStatic + fun normalAndValueClassProvider() = listOf( + Arguments.of("normal", UserId(200L)) + ) + } +} \ No newline at end of file diff --git a/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/NullableInlineValueClassTest.kt b/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/NullableInlineValueClassTest.kt new file mode 100644 index 000000000000..8cde07d6819d --- /dev/null +++ b/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/NullableInlineValueClassTest.kt @@ -0,0 +1,38 @@ +package org.junit.jupiter.api.kotlin + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.assertNotNull +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +class NullableInlineValueClassTest { + + @MethodSource("nullableResultProvider") + @ParameterizedTest + fun testNullableResult(result: Result?) { + assertNotNull(result) + assertEquals("test", result.getOrNull()) + } + + @MethodSource("nullableUserIdProvider") + @ParameterizedTest + fun testNullableUserId(userId: UserId?) { + assertNotNull(userId) + assertEquals(999L, userId.value) + } + + companion object { + @JvmStatic + fun nullableResultProvider() = + listOf( + Arguments.of(Result.success("test")) + ) + + @JvmStatic + fun nullableUserIdProvider() = + listOf( + Arguments.of(UserId(999L)) + ) + } +} \ No newline at end of file diff --git a/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/ValueClasses.kt b/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/ValueClasses.kt new file mode 100644 index 000000000000..effe8a3bf66d --- /dev/null +++ b/jupiter-tests/src/test/kotlin/org/junit/jupiter/api/kotlin/ValueClasses.kt @@ -0,0 +1,7 @@ +package org.junit.jupiter.api.kotlin + +@JvmInline +value class UserId(val value: Long) + +@JvmInline +value class Email(val value: String) \ No newline at end of file