Skip to content

Commit 2e09def

Browse files
committed
Add @MethodConversion converter
1 parent 2a8128e commit 2e09def

7 files changed

Lines changed: 559 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
description: An argument converter using a conversion method
3+
---
4+
5+
# `@MethodConversion`
6+
7+
`@MethodConversion` is an annotation that converts instances using a conversion method defined either in the test class
8+
or in an external class.
9+
10+
Conversion methods in the test class must be `static` unless the test class is annotated with
11+
[`@TestInstance(Lifecycle.PER_CLASS)`](https://docs.junit.org/current/writing-tests/test-instance-lifecycle.html);
12+
conversion methods in external classes must always be `static`.

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ The following converters are available:
4343
* [`@Base64`](converters/base64.md): decodes Base64 instances into byte arrays
4444
* [`@Bytes`](converters/bytes.md): converts strings or numbers into byte arrays
4545
* [`@Hex`](converters/hex.md): decodes hexadecimal strings into byte arrays
46+
* [`@MethodConversion`](converters/method-conversion.md): converts instances by using a convertion method
4647
* [`@SpringConversion`](converters/spring-conversion.md): converts instances by using the Spring Framework type conversion
4748

4849
Do you have another converter in mind for your use case?

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ nav:
109109
- converters/base64.md
110110
- converters/bytes.md
111111
- converters/hex.md
112+
- converters/method-conversion.md
112113
- converters/spring-conversion.md
113114
- javadoc.md
114115
- release-notes.md
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/*
2+
* Copyright © 2025-present Stefano Cordio
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.github.scordio.junit.converters;
17+
18+
import org.jspecify.annotations.Nullable;
19+
import org.junit.jupiter.api.extension.ParameterContext;
20+
import org.junit.jupiter.params.converter.ArgumentConversionException;
21+
import org.junit.jupiter.params.converter.ArgumentConverter;
22+
import org.junit.jupiter.params.support.AnnotationConsumer;
23+
import org.junit.jupiter.params.support.FieldContext;
24+
import org.junit.platform.commons.support.HierarchyTraversalMode;
25+
import org.junit.platform.commons.support.ModifierSupport;
26+
import org.junit.platform.commons.support.ReflectionSupport;
27+
28+
import java.lang.reflect.Method;
29+
import java.util.List;
30+
import java.util.Objects;
31+
import java.util.function.Predicate;
32+
import java.util.stream.Collectors;
33+
34+
class MethodArgumentConverter implements ArgumentConverter, AnnotationConsumer<MethodConversion> {
35+
36+
private static final Predicate<Method> IS_STATIC = ModifierSupport::isStatic;
37+
38+
private @Nullable MethodConversion annotation;
39+
40+
@Override
41+
public void accept(MethodConversion annotation) {
42+
this.annotation = annotation;
43+
}
44+
45+
@Override
46+
public @Nullable Object convert(@Nullable Object source, ParameterContext context) {
47+
return convert(source, context.getParameter().getType(), context.getDeclaringExecutable().getDeclaringClass());
48+
}
49+
50+
@Override
51+
public @Nullable Object convert(@Nullable Object source, FieldContext context) {
52+
return convert(source, context.getField().getType(), context.getField().getDeclaringClass());
53+
}
54+
55+
private @Nullable Object convert(@Nullable Object source, Class<?> targetType, Class<?> declaringClass) {
56+
Objects.requireNonNull(source, "'null' is not supported");
57+
Objects.requireNonNull(annotation, "'annotation' must not be null");
58+
Class<?> sourceType = source.getClass();
59+
String methodName = annotation.value();
60+
Method conversionMethod = methodName.isEmpty()
61+
? findCompatibleConversionMethod(sourceType, targetType, declaringClass)
62+
: findConversionMethodByName(methodName, sourceType, targetType, declaringClass);
63+
return ReflectionSupport.invokeMethod(conversionMethod, null, source);
64+
}
65+
66+
private static Method findCompatibleConversionMethod(Class<?> sourceType, Class<?> targetType,
67+
Class<?> declaringClass) {
68+
Predicate<Method> filter = IS_STATIC.and(accepts(sourceType)).and(produces(targetType));
69+
List<Method> methods = ReflectionSupport.findMethods(declaringClass, filter, HierarchyTraversalMode.BOTTOM_UP);
70+
71+
if (methods.isEmpty()) {
72+
throw new ArgumentConversionException(
73+
String.format("No compatible conversion method found for source type %s and target type %s",
74+
sourceType.getName(), targetType.getName()));
75+
}
76+
77+
if (methods.size() > 1) {
78+
String signatures = methods.stream().map(method -> "\t\t" + method).collect(Collectors.joining("\n"));
79+
80+
throw new ArgumentConversionException(
81+
String.format("Too many compatible conversion methods for source type %s and target type %s:%n%s",
82+
sourceType.getName(), targetType.getName(), signatures));
83+
}
84+
85+
return methods.get(0);
86+
}
87+
88+
private static Method findConversionMethodByName(String name, Class<?> sourceType, Class<?> targetType,
89+
Class<?> declaringClass) {
90+
Predicate<Method> filter = IS_STATIC.and(hasName(name)).and(accepts(sourceType)).and(produces(targetType));
91+
List<Method> methods = ReflectionSupport.findMethods(declaringClass, filter, HierarchyTraversalMode.BOTTOM_UP);
92+
93+
if (methods.isEmpty()) {
94+
throw new ArgumentConversionException(
95+
String.format("No conversion method found with the following signature: static %s %s(%s)",
96+
targetType.getName(), name, sourceType.getName()));
97+
}
98+
99+
return methods.get(0);
100+
}
101+
102+
private static Predicate<Method> accepts(Class<?> sourceType) {
103+
return method -> {
104+
Class<?>[] parameterTypes = method.getParameterTypes();
105+
return parameterTypes.length == 1 && ReflectionUtils.isAssignableTo(sourceType, parameterTypes[0]);
106+
};
107+
}
108+
109+
private static Predicate<Method> produces(Class<?> targetType) {
110+
return method -> ReflectionUtils.isAssignableTo(method.getReturnType(), targetType);
111+
}
112+
113+
private static Predicate<Method> hasName(String name) {
114+
return method -> method.getName().equals(name);
115+
}
116+
117+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright © 2025-present Stefano Cordio
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.github.scordio.junit.converters;
17+
18+
import org.junit.jupiter.params.converter.ConvertWith;
19+
20+
import java.lang.annotation.Documented;
21+
import java.lang.annotation.ElementType;
22+
import java.lang.annotation.Retention;
23+
import java.lang.annotation.RetentionPolicy;
24+
import java.lang.annotation.Target;
25+
26+
/**
27+
* {@code @MethodConversion} is a {@link ConvertWith} composed annotation that converts
28+
* arguments using a {@linkplain #value() static method} declared in the test class.
29+
*
30+
* @since 0.2.0
31+
*/
32+
@Target({ ElementType.ANNOTATION_TYPE, ElementType.PARAMETER, ElementType.FIELD })
33+
@Retention(RetentionPolicy.RUNTIME)
34+
@Documented
35+
@ConvertWith(MethodArgumentConverter.class)
36+
@SuppressWarnings("exports")
37+
public @interface MethodConversion {
38+
39+
/**
40+
* The name of the conversion method within the test class to use for the conversion.
41+
* <p>
42+
* If no name is declared, the converter will look for a single static method within
43+
* the test class whose parameter type matches the source type and whose return type
44+
* matches the target type. The search traverses the class hierarchy with
45+
* {@linkplain org.junit.platform.commons.support.HierarchyTraversalMode#BOTTOM_UP
46+
* bottom-up} semantics.
47+
* @return the name of the conversion method to use
48+
*/
49+
String value() default "";
50+
51+
}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/*
2+
* Copyright © 2025-present Stefano Cordio
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.github.scordio.junit.converters;
17+
18+
import java.util.Collections;
19+
import java.util.IdentityHashMap;
20+
import java.util.Map;
21+
import java.util.Objects;
22+
23+
/**
24+
* Methods borrowed from {@link org.junit.platform.commons.util.ReflectionUtils}.
25+
*/
26+
class ReflectionUtils {
27+
28+
private static final Map<Class<?>, Class<?>> primitiveToWrapperMap;
29+
30+
static {
31+
@SuppressWarnings("IdentityHashMapUsage")
32+
Map<Class<?>, Class<?>> primitivesToWrappers = new IdentityHashMap<>(9);
33+
34+
primitivesToWrappers.put(boolean.class, Boolean.class);
35+
primitivesToWrappers.put(byte.class, Byte.class);
36+
primitivesToWrappers.put(char.class, Character.class);
37+
primitivesToWrappers.put(short.class, Short.class);
38+
primitivesToWrappers.put(int.class, Integer.class);
39+
primitivesToWrappers.put(long.class, Long.class);
40+
primitivesToWrappers.put(float.class, Float.class);
41+
primitivesToWrappers.put(double.class, Double.class);
42+
43+
primitiveToWrapperMap = Collections.unmodifiableMap(primitivesToWrappers);
44+
}
45+
46+
// https://github.com/junit-team/junit-framework/issues/5641
47+
static boolean isAssignableTo(Class<?> sourceType, Class<?> targetType) {
48+
Objects.requireNonNull(sourceType, "source type must not be null");
49+
Objects.requireNonNull(targetType, "target type must not be null");
50+
51+
if (sourceType.isPrimitive()) {
52+
throw new IllegalArgumentException("source type must not be a primitive type");
53+
}
54+
55+
if (targetType.isAssignableFrom(sourceType)) {
56+
return true;
57+
}
58+
59+
if (targetType.isPrimitive()) {
60+
return sourceType == primitiveToWrapperMap.get(targetType) || isWideningConversion(sourceType, targetType);
61+
}
62+
63+
return false;
64+
}
65+
66+
private static boolean isWideningConversion(Class<?> sourceType, Class<?> targetType) {
67+
if (!targetType.isPrimitive()) {
68+
throw new IllegalArgumentException("targetType must be primitive");
69+
}
70+
71+
boolean isPrimitive = sourceType.isPrimitive();
72+
boolean isWrapper = primitiveToWrapperMap.containsValue(sourceType);
73+
74+
// Neither a primitive nor a wrapper?
75+
if (!isPrimitive && !isWrapper) {
76+
return false;
77+
}
78+
79+
if (isPrimitive) {
80+
sourceType = primitiveToWrapperMap.get(sourceType);
81+
}
82+
83+
// @formatter:off
84+
if (sourceType == Byte.class) {
85+
return
86+
targetType == short.class ||
87+
targetType == int.class ||
88+
targetType == long.class ||
89+
targetType == float.class ||
90+
targetType == double.class;
91+
}
92+
93+
if (sourceType == Short.class || sourceType == Character.class) {
94+
return
95+
targetType == int.class ||
96+
targetType == long.class ||
97+
targetType == float.class ||
98+
targetType == double.class;
99+
}
100+
101+
if (sourceType == Integer.class) {
102+
return
103+
targetType == long.class ||
104+
targetType == float.class ||
105+
targetType == double.class;
106+
}
107+
108+
if (sourceType == Long.class) {
109+
return
110+
targetType == float.class ||
111+
targetType == double.class;
112+
}
113+
114+
if (sourceType == Float.class) {
115+
return
116+
targetType == double.class;
117+
}
118+
// @formatter:on
119+
120+
return false;
121+
}
122+
123+
static String[] parseFullyQualifiedMethodName(String fullyQualifiedMethodName) {
124+
if (fullyQualifiedMethodName.isEmpty()) {
125+
throw new IllegalArgumentException("fullyQualifiedMethodName must not be null or blank");
126+
}
127+
128+
int indexOfFirstHashtag = fullyQualifiedMethodName.indexOf('#');
129+
boolean validSyntax = (indexOfFirstHashtag > 0)
130+
&& (indexOfFirstHashtag < fullyQualifiedMethodName.length() - 1);
131+
132+
if (!validSyntax) {
133+
throw new IllegalArgumentException("[" + fullyQualifiedMethodName
134+
+ "] is not a valid fully qualified method name: "
135+
+ "it must start with a fully qualified class name followed by a '#' "
136+
+ "and then the method name, optionally followed by a parameter list enclosed in parentheses.");
137+
}
138+
139+
String className = fullyQualifiedMethodName.substring(0, indexOfFirstHashtag);
140+
String methodPart = fullyQualifiedMethodName.substring(indexOfFirstHashtag + 1);
141+
String methodName = methodPart;
142+
String methodParameters = "";
143+
144+
if (methodPart.endsWith("()")) {
145+
methodName = methodPart.substring(0, methodPart.length() - 2);
146+
}
147+
else if (methodPart.endsWith(")")) {
148+
int indexOfLastOpeningParenthesis = methodPart.lastIndexOf('(');
149+
if ((indexOfLastOpeningParenthesis > 0) && (indexOfLastOpeningParenthesis < methodPart.length() - 1)) {
150+
methodName = methodPart.substring(0, indexOfLastOpeningParenthesis);
151+
methodParameters = methodPart.substring(indexOfLastOpeningParenthesis + 1, methodPart.length() - 1);
152+
}
153+
}
154+
return new String[] { className, methodName, methodParameters };
155+
}
156+
157+
}

0 commit comments

Comments
 (0)