Skip to content

Commit 1838d7d

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

7 files changed

Lines changed: 469 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: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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+
Method conversionMethod = annotation.value().isEmpty()
59+
? findCandidateMethod(source.getClass(), targetType, declaringClass)
60+
: findMethodByName(annotation.value(), source.getClass(), targetType, declaringClass);
61+
return ReflectionSupport.invokeMethod(conversionMethod, null, source);
62+
}
63+
64+
private static Method findCandidateMethod(Class<?> sourceType, Class<?> targetType, Class<?> declaringClass) {
65+
Predicate<Method> filter = IS_STATIC.and(accepts(sourceType)).and(produces(targetType));
66+
List<Method> methods = ReflectionSupport.findMethods(declaringClass, filter, HierarchyTraversalMode.BOTTOM_UP);
67+
68+
if (methods.isEmpty()) {
69+
throw new ArgumentConversionException(
70+
String.format("No conversion method found compatible with source type %s and target type %s",
71+
sourceType.getName(), targetType.getName()));
72+
}
73+
74+
if (methods.size() > 1) {
75+
String signatures = methods.stream().map(method -> "\t\t" + method).collect(Collectors.joining("\n"));
76+
77+
throw new ArgumentConversionException(
78+
String.format("Too many conversion methods compatible with source type %s and target type %s:%n%s",
79+
sourceType.getName(), targetType.getName(), signatures));
80+
}
81+
82+
return methods.get(0);
83+
}
84+
85+
private static Method findMethodByName(String name, Class<?> sourceType, Class<?> targetType,
86+
Class<?> declaringClass) {
87+
Predicate<Method> filter = IS_STATIC.and(hasName(name)).and(accepts(sourceType)).and(produces(targetType));
88+
List<Method> methods = ReflectionSupport.findMethods(declaringClass, filter, HierarchyTraversalMode.BOTTOM_UP);
89+
90+
if (methods.isEmpty()) {
91+
throw new ArgumentConversionException(
92+
String.format("No conversion method found with the following signature: static %s %s(%s)",
93+
targetType.getName(), name, sourceType.getName()));
94+
}
95+
96+
return methods.get(0);
97+
}
98+
99+
private static Predicate<Method> accepts(Class<?> sourceType) {
100+
return method -> {
101+
Class<?>[] parameterTypes = method.getParameterTypes();
102+
return parameterTypes.length == 1 && ReflectionUtils.isAssignableTo(sourceType, parameterTypes[0]);
103+
};
104+
}
105+
106+
private static Predicate<Method> produces(Class<?> targetType) {
107+
return method -> ReflectionUtils.isAssignableTo(method.getReturnType(), targetType);
108+
}
109+
110+
private static Predicate<Method> hasName(String name) {
111+
return method -> method.getName().equals(name);
112+
}
113+
114+
}
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: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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+
}

0 commit comments

Comments
 (0)