From 03102e68eb25c4d52ed4c5fd674518a95614559f Mon Sep 17 00:00:00 2001 From: istarwyh Date: Sun, 31 Mar 2024 18:14:23 +0800 Subject: [PATCH 1/2] Add: JsonFileArgumentsProvider --- pom.xml | 32 ++- .../json/JsonFileArgumentsProvider.java | 251 ++++++++++++++++++ .../junit/extension/json/TestCase.java | 78 ++++++ .../json/annotation/JsonFileSource.java | 24 ++ .../random/RandomBeansExtension.java | 1 + .../util/RecursiveReferenceDetector.java | 101 +++++++ .../junit/extension/util/ReflectionUtils.java | 203 ++++++++++++++ .../junit/extension/util/TypeUtils.java | 35 +++ .../junit/extension/util/UnsafeUtils.java | 29 ++ .../json/JsonFileArgumentsProviderTest.java | 83 ++++++ .../util/RecursiveReferenceDetectorTest.java | 43 +++ .../extension/util/ReflectionUtilsTest.java | 78 ++++++ .../junit/extension/util/TypeUtilsTest.java | 5 + .../extension/json/RecursionClass_input.json | 1 + .../extension/json/absent_test_case.json | 1 + .../junit/extension/json/list_testCase.json | 1 + .../junit/extension/json/map_testCase.json | 1 + .../junit/extension/json/people_input.json | 1 + .../extension/json/string_test_case.json | 4 + 19 files changed, 970 insertions(+), 2 deletions(-) create mode 100644 src/main/java/io/github/glytching/junit/extension/json/JsonFileArgumentsProvider.java create mode 100644 src/main/java/io/github/glytching/junit/extension/json/TestCase.java create mode 100644 src/main/java/io/github/glytching/junit/extension/json/annotation/JsonFileSource.java create mode 100644 src/main/java/io/github/glytching/junit/extension/util/RecursiveReferenceDetector.java create mode 100644 src/main/java/io/github/glytching/junit/extension/util/ReflectionUtils.java create mode 100644 src/main/java/io/github/glytching/junit/extension/util/TypeUtils.java create mode 100644 src/main/java/io/github/glytching/junit/extension/util/UnsafeUtils.java create mode 100644 src/test/java/io/github/glytching/junit/extension/json/JsonFileArgumentsProviderTest.java create mode 100644 src/test/java/io/github/glytching/junit/extension/util/RecursiveReferenceDetectorTest.java create mode 100644 src/test/java/io/github/glytching/junit/extension/util/ReflectionUtilsTest.java create mode 100644 src/test/java/io/github/glytching/junit/extension/util/TypeUtilsTest.java create mode 100644 src/test/resources/io/github/glytching/junit/extension/json/RecursionClass_input.json create mode 100644 src/test/resources/io/github/glytching/junit/extension/json/absent_test_case.json create mode 100644 src/test/resources/io/github/glytching/junit/extension/json/list_testCase.json create mode 100644 src/test/resources/io/github/glytching/junit/extension/json/map_testCase.json create mode 100644 src/test/resources/io/github/glytching/junit/extension/json/people_input.json create mode 100644 src/test/resources/io/github/glytching/junit/extension/json/string_test_case.json diff --git a/pom.xml b/pom.xml index e9ccec5..b736f47 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ io.github.glytching junit-extensions - 2.7.0-SNAPSHOT + 2.8.0-SNAPSHOT jar JUnit Extensions @@ -54,7 +54,7 @@ UTF-8 1.2.0 - 5.2.0 + 5.9.2 1.3 2.7.19 3.9.0 @@ -209,6 +209,11 @@ + + org.junit.jupiter + junit-jupiter-params + ${junit.jupiter.version} + org.hamcrest hamcrest-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..b89a928 --- /dev/null +++ b/src/main/java/io/github/glytching/junit/extension/json/JsonFileArgumentsProvider.java @@ -0,0 +1,251 @@ +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.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.support.AnnotationConsumer; +import org.junit.platform.commons.util.Preconditions; + +/** + * @author xiaohui + */ +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 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; + } + + /** 将对象中所有有递归引用的字段设置为null */ + 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 visited = new IdentityHashMap<>(); + return Stream.of(obj).anyMatch(newObj -> checkRecursiveAndAdd(visited, newObj)); + } + + private static boolean checkRecursiveAndAdd(IdentityHashMap visited, Object obj) { + return Optional.ofNullable(obj) + .map(Object::getClass) + .filter(it -> !TypeUtils.isBuiltInType(it)) + .map( + it -> + canIterate(it) + ? Arrays.stream(toInstanceArray(obj)) + : Stream.of(it.getDeclaredFields()).map(field -> getFieldValue(obj, field))) + .map( + it -> + it.anyMatch( + childObj -> { + if (isNotRecursion(visited, childObj)) { + return checkRecursiveAndAdd(visited, childObj); + } + return true; + })) + .orElse(false); + } + + private static boolean canIterate(Class it) { + return it.isArray() || Collection.class.isAssignableFrom(it); + } + + private static Object getFieldValue(Object obj, Field cur) { + Predicate fieldPredicate = getFieldTypePredicate(TypeUtils::isBuiltInType) + .and(combineModifierPredicates( + Modifier::isFinal, + Modifier::isAbstract, + Modifier::isNative, + Modifier::isTransient, + Modifier::isInterface + )); + return Optional.of(cur) + .map(field -> ReflectionUtils.getFieldWithFilter(obj, field.getName(), fieldPredicate)) + .orElse(null); + } + + private static Predicate getFieldTypePredicate(Predicate> classPredicate) { + return field -> !classPredicate.test(field.getType()); + } + + @SafeVarargs + private static Predicate combineModifierPredicates(Predicate... predicates) { + Predicate combinedPredicate = field -> true; + for (Predicate predicate : predicates) { + combinedPredicate = combinedPredicate.and(field -> !predicate.test(field.getModifiers())); + } + return combinedPredicate; + } + + /** + * The `putIfAbsent` method returns the value previously associated with the key, or `null` if + * there was no previous association. If it's the first access, the condition is true,which means + * no recursive reference is found; if it's not the first access, the condition is false, + * indicating that a recursive reference has been detected. + */ + private static boolean isNotRecursion(IdentityHashMap visited, Object childObj) { + if(childObj == null){ + return true; + } + return visited.putIfAbsent(childObj, Boolean.TRUE) == null; + } + + private static Object[] toInstanceArray(Object array) { + array = array.getClass().isArray() ? array : ((Collection) array).toArray(); + int length = Array.getLength(array); + List list = new ArrayList<>(length); + for (int i = 0; i < length; i++) { + list.add(Array.get(array, i)); + } + return list.toArray(); + } +} + diff --git a/src/main/java/io/github/glytching/junit/extension/util/ReflectionUtils.java b/src/main/java/io/github/glytching/junit/extension/util/ReflectionUtils.java new file mode 100644 index 0000000..13341a9 --- /dev/null +++ b/src/main/java/io/github/glytching/junit/extension/util/ReflectionUtils.java @@ -0,0 +1,203 @@ +package io.github.glytching.junit.extension.util; + +import static io.github.glytching.junit.extension.util.UnsafeUtils.unsafe; + +import java.lang.reflect.*; +import java.security.AccessController; +import java.security.PrivilegedAction; +import java.util.*; +import java.util.function.Function; +import java.util.function.Predicate; +import lombok.*; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import sun.misc.Unsafe; + +/** + * This class is changed from WhileBox in PowerMock. The class can set instance field,including + * final field, not null or static field + * + * @author xiaohui + */ +public class ReflectionUtils { + + /** + * If you want to set static field, you should call {@link ReflectionUtils#setField(Object, Field, + * Object)} + * + * @param modifiedObj modifiedObj + * @param fieldName including final field, not null or static field + * @param value value + */ + @SneakyThrows(NoSuchFieldException.class) + public static void setField(Object modifiedObj, String fieldName, Object value) { + Predicate fieldPredicate = field -> hasFieldProperModifier(modifiedObj, field); + Field foundField = + findFieldInHierarchy(modifiedObj, fieldName, fieldPredicate) + .orElseThrow( + () -> + new NoSuchFieldException( + String.format( + "No %s field named \"%s\" could be found in the \"%s\" class hierarchy", + isClass(modifiedObj) ? "static" : "instance", + fieldName, + getClassOf(modifiedObj).getName()))); + setField(modifiedObj, foundField, value); + } + + @Nullable + public static T getField(Object object, String fieldName) { + return getFieldWithFilter(object, fieldName, anyField -> true); + } + + @Nullable + @SneakyThrows({IllegalAccessException.class}) + public static T getFieldWithFilter( + Object object, String fieldName, Predicate fieldPredicate) { + Field foundField = findFieldInHierarchy(object, fieldName, fieldPredicate).orElse(null); + if (foundField == null) { + return null; + } + return (T) foundField.get(object); + } + + public static Optional findFieldInHierarchy( + Object modifiedObj, String fieldName, @NotNull Predicate fieldPredicate) { + if (modifiedObj == null) { + throw new IllegalArgumentException("The modifiedObj containing the field cannot be null!"); + } + Class startClass = getClassOf(modifiedObj); + + Optional optionalField = + findFieldByUniqueName(fieldName, startClass).filter(fieldPredicate); + optionalField.ifPresent(it -> it.setAccessible(true)); + return optionalField; + } + + public static Optional findFieldByUniqueName(String fieldName, Class startClass) { + FieldSearchCriteria criteria = + new FieldSearchCriteria(startClass, field -> field.getName().equals(fieldName), fieldName); + return findField(criteria); + } + + private static Optional findField(FieldSearchCriteria criteria) { + Field foundField = null; + Class currentClass = criteria.getStartClass(); + while (currentClass != null) { + Field[] declaredFields = currentClass.getDeclaredFields(); + for (val field : declaredFields) { + if (criteria.getMatcher().apply(field)) { + if (foundField != null) { + throw new IllegalStateException( + "Two or more fields matching " + criteria.getErrorMessage() + "."); + } + foundField = field; + } + } + if (foundField != null) { + break; + } + currentClass = currentClass.getSuperclass(); + } + return Optional.ofNullable(foundField); + } + + @Getter + @RequiredArgsConstructor + public static class FieldSearchCriteria { + private final Class startClass; + + private final Function matcher; + + private final String errorMessage; + } + + private static boolean hasFieldProperModifier(Object object, Field field) { + if (isClass(object)) { + return Modifier.isStatic(field.getModifiers()); + } else { + return !Modifier.isStatic(field.getModifiers()); + } + } + + private static Class getClassOf(@NotNull Object object) { + Class type; + if (isClass(object)) { + type = (Class) object; + } else { + type = object.getClass(); + } + return type; + } + + private static boolean isClass(Object object) { + return object instanceof Class; + } + + public static void setField(Object object, Field foundField, Object value) { + boolean isStatic = isModifier(foundField, Modifier.STATIC); + Unsafe unsafe = unsafe(); + if (isStatic) { + setStaticFieldUsingUnsafe(foundField, value); + } else { + setFieldUsingUnsafe(object, foundField, unsafe.objectFieldOffset(foundField), value); + } + } + + private static void setStaticFieldUsingUnsafe(Field field, Object value) { + Object base = unsafe().staticFieldBase(field); + long offset = unsafe().staticFieldOffset(field); + setFieldUsingUnsafe(base, field, offset, value); + } + + /** + * judge whether modifier the field belongs to + * + * @param field field + * @param modifier {@link Modifier#STATIC }.etc + * @return if modifier the field belongs to + */ + private static boolean isModifier(Field field, int modifier) { + return (field.getModifiers() & modifier) == modifier; + } + + @SneakyThrows + @SuppressWarnings("all") + private static void setFieldUsingUnsafe(Object base, Field field, long offset, Object newValue) { + field.setAccessible(true); + boolean isFinal = isModifier(field, Modifier.FINAL); + if (isFinal) { + AccessController.doPrivileged( + (PrivilegedAction) + () -> { + setFieldUsingUnsafe(base, field.getType(), offset, newValue); + return null; + }); + } else { + field.set(base, newValue); + } + } + + private static void setFieldUsingUnsafe( + Object base, Class type, long offset, Object newValue) { + if (type == Integer.TYPE) { + unsafe().putInt(base, offset, (Integer) newValue); + } else if (type == Short.TYPE) { + unsafe().putShort(base, offset, (Short) newValue); + } else if (type == Long.TYPE) { + unsafe().putLong(base, offset, (Long) newValue); + } else if (type == Byte.TYPE) { + unsafe().putByte(base, offset, (Byte) newValue); + } else if (type == Boolean.TYPE) { + unsafe().putBoolean(base, offset, (Boolean) newValue); + } else if (type == Float.TYPE) { + unsafe().putFloat(base, offset, (Float) newValue); + } else if (type == Double.TYPE) { + unsafe().putDouble(base, offset, (Double) newValue); + } else if (type == Character.TYPE) { + unsafe().putChar(base, offset, (Character) newValue); + } else { + unsafe().putObject(base, offset, newValue); + } + } +} diff --git a/src/main/java/io/github/glytching/junit/extension/util/TypeUtils.java b/src/main/java/io/github/glytching/junit/extension/util/TypeUtils.java new file mode 100644 index 0000000..9a0fb78 --- /dev/null +++ b/src/main/java/io/github/glytching/junit/extension/util/TypeUtils.java @@ -0,0 +1,35 @@ +package io.github.glytching.junit.extension.util; + + +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * @author mac + */ +public class TypeUtils { + + private static final Set> BUILT_IN_TYPES = new HashSet<>( + Arrays.asList( + Boolean.class, Byte.class, Character.class, Short.class, + Integer.class, Long.class, Float.class, Double.class, + boolean.class, byte.class, char.class, short.class, + int.class, long.class, float.class, double.class + ) + ); + + public static boolean isBuiltInType(Class clazz) { + if (clazz == null) { + return false; + } + return BUILT_IN_TYPES.contains(clazz); + } + + public static boolean isJsonType(T o){ + return o instanceof JSONObject || o instanceof JSONArray; + } +} diff --git a/src/main/java/io/github/glytching/junit/extension/util/UnsafeUtils.java b/src/main/java/io/github/glytching/junit/extension/util/UnsafeUtils.java new file mode 100644 index 0000000..e6c4e8c --- /dev/null +++ b/src/main/java/io/github/glytching/junit/extension/util/UnsafeUtils.java @@ -0,0 +1,29 @@ +package io.github.glytching.junit.extension.util; + +import sun.misc.Unsafe; + +import java.lang.reflect.Field; + +/** + * @author mac + */ +public class UnsafeUtils { + + public static Unsafe unsafe(){ + return Singleton.UNSAFE; + } + + private static class Singleton{ + private static final Unsafe UNSAFE; + static { + Field unsafeFiled; + try { + unsafeFiled = Unsafe.class.getDeclaredField("theUnsafe"); + unsafeFiled.setAccessible(true); + UNSAFE = (Unsafe)unsafeFiled.get(Unsafe.class); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/src/test/java/io/github/glytching/junit/extension/json/JsonFileArgumentsProviderTest.java b/src/test/java/io/github/glytching/junit/extension/json/JsonFileArgumentsProviderTest.java new file mode 100644 index 0000000..3f6043b --- /dev/null +++ b/src/test/java/io/github/glytching/junit/extension/json/JsonFileArgumentsProviderTest.java @@ -0,0 +1,83 @@ +package io.github.glytching.junit.extension.json; + +import static org.junit.jupiter.api.Assertions.*; + +import com.alibaba.fastjson2.TypeReference; +import io.github.glytching.junit.extension.json.annotation.JsonFileSource; +import java.util.List; +import java.util.Map; +import lombok.Data; +import org.jeasy.random.EasyRandom; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +public class JsonFileArgumentsProviderTest { + + @ParameterizedTest + @CsvSource(value = {"1,2", "3,4"}) + void should_show_how_to_parse_multi_args_with_csv(Integer in, Integer out) { + assertEquals(out, in + 1); + } + + @JsonFileSource(resources = {"string_test_case.json"}) + void should_generate_test_case_json_lack_of_it(TestCase testCase) { + assertEquals("eOMtThyhVNLWUZNRcBaQKxI", testCase.getInput()); + assertEquals("yedUsFwdkelQbxeTeQOvaScfqIOOmaa", testCase.getOutput()); + } + + @JsonFileSource(resources = {"string_test_case.json"}) + void should_generate_test_case_json_given_ownClass(TestCase testCase) { + assertEquals("eOMtThyhVNLWUZNRcBaQKxI", testCase.getInput()); + assertEquals("yedUsFwdkelQbxeTeQOvaScfqIOOmaa", testCase.getOutput()); + } + + @JsonFileSource(resources = {"list_testCase.json", "list_testCase.json"}) + void should_parse_multi_List_with_Integer_type_test_case( + TestCase, List> testCase) { + assertEquals(0, testCase.getInput().size()); + assertEquals(0, testCase.getOutput(new TypeReference>() {}).size()); + } + + @JsonFileSource(resources = {"map_testCase.json"}) + void should_parse_Map_type_test_case( + TestCase, Map> testCase) { + assertNull(testCase.getInput().get("key1")); + assertNull(testCase.getOutput().get("key2")); + } + + @JsonFileSource(resources = {"people_input.json"}) + void should_parse_People_input(People people) { + assertEquals("lele", people.name); + } + + @JsonFileSource(resources = {"RecursionClass_input.json"}) + void should_parse_recursionClass_input(RecursionClass recursionClass) { + assertNotNull(recursionClass); + assertNull(recursionClass.getRecursionClasses()); + assertNotNull(recursionClass.getPeople()); + } + + @Test + void setNullIfRecursive() { + RecursionClass recursionClass = new EasyRandom().nextObject(RecursionClass.class); + JsonFileArgumentsProvider.setNullIfRecursive(recursionClass); + assertNull(recursionClass.getRecursionClass()); + assertNull(recursionClass.getRecursionClasses()); + assertNotNull(recursionClass.getPeople()); + } + + @Data + public static class RecursionClass { + private People people; + private RecursionClass recursionClass; + private List recursionClasses; + } + + public static class People { + public final String id = "02"; + public final String name = "lele"; + } + + +} \ No newline at end of file diff --git a/src/test/java/io/github/glytching/junit/extension/util/RecursiveReferenceDetectorTest.java b/src/test/java/io/github/glytching/junit/extension/util/RecursiveReferenceDetectorTest.java new file mode 100644 index 0000000..e043d57 --- /dev/null +++ b/src/test/java/io/github/glytching/junit/extension/util/RecursiveReferenceDetectorTest.java @@ -0,0 +1,43 @@ +package io.github.glytching.junit.extension.util; + +import static io.github.glytching.junit.extension.json.JsonFileArgumentsProviderTest.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import org.jeasy.random.EasyRandom; +import org.junit.jupiter.api.Test; + +public class RecursiveReferenceDetectorTest { + + private final RecursionClass recursionClass = new EasyRandom().nextObject(RecursionClass.class); + + @Test + public void should_judge_recursion() { + boolean res = RecursiveReferenceDetector.hasRecursiveReference(recursionClass); + assertTrue(res); + } + + @Test + public void should_judge_recursion_list() { + List recursionClasses = new ArrayList<>(); + recursionClasses.add(recursionClass); + boolean res = RecursiveReferenceDetector.hasRecursiveReference(recursionClasses); + assertTrue(res); + } + + @Test + public void should_not_judge_recursion() { + Object object = new EasyRandom().nextObject(Object.class); + boolean res = RecursiveReferenceDetector.hasRecursiveReference(object); + assertFalse(res); + } + + @Test + public void should_not_judge_recursion_2() { + Object object = new EasyRandom().nextObject(People.class); + boolean res = RecursiveReferenceDetector.hasRecursiveReference(object); + assertFalse(res); + } +} diff --git a/src/test/java/io/github/glytching/junit/extension/util/ReflectionUtilsTest.java b/src/test/java/io/github/glytching/junit/extension/util/ReflectionUtilsTest.java new file mode 100644 index 0000000..4c364c2 --- /dev/null +++ b/src/test/java/io/github/glytching/junit/extension/util/ReflectionUtilsTest.java @@ -0,0 +1,78 @@ +package io.github.glytching.junit.extension.util; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class ReflectionUtilsTest { + + private static final String wuwei = "wuwei"; + private static final String died = "died"; + private WhoIAm whoIAm; + private WhereIGo whereIGo; + + @BeforeEach + void setUp() { + whoIAm = new WhoIAm(); + whereIGo = new WhereIGo(); + } + + @Test + void should_throw_exception_if_setting_static_field() { + String value = "island"; + assertThrows( + NoSuchFieldException.class, () -> ReflectionUtils.setField(whoIAm, "country", value)); + } + + @ParameterizedTest + @CsvSource(value = {"name,me", "heart,will always go on"}) + void should_set_final_field_and_get_it(String fieldName, String value) { + ReflectionUtils.setField(whoIAm, fieldName, value); + String heart = ReflectionUtils.getField(whoIAm, fieldName); + assertEquals(value, heart); + } + + @Test + void should_set_parent_final_field_and_get_it() { + String value = "will always go on"; + String fieldName = "heart"; + ReflectionUtils.setField(whereIGo, fieldName, value); + String heart = ReflectionUtils.getField(whereIGo, fieldName); + assertEquals(value, heart); + } + + @Test + void should_get_static_field_value() { + String country = ReflectionUtils.getField(whereIGo, "country"); + assertEquals(wuwei, country); + } + + @Test + void should_not_get_non_exist_field_value_with_null() { + assertDoesNotThrow(() -> ReflectionUtils.getField(whereIGo, "died")); + } + + @Test + void should_set_final_field_value() { + assertDoesNotThrow(() -> ReflectionUtils.setField(whereIGo, "heart", null)); + } + + public static class WhoIAm { + private final String name = "halley"; + + private static final String country = wuwei; + + private final String heart = died; + + private final TestClassEnum type = TestClassEnum.WHO_AM_I; + } + + public static class WhereIGo extends WhoIAm {} + + public enum TestClassEnum { + WHO_AM_I; + } +} diff --git a/src/test/java/io/github/glytching/junit/extension/util/TypeUtilsTest.java b/src/test/java/io/github/glytching/junit/extension/util/TypeUtilsTest.java new file mode 100644 index 0000000..5c77dd9 --- /dev/null +++ b/src/test/java/io/github/glytching/junit/extension/util/TypeUtilsTest.java @@ -0,0 +1,5 @@ +package io.github.glytching.junit.extension.util; + +import static org.junit.jupiter.api.Assertions.*; + +class TypeUtilsTest {} diff --git a/src/test/resources/io/github/glytching/junit/extension/json/RecursionClass_input.json b/src/test/resources/io/github/glytching/junit/extension/json/RecursionClass_input.json new file mode 100644 index 0000000..0703ad4 --- /dev/null +++ b/src/test/resources/io/github/glytching/junit/extension/json/RecursionClass_input.json @@ -0,0 +1 @@ +{"people":{"id":"02","name":"lele"}} \ No newline at end of file diff --git a/src/test/resources/io/github/glytching/junit/extension/json/absent_test_case.json b/src/test/resources/io/github/glytching/junit/extension/json/absent_test_case.json new file mode 100644 index 0000000..0f513d4 --- /dev/null +++ b/src/test/resources/io/github/glytching/junit/extension/json/absent_test_case.json @@ -0,0 +1 @@ +{"input":"eOMtThyhVNLWUZNRcBaQKxI","output":"yedUsFwdkelQbxeTeQOvaScfqIOOmaa"} \ No newline at end of file diff --git a/src/test/resources/io/github/glytching/junit/extension/json/list_testCase.json b/src/test/resources/io/github/glytching/junit/extension/json/list_testCase.json new file mode 100644 index 0000000..269e101 --- /dev/null +++ b/src/test/resources/io/github/glytching/junit/extension/json/list_testCase.json @@ -0,0 +1 @@ +{"input":[],"output":[]} \ No newline at end of file diff --git a/src/test/resources/io/github/glytching/junit/extension/json/map_testCase.json b/src/test/resources/io/github/glytching/junit/extension/json/map_testCase.json new file mode 100644 index 0000000..447a1ab --- /dev/null +++ b/src/test/resources/io/github/glytching/junit/extension/json/map_testCase.json @@ -0,0 +1 @@ +{"input":{},"output":{}} \ No newline at end of file diff --git a/src/test/resources/io/github/glytching/junit/extension/json/people_input.json b/src/test/resources/io/github/glytching/junit/extension/json/people_input.json new file mode 100644 index 0000000..ddeaaec --- /dev/null +++ b/src/test/resources/io/github/glytching/junit/extension/json/people_input.json @@ -0,0 +1 @@ +{"id":"02","name":"lele"} \ No newline at end of file diff --git a/src/test/resources/io/github/glytching/junit/extension/json/string_test_case.json b/src/test/resources/io/github/glytching/junit/extension/json/string_test_case.json new file mode 100644 index 0000000..2529ce2 --- /dev/null +++ b/src/test/resources/io/github/glytching/junit/extension/json/string_test_case.json @@ -0,0 +1,4 @@ +{ + "input": "eOMtThyhVNLWUZNRcBaQKxI", + "output": "yedUsFwdkelQbxeTeQOvaScfqIOOmaa" +} \ No newline at end of file From 190c43d349cbce8b0826cf528ad5908b7dcbcf89 Mon Sep 17 00:00:00 2001 From: istarwyh Date: Sun, 31 Mar 2024 18:44:35 +0800 Subject: [PATCH 2/2] Add: JsonFileArgumentsProvider Javadoc --- .../json/JsonFileArgumentsProvider.java | 41 ++++++++++++++++++- .../json/JsonFileArgumentsProviderTest.java | 10 +---- 2 files changed, 40 insertions(+), 11 deletions(-) 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 index b89a928..66f35d9 100644 --- a/src/main/java/io/github/glytching/junit/extension/json/JsonFileArgumentsProvider.java +++ b/src/main/java/io/github/glytching/junit/extension/json/JsonFileArgumentsProvider.java @@ -23,16 +23,54 @@ 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. + * + *

Example usage: + *

{@code
+ * @ParameterizedTest
+ * @JsonFileSource(resources = "yourTestData.json")
+ * void testWithJsonFileSource(YourCustomType customArgument) {
+ *     assertNotNull(customArgument);
+ *     // Perform tests with the deserialized customArgument object
+ * }
+ * }
+ * + * 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 { + implements AnnotationConsumer, ArgumentsProvider { public static final String ADDRESS_DASH = "/"; private final BiFunction, String, InputStream> inputStreamProvider; @@ -176,7 +214,6 @@ private static Object newConcreteTypeInstance(Class testMethodParameterClazz1 return object; } - /** 将对象中所有有递归引用的字段设置为null */ public static void setNullIfRecursive(Object object) { if (object == null || TypeUtils.isBuiltInType(object.getClass())) { return; diff --git a/src/test/java/io/github/glytching/junit/extension/json/JsonFileArgumentsProviderTest.java b/src/test/java/io/github/glytching/junit/extension/json/JsonFileArgumentsProviderTest.java index 3f6043b..33e2b9c 100644 --- a/src/test/java/io/github/glytching/junit/extension/json/JsonFileArgumentsProviderTest.java +++ b/src/test/java/io/github/glytching/junit/extension/json/JsonFileArgumentsProviderTest.java @@ -9,17 +9,9 @@ import lombok.Data; import org.jeasy.random.EasyRandom; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.CsvSource; public class JsonFileArgumentsProviderTest { - @ParameterizedTest - @CsvSource(value = {"1,2", "3,4"}) - void should_show_how_to_parse_multi_args_with_csv(Integer in, Integer out) { - assertEquals(out, in + 1); - } - @JsonFileSource(resources = {"string_test_case.json"}) void should_generate_test_case_json_lack_of_it(TestCase testCase) { assertEquals("eOMtThyhVNLWUZNRcBaQKxI", testCase.getInput()); @@ -59,7 +51,7 @@ void should_parse_recursionClass_input(RecursionClass recursionClass) { } @Test - void setNullIfRecursive() { + void set_null_if_recursive() { RecursionClass recursionClass = new EasyRandom().nextObject(RecursionClass.class); JsonFileArgumentsProvider.setNullIfRecursive(recursionClass); assertNull(recursionClass.getRecursionClass());