diff --git a/pom.xml b/pom.xml
index e9ccec5..b736f47 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
io.github.glytchingjunit-extensions
- 2.7.0-SNAPSHOT
+ 2.8.0-SNAPSHOTjarJUnit Extensions
@@ -54,7 +54,7 @@
UTF-81.2.0
- 5.2.0
+ 5.9.21.32.7.193.9.0
@@ -209,6 +209,11 @@
+
+ org.junit.jupiter
+ junit-jupiter-params
+ ${junit.jupiter.version}
+ org.hamcresthamcrest-library
@@ -221,6 +226,29 @@
${mockito.version}test
+
+ com.alibaba.fastjson2
+ fastjson2
+ 2.0.25
+
+
+ org.projectlombok
+ lombok
+ 1.18.30
+
+
+ org.jetbrains
+ annotations
+ 24.1.0
+ compile
+
+
+
+ org.jeasy
+ easy-random-core
+ 4.3.0
+
+
diff --git a/src/main/java/io/github/glytching/junit/extension/json/JsonFileArgumentsProvider.java b/src/main/java/io/github/glytching/junit/extension/json/JsonFileArgumentsProvider.java
new file mode 100644
index 0000000..66f35d9
--- /dev/null
+++ b/src/main/java/io/github/glytching/junit/extension/json/JsonFileArgumentsProvider.java
@@ -0,0 +1,288 @@
+package io.github.glytching.junit.extension.json;
+
+import static java.util.Arrays.copyOf;
+import static java.util.Arrays.stream;
+import static java.util.concurrent.CompletableFuture.*;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONReader;
+import java.io.*;
+import java.lang.reflect.*;
+import java.nio.charset.Charset;
+import java.util.Arrays;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BiFunction;
+import java.util.stream.Stream;
+
+import io.github.glytching.junit.extension.json.annotation.JsonFileSource;
+import io.github.glytching.junit.extension.util.RecursiveReferenceDetector;
+import io.github.glytching.junit.extension.util.ReflectionUtils;
+import io.github.glytching.junit.extension.util.TypeUtils;
+import lombok.SneakyThrows;
+import org.jeasy.random.EasyRandom;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.ArgumentsProvider;
+import org.junit.jupiter.params.support.AnnotationConsumer;
+import org.junit.platform.commons.util.Preconditions;
+
+/**
+ * Provides a way to supply arguments to parameterized tests in JUnit 5 by reading JSON files
+ * specified by the {@link JsonFileSource} annotation. This class implements the {@link ArgumentsProvider}
+ * interface, which is part of the JUnit Jupiter Params API, and the {@link AnnotationConsumer} interface
+ * to consume the {@link JsonFileSource} annotations.
+ *
+ *
The {@code JsonFileArgumentsProvider} is responsible for locating the JSON files specified in the
+ * annotation, reading their contents, and converting them into objects of the type expected by the
+ * test method parameters. It supports both simple and generic types, including handling of recursive
+ * type references by setting them to {@code null} to prevent infinite loops during JSON parsing.
+ *
+ *
Usage of this class requires the {@code @JsonFileSource} annotation to be present on the test
+ * method with one or more JSON file resources specified. The class will then read each file, deserialize
+ * the JSON content into the required parameter type, and provide it as arguments to the parameterized test.
+ *
+ *
+ *
+ * Detailed example usage can be seen in the {@code JsonFileArgumentsProviderTest}.
+ *
Note that this class relies on the {@link com.alibaba.fastjson2.JSON} library for JSON processing
+ * and uses the {@link org.jeasy.random.EasyRandom} library for generating random values for object
+ * instantiation when needed. It also makes use of {@link lombok.SneakyThrows} to bypass checked
+ * exceptions, which should be used cautiously as it may hide potentially recoverable errors.
+ *
+ *
This class is part of a suite of extensions that enhance JUnit 5's parameterized testing capabilities,
+ * allowing for more flexible and data-driven test cases.
+ *
+ * @author xiaohui
+ * @see ArgumentsProvider
+ * @see AnnotationConsumer
+ * @see JsonFileSource
+ * @see ParameterizedTest
+ */
+public class JsonFileArgumentsProvider
+ implements AnnotationConsumer, ArgumentsProvider {
+
+ public static final String ADDRESS_DASH = "/";
+ private final BiFunction, String, InputStream> inputStreamProvider;
+ private static final String RESOURCES_PATH_PREFIX = "src/test/resources";
+
+ private static final EasyRandom RANDOM = new EasyRandom();
+
+ private String[] resourceNames;
+
+ private Method requiredTestMethod;
+
+ private Class> requiredTestClass;
+
+ private Class> testMethodParameterClazz;
+
+ @SuppressWarnings("unused")
+ JsonFileArgumentsProvider() {
+ this(Class::getResourceAsStream);
+ }
+
+ JsonFileArgumentsProvider(BiFunction, String, InputStream> inputStreamProvider) {
+ this.inputStreamProvider = inputStreamProvider;
+ }
+
+ private Object valueOfType(InputStream inputStream) {
+ try (JSONReader reader = JSONReader.of(inputStream, Charset.defaultCharset())) {
+ return reader.read(testMethodParameterClazz);
+ }
+ }
+
+ @Override
+ public void accept(JsonFileSource jsonFileSource) {
+ resourceNames = jsonFileSource.resources();
+ }
+
+ private String[] getResourcePaths(String[] partResourceNames) {
+ final String[] resourcePaths = copyOf(partResourceNames, partResourceNames.length);
+ String packageName = requiredTestClass.getPackage().getName();
+ for (int i = 0; i < resourcePaths.length; i++) {
+ resourcePaths[i] =
+ packageName.replaceAll("\\.", ADDRESS_DASH) + ADDRESS_DASH + partResourceNames[i];
+ }
+ for (int i = 0; i < resourcePaths.length; i++) {
+ if (!partResourceNames[i].startsWith(ADDRESS_DASH)) {
+ resourcePaths[i] = ADDRESS_DASH + resourcePaths[i];
+ }
+ }
+ return resourcePaths;
+ }
+
+ /** Only support one genericParameterType */
+ @Override
+ public Stream extends Arguments> provideArguments(ExtensionContext context) {
+ requiredTestMethod = context.getRequiredTestMethod();
+ requiredTestClass = context.getRequiredTestClass();
+ testMethodParameterClazz = initTestMethodParameterClazz();
+ String[] resourcePaths = getResourcePaths(resourceNames);
+ return stream(resourcePaths)
+ .map(resource -> openInputStream(requiredTestClass, resource))
+ .map(this::valueOfType)
+ .map(Arguments::arguments);
+ }
+
+ /** Only support one genericParameterType */
+ @SneakyThrows
+ private Class> initTestMethodParameterClazz() {
+ Type genericParameterType = requiredTestMethod.getGenericParameterTypes()[0];
+ String testMethodParameterTypeName =
+ genericParameterType instanceof ParameterizedType
+ ? ((ParameterizedType) genericParameterType).getRawType().getTypeName()
+ : genericParameterType.getTypeName();
+ return Class.forName(testMethodParameterTypeName);
+ }
+
+ @SneakyThrows(Exception.class)
+ private InputStream openInputStream(Class> testClass, String resource) {
+ InputStream inputStream;
+ inputStream = inputStreamProvider.apply(testClass, resource);
+ if (inputStream == null) {
+ CompletableFuture resourceFuture = supplyAsync(() -> createTestResource(resource));
+ // avoid too long to end this process by io error
+ inputStream = inputStreamProvider.apply(testClass, resourceFuture.get(5, TimeUnit.SECONDS));
+ }
+ return Preconditions.notNull(
+ inputStream,
+ () ->
+ "*** Classpath resource does not exist: " + resource + ", and we have created it ***");
+ }
+
+ private String createTestResource(String resource) {
+ createDirectoryForResource(resource);
+ return createFileAndWriteTestResource(resource);
+ }
+
+ private String createFileAndWriteTestResource(String resource) {
+ try {
+ String moduleAbsoluteResource = RESOURCES_PATH_PREFIX + resource;
+ File file = createFile(moduleAbsoluteResource);
+ writeTestResource2File(moduleAbsoluteResource, file);
+ return resource;
+ } catch (IOException e) {
+ System.out.println("Error creating file: " + e.getMessage());
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static File createFile(String moduleAbsoluteResource) throws IOException {
+ File file = new File(moduleAbsoluteResource);
+ if (!file.createNewFile()) {
+ System.out.println("File already exists or could not be created: " + moduleAbsoluteResource);
+ }
+ return file;
+ }
+
+ private void writeTestResource2File(String moduleAbsoluteResource, File file) throws IOException {
+ try (BufferedWriter writer = new BufferedWriter(new FileWriter(moduleAbsoluteResource))) {
+ Type[] genericParameterTypes = requiredTestMethod.getGenericParameterTypes();
+ Type genericParameterType = genericParameterTypes[0];
+ Object parameterInstance = getParameterInstance(genericParameterType);
+ writer.write(JSON.toJSONString(parameterInstance));
+ writer.flush();
+ System.out.println(
+ moduleAbsoluteResource + (file.exists() ? "\ncreated successfully" : "on way..."));
+ }
+ }
+
+ @SneakyThrows
+ private Object getParameterInstance(Type genericParameterType) {
+ Object object;
+ if (genericParameterType instanceof ParameterizedType) {
+ object = newParameterTypeInstance((ParameterizedType) genericParameterType);
+ } else {
+ object = newConcreteTypeInstance(Class.forName(genericParameterType.getTypeName()));
+ }
+ return object;
+ }
+
+ private static Object newConcreteTypeInstance(Class> testMethodParameterClazz1) {
+ Object object = RANDOM.nextObject(testMethodParameterClazz1);
+ setNullIfRecursive(object);
+ return object;
+ }
+
+ public static void setNullIfRecursive(Object object) {
+ if (object == null || TypeUtils.isBuiltInType(object.getClass())) {
+ return;
+ }
+ boolean hasRecursiveReference = RecursiveReferenceDetector.hasRecursiveReference(object);
+ if (hasRecursiveReference) {
+ Stream.of(object.getClass().getDeclaredFields())
+ .forEach(field -> setNullIfRecursive(object, field));
+ }
+ }
+
+ private static void setNullIfRecursive(Object object, Field it) {
+ Object filedObj = ReflectionUtils.getField(object, it.getName());
+ if (RecursiveReferenceDetector.hasRecursiveReference(filedObj)) {
+ ReflectionUtils.setField(object, it, null);
+ } else {
+ setNullIfRecursive(filedObj);
+ }
+ }
+
+ /**
+ * Here it is assumed that {@code testMethodParameterClass} must have corresponding constructor
+ * arguments.
+ *
+ *
Because for parameterized types of classes (such as generic classes), we cannot actually
+ * find that field to assign a value to it, so as a compromise, it is agreed to use the
+ * constructor instead.
+ */
+ @SneakyThrows
+ @NotNull
+ private Object newParameterTypeInstance(ParameterizedType genericParameterType) {
+ Class> methodParameterClazz = Class.forName(genericParameterType.getRawType().getTypeName());
+ Type[] genericParameterTypes = genericParameterType.getActualTypeArguments();
+ Object newInstance;
+ try {
+ newInstance = newInstanceBySuitableConstructor(methodParameterClazz, genericParameterTypes);
+ } catch (UnsupportedOperationException exception) {
+ System.out.println(exception.toString());
+ newInstance = RANDOM.nextObject(methodParameterClazz);
+ }
+ return newInstance;
+ }
+
+ @NotNull
+ private Object newInstanceBySuitableConstructor(
+ Class> methodParameterClazz, Type[] genericParameterTypes)
+ throws InstantiationException, IllegalAccessException, InvocationTargetException {
+ Constructor> suitableConstructor =
+ findSuitableConstructor(methodParameterClazz, genericParameterTypes);
+ Object[] args = stream(genericParameterTypes).map(this::getParameterInstance).toArray();
+ return suitableConstructor.newInstance(args);
+ }
+
+ @SneakyThrows
+ public static Constructor> findSuitableConstructor(Class> clazz, Type[] typeArguments) {
+ return stream(clazz.getConstructors())
+ .filter(it -> it.getParameterCount() == typeArguments.length)
+ .findFirst()
+ .orElseThrow(
+ () ->
+ new UnsupportedOperationException(
+ "Lack of the first matched constructors for type argument: "
+ + Arrays.toString(typeArguments)));
+ }
+
+ private static void createDirectoryForResource(String resource) {
+ String fileDirPath = RESOURCES_PATH_PREFIX + resource.substring(0, resource.lastIndexOf("/"));
+ if (!new File(fileDirPath).mkdirs()) {
+ System.out.println("Directory already exists or could not be created: " + fileDirPath);
+ }
+ }
+}
diff --git a/src/main/java/io/github/glytching/junit/extension/json/TestCase.java b/src/main/java/io/github/glytching/junit/extension/json/TestCase.java
new file mode 100644
index 0000000..152ea88
--- /dev/null
+++ b/src/main/java/io/github/glytching/junit/extension/json/TestCase.java
@@ -0,0 +1,78 @@
+package io.github.glytching.junit.extension.json;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.TypeReference;
+import io.github.glytching.junit.extension.util.TypeUtils;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+/**
+ * @author xiaohui
+ */
+@RequiredArgsConstructor
+@Getter
+public class TestCase {
+
+ /**
+ * input args of function
+ */
+ private final IN input;
+
+ /**
+ * output result of function*
+ */
+ private final OUT output;
+
+ /**
+ *
+ * @param inType one level type
+ * @return {@link IN}
+ */
+ public IN getInput(Class inType) {
+ if(TypeUtils.isJsonType(input)){
+ return JSON.parseObject(JSON.toJSONString(input),inType) ;
+ }else {
+ return input ;
+ }
+ }
+
+ /**
+ *
+ * @param typeReference any type
+ * @return {@link IN}
+ */
+ public IN getInput(TypeReference typeReference) {
+ if(TypeUtils.isJsonType(input)){
+ return JSON.parseObject(JSON.toJSONString(input),typeReference) ;
+ }else {
+ return input ;
+ }
+ }
+
+ /**
+ *
+ * @param outType one level type
+ * @return {@link OUT}
+ */
+ public OUT getOutput(Class outType) {
+ if(TypeUtils.isJsonType(output)){
+ return JSON.parseObject(JSON.toJSONString(output),outType) ;
+ }else {
+ return output ;
+ }
+ }
+
+ /**
+ *
+ * @param typeReference any type
+ * @return {@link OUT}
+ */
+ public OUT getOutput(TypeReference typeReference) {
+ if(TypeUtils.isJsonType(output)){
+ return JSON.parseObject(JSON.toJSONString(output),typeReference) ;
+ }else {
+ return output;
+ }
+ }
+
+}
diff --git a/src/main/java/io/github/glytching/junit/extension/json/annotation/JsonFileSource.java b/src/main/java/io/github/glytching/junit/extension/json/annotation/JsonFileSource.java
new file mode 100644
index 0000000..764ab9e
--- /dev/null
+++ b/src/main/java/io/github/glytching/junit/extension/json/annotation/JsonFileSource.java
@@ -0,0 +1,24 @@
+package io.github.glytching.junit.extension.json.annotation;
+
+import java.lang.annotation.*;
+
+import io.github.glytching.junit.extension.json.JsonFileArgumentsProvider;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ArgumentsSource;
+
+/**
+ * @author xiaohui
+ */
+@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@ArgumentsSource(JsonFileArgumentsProvider.class)
+@ParameterizedTest
+public @interface JsonFileSource {
+
+ /**
+ * The JsonFile Resource Path in the test/resources
+ */
+ String[] resources();
+
+}
\ No newline at end of file
diff --git a/src/main/java/io/github/glytching/junit/extension/random/RandomBeansExtension.java b/src/main/java/io/github/glytching/junit/extension/random/RandomBeansExtension.java
index 2e18b1c..56c118d 100644
--- a/src/main/java/io/github/glytching/junit/extension/random/RandomBeansExtension.java
+++ b/src/main/java/io/github/glytching/junit/extension/random/RandomBeansExtension.java
@@ -22,6 +22,7 @@
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
+import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.List;
import java.util.Set;
diff --git a/src/main/java/io/github/glytching/junit/extension/util/RecursiveReferenceDetector.java b/src/main/java/io/github/glytching/junit/extension/util/RecursiveReferenceDetector.java
new file mode 100644
index 0000000..6b48af2
--- /dev/null
+++ b/src/main/java/io/github/glytching/junit/extension/util/RecursiveReferenceDetector.java
@@ -0,0 +1,101 @@
+package io.github.glytching.junit.extension.util;
+
+import java.lang.reflect.Array;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.util.*;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
+
+/**
+ * @author mac
+ */
+public class RecursiveReferenceDetector {
+
+ /**
+ * Checks for recursive references in an object using a breadth-first search approach.
+ *
+ * @param obj The object to check.
+ * @return true if a recursive reference is found, false otherwise.
+ */
+ public static boolean hasRecursiveReference(Object obj) {
+ IdentityHashMap