TEST

테스트 코드 - 더미 데이터 쉽게 만들기 (Easy Random) 알아보기

류큐큐 2023. 8. 26. 17:58

해당 포스팅은 Easy Random 코드를 까보는 포스팅이다.



테스트 코드를 작성하다보면 어쨋든 그 테스트를 위한 데이터들이 필요로 한데

이 더미 테스트를 만드는것이 여간 일이 아니다.

 

특히나 필드가 많고 복잡한 데이터들일수록 내가 이렇게까지 데이터를 만들어서 넣어줘야하나? 

 

이런 생각이 든다.

 

이런 불편한점을 해소하기 위해

java 라이브러리로 누군가

EasyRandom이란걸  만들어놨고 우린 그걸 사용하면 데이터를 쉽게 만들 수 있다.

 

https://github.com/j-easy/easy-random

 

GitHub - j-easy/easy-random: The simple, stupid random Java beans/records generator

The simple, stupid random Java beans/records generator - GitHub - j-easy/easy-random: The simple, stupid random Java beans/records generator

github.com



위의 링크는 easy-random의 깃허브이다.

글을 읽어보면 여러 큰기업들도 많이 사용하고있다.

깃허브에 들어가보면 간단하게 사용법들에 대해 나와있는데 한글로 요약해보자면

EasyRandomParameters parameters = new EasyRandomParameters()
        .seed(123L)
        .objectPoolSize(100)
        .randomizationDepth(3)
        .charset(forName("UTF-8"))
        .timeRange(nine, five)
        .dateRange(today, tomorrow)
        .stringLengthRange(5, 50)
        .collectionSizeRange(1, 10)
        .scanClasspathForConcreteTypes(true)
        .overrideDefaultInitialization(false)
        .ignoreRandomizationErrors(true);

EasyRandom easyRandom = new EasyRandom(parameters);


java.util.Random 에서 제공하는 API에는 nextInt(), nextLong(), nextDouble(), nextFloat(), nextBytes(), nextBoolean(), 그리고 nextGaussian()등의 메서드들을 제공하는데 

만약 랜덤한 문자열이나 도메인 객체의 랜덤 인스턴스를 생성하려면  Easy Random을 사용하라고 나와있다.

위의 코드는 랜덤한 문자열이나 도메인 객체의 랜덤 인스턴스를 생성하는데 필요한 파라미터들을 세팅하는 코드이다.


Easy Random은 org.jeasy.random.api.Randomizer 인터페이스를 통해 랜덤 데이터 생성 방법을 제어할 수 있게 하며, java.util.function.Predicate를 사용하여 객체 그래프에서 일부 필드를 쉽게 제외할 수 있게 해준다고 한다.


EasyRandomParameters parameters = new EasyRandomParameters()
        .randomize(String.class, () -> "foo")
        .excludeField(named("age").and(ofType(Integer.class)).and(inClass(Person.class)));

EasyRandom easyRandom = new EasyRandom(parameters);
Person person = easyRandom.nextObject(Person.class);


위의 코드를 보면 
람다 표현식으로 정의된 Randomizer를 사용하여 String 타입의 모든 필드 값을 "foo"로 설정한다.
Person 클래스의 Integer 타입인 "age"라는 이름의 필드는 값 생성을 제외한다.

이런식으로 사용할수있다고 한다.


이제 EasyRandom의 내부 코드를 살짝 살펴보자면

public EasyRandom() {
    this(new EasyRandomParameters());
}

/**
 * Create a new {@link EasyRandom} instance.
 *
 * @param easyRandomParameters randomization parameters
 */
public EasyRandom(final EasyRandomParameters easyRandomParameters) {
    Objects.requireNonNull(easyRandomParameters, "Parameters must not be null");
    super.setSeed(easyRandomParameters.getSeed());
    LinkedHashSet<RandomizerRegistry> registries = setupRandomizerRegistries(easyRandomParameters);
    RandomizerProvider customRandomizerProvider = easyRandomParameters.getRandomizerProvider();
    randomizerProvider = customRandomizerProvider == null ? new RegistriesRandomizerProvider() : customRandomizerProvider;
    randomizerProvider.setRandomizerRegistries(registries);
    objectFactory = easyRandomParameters.getObjectFactory();
    arrayPopulator = new ArrayPopulator(this);
    CollectionPopulator collectionPopulator = new CollectionPopulator(this);
    MapPopulator mapPopulator = new MapPopulator(this, objectFactory);
    OptionalPopulator optionalPopulator = new OptionalPopulator(this);
    enumRandomizersByType = new ConcurrentHashMap<>();
    fieldPopulator = new FieldPopulator(this,
            this.randomizerProvider, arrayPopulator,
            collectionPopulator, mapPopulator, optionalPopulator);
    exclusionPolicy = easyRandomParameters.getExclusionPolicy();
    parameters = easyRandomParameters;
}


일반생성자와

EasyRandomParameters를 파라미터로 받는 생성자 총 두개를 볼 수 있다.


아무래도 EasyRandomParameters를 받는 생성자가 여러 파라미터들을 조작할 수 있어 보인다.

그리고 위의 코드에서 EasyRandom객체를 생성후 nextObject 메서드안에 만들고싶은 객체를 넣어 놓으면 
그 객체가 생성되는데 

public <T> T nextObject(final Class<T> type) {
    return doPopulateBean(type, new RandomizationContext(type, parameters));
}

RandomizationContext라는 객체를 생성하고 doPopulateBean 메서드를 호출한다.

그리고 RandomizationContext 코드중 중요한 라인들만 살펴보자면

class RandomizationContext implements RandomizerContext {

    private final EasyRandomParameters parameters;

    private final Map<Class<?>, List<Object>> populatedBeans;

    private final Stack<RandomizationContextStackItem> stack;

    private final Class<?> type;

    private final Random random;

    private Object rootObject;

    RandomizationContext(final Class<?> type, final EasyRandomParameters parameters) {
        this.type = type;
        populatedBeans = new IdentityHashMap<>();
        stack = new Stack<>();
        this.parameters = parameters;
        this.random = new Random(parameters.getSeed());
    }

    void addPopulatedBean(final Class<?> type, Object object) {
        int objectPoolSize = parameters.getObjectPoolSize();
        List<Object> objects = populatedBeans.get(type);
        if (objects == null) {
            objects = new ArrayList<>(objectPoolSize);
        }
        if (objects.size() < objectPoolSize) {
            objects.add(object);
        }
        populatedBeans.put(type, objects);
    }

    Object getPopulatedBean(final Class<?> type) {
        int actualPoolSize = populatedBeans.get(type).size();
        int randomIndex = actualPoolSize > 1 ? random.nextInt(actualPoolSize) : 0;
        return populatedBeans.get(type).get(randomIndex);
    }

    boolean hasAlreadyRandomizedType(final Class<?> type) {
        return populatedBeans.containsKey(type) && populatedBeans.get(type).size() == parameters.getObjectPoolSize();
    }



   
....
}


이 클래스는 랜덤화 작업의 컨텍스트를 관리한다.

특정 타입의 객체가 이미 생성되었는지, 어떤 파라미터를 사용하여 객체를 생성해야 하는지 등의 정보를 가지고 있는데
populatedBeans는 이미 생성된 객체의 리스트를 저장하는 IdentityHashMap이다. 특정 타입에 대한 객체가 이미 생성되었는지 확인하고 재사용할 수 있게 해준다.
addPopulatedBean 메서드는 주어진 타입의 객체를 populatedBeans에 추가하는 역할을 한다.

여기서 우리가 특정 동일한 클래스에 대한 객체를 여러개를 만들어낼때 계속 다른값이 나올수있는 이유는
IdentityHashMap때문에 가능함을 유추할 수 있다.

IdentityHashMap이 궁금하면 더보기 클릭

더보기
더보기

IdentityHashMap은 Java의 java.util 패키지에 있는 Map 인터페이스의 구현체 중 하나.
기본적인 HashMap과는 다르게 동작한다.

키의 동등성(equality) 확인
 IdentityHashMap은 키의 동등성을 확인할 때 equals() 메서드 대신 == 연산자를 사용.
즉, 두 키 객체가 동일한 객체인 경우에만 동등하다고 간주 이는 IdentityHashMap이 키의 '정체성'에 기반한 매핑을 제공한다는 것을 의미

성능: IdentityHashMap은 해시맵의 일반적인 구현과 달리, 객체의 해시코드가 아닌 시스템 해시코드(System.identityHashCode())를 사용하여 해시 값을 얻는다. 이로 인해 IdentityHashMap은 일반적인 경우보다 성능 향상을 얻을 수 있다

용도: IdentityHashMap은 주로 객체 참조를 키로 사용하는 경우에 유용 예를 들어, 객체 그래프에서 객체 간의 관계를 나타내거나, 객체의 메타데이터를 저장할 때 사용

Map<String, String> identityMap = new IdentityHashMap<>();

String key1 = new String("key");
String key2 = new String("key");

identityMap.put(key1, "value1");
identityMap.put(key2, "value2");

System.out.println(identityMap.size());   // 출력: 2


위의 예제에서 key1과 key2는 같은 문자열 값을 가지지만 다른 객체로 분류한다.
일반 HashMap에서는 둘을 같은 키로 간주하겠지만, IdentityHashMap에서는 두 객체를 다른 키로 간주하므로 맵의 크기는 2가 된다


이제 doPopulateBean 메서드를 살펴보면 

<T> T doPopulateBean(final Class<T> type, final RandomizationContext context) {
    if (exclusionPolicy.shouldBeExcluded(type, context)) {
        return null;
    }

    T result;
    try {

        Randomizer<?> randomizer = randomizerProvider.getRandomizerByType(type, context);
        if (randomizer != null) {
            if (randomizer instanceof ContextAwareRandomizer) {
                ((ContextAwareRandomizer<?>) randomizer).setRandomizerContext(context);
            }
            return (T) randomizer.getRandomValue();
        }

        // Collection types are randomized without introspection for internal fields
        if (!isIntrospectable(type)) {
            return randomize(type, context);
        }

        // If the type has been already randomized, return one cached instance to avoid recursion.
        if (context.hasAlreadyRandomizedType(type)) {
            return (T) context.getPopulatedBean(type);
        }

        // create a new instance of the target type
        result = objectFactory.createInstance(type, context);
        context.setRandomizedObject(result);

        // cache instance in the population context
        context.addPopulatedBean(type, result);

        // retrieve declared and inherited fields
        List<Field> fields = getDeclaredFields(result);
        // we cannot use type here, because with classpath scanning enabled the result can be a subtype
        fields.addAll(getInheritedFields(result.getClass()));

        // inner classes (and static nested classes) have a field named "this$0" that references the enclosing class.
        // This field should be excluded
        if (type.getEnclosingClass() != null) {
            fields.removeIf(field -> field.getName().equals("this$0"));
        }

        // populate fields with random data
        populateFields(fields, result, context);

        return result;
    } catch (Throwable e) {
        if (parameters.isIgnoreRandomizationErrors()) {
            return null;
        } else {
            throw new ObjectCreationException("Unable to create a random instance of type " + type, e);
        }
    }
}

 

주어진 타입의 객체를 랜덤하게 생성하는 작업을 수행한다.

먼저, 해당 타입이 제외되어야 하는지(shouldBeExcluded) 확인 후,


해당 타입에 대한 Randomizer가 있는 경우, 해당 Randomizer를 사용하여 랜덤 객체를 생성

타입이 이미 랜덤화되었는지 확인(hasAlreadyRandomizedType)하고, 이미 랜덤화된 경우 캐시된 객체를 반환

그렇지 않은 경우, 새 인스턴스를 생성하고 필드를 랜덤값으로 채운다.

만약 생성 중에 문제가 발생하면, 파라미터에 따라 오류를 무시하거나 예외를 발생시킨다.


이제 어느정도 RandomEasy가 어떤지 알겠는가

그리고 더 세부적인 내용들을 보자면 아래 더보기를 클릭하고 
코드단을 확인하고 싶다면 다음 포스팅으로 넘어가자!

 

 

 

 

 

더보기
더보기


그래서 doPopulateBean의 메서드 마지막을 보면 populatedFields라는 메서드를 타고타고 들어가보자.
뭔가 아까 RandomizationContext에 stack 타입이 있었는데 거기서 뭔가를 꺼내는듯한 느낌이 든다.

private <T> void populateFields(final List<Field> fields, final T result, final RandomizationContext context) throws IllegalAccessException {
    for (final Field field : fields) {
        populateField(field, result, context);
    }
}

private <T> void populateField(final Field field, final T result, final RandomizationContext context) throws IllegalAccessException {
    if (exclusionPolicy.shouldBeExcluded(field, context)) {
        return;
    }
    if (!parameters.isOverrideDefaultInitialization() && getFieldValue(result, field) != null && !isPrimitiveFieldWithDefaultValue(result, field)) {
      return;
    }
    fieldPopulator.populateField(result, field, context);
}

populateFields
주어진 객체의 모든 필드를 순회하면서 각 필드를 랜덤 값으로 채우는데 populateField 메서드를 호출하여 실제로 필드 값을 설정

populateField
주어진 객체의 특정 필드에 랜덤 값을 설정
먼저 exclusionPolicy.shouldBeExcluded를 통해 해당 필드가 제외되어야 하는지 확인한다. 또 
parameters.isOverrideDefaultInitialization() 및 getFieldValue를 통해 필드의 현재 값이 기본값인지, 또는 이미 값이 설정되어 있는지 확인 후 필드가 기본값이 아니고 값을 오버라이드하면 안된다면 메서드를 종료한다.

fieldPopulator.populateField 메서드를 호출하여 실제 필드 값을 설정한다.

void populateField(final Object target, final Field field, final RandomizationContext context) throws IllegalAccessException {
    Randomizer<?> randomizer = getRandomizer(field, context);
    if (randomizer instanceof SkipRandomizer) {
        return;
    }
    context.pushStackItem(new RandomizationContextStackItem(target, field));
    if (randomizer instanceof ContextAwareRandomizer) {
        ((ContextAwareRandomizer<?>) randomizer).setRandomizerContext(context);
    }
    if(!context.hasExceededRandomizationDepth()) {
        Object value;
        if (randomizer != null) {
            value = randomizer.getRandomValue();
        } else {
            try {
                value = generateRandomValue(field, context);
            } catch (ObjectCreationException e) {
                String exceptionMessage = String.format("Unable to create type: %s for field: %s of class: %s",
                      field.getType().getName(), field.getName(), target.getClass().getName());
                // FIXME catch ObjectCreationException and throw ObjectCreationException ?
                throw new ObjectCreationException(exceptionMessage, e);
            }
        }
        if (context.getParameters().isBypassSetters()) {
            setFieldValue(target, field, value);
        } else {
            try {
                setProperty(target, field, value);
            } catch (InvocationTargetException e) {
                String exceptionMessage = String.format("Unable to invoke setter for field %s of class %s",
                        field.getName(), target.getClass().getName());
                throw new ObjectCreationException(exceptionMessage,  e.getCause());
            }
        }
    }
    context.popStackItem();
}


fieldPopulator의 populateField 
주어진 객체의 특정 필드에 랜덤 값을 설정한다.
해당 필드에 대한 Randomizer를 가져온 후  SkipRandomizer 인스턴스인 경우 필드 설정을 스킵한다.

context.pushStackItem는 현재 필드와 객체 정보를 스택에 저장하는데 이는 재귀적인 랜덤화를 피하기 위해 사용된다.

이게 뭔말이냐면 위의 populateField 메서드를 보면 pushStackItem를 호출 후
if(!context.hasExceededRandomizationDepth()) { ... }의 분기문을 만나는데 뭔가 초과되지 않았다면
randomizer가 없으면 
generatedValue(field, context)를 만나게 된다. 

private Object generateRandomValue(final Field field, final RandomizationContext context) {
    Class<?> fieldType = field.getType();
    Type fieldGenericType = field.getGenericType();

    if (isArrayType(fieldType)) {
        return arrayPopulator.getRandomArray(fieldType, context);
    } else if (isCollectionType(fieldType)) {
        return collectionPopulator.getRandomCollection(field, context);
    } else if (isMapType(fieldType)) {
        return mapPopulator.getRandomMap(field, context);
    } else if (isOptionalType(fieldType)) {
        return optionalPopulator.getRandomOptional(field, context);
    } else {
        if (context.getParameters().isScanClasspathForConcreteTypes() && isAbstract(fieldType) && !isEnumType(fieldType) /*enums can be abstract, but cannot inherit*/) {
            List<Class<?>> parameterizedTypes = filterSameParameterizedTypes(getPublicConcreteSubTypesOf(fieldType), fieldGenericType);
            if (parameterizedTypes.isEmpty()) {
                throw new ObjectCreationException("Unable to find a matching concrete subtype of type: " + fieldType);
            } else {
                Class<?> randomConcreteSubType = parameterizedTypes.get(easyRandom.nextInt(parameterizedTypes.size()));
                return easyRandom.doPopulateBean(randomConcreteSubType, context);
            }
        } else {
            Type genericType = field.getGenericType();
            if (isTypeVariable(genericType)) {
                // if generic type, try to retrieve actual type from hierarchy
                Class<?> type = getParametrizedType(field, context);
                return easyRandom.doPopulateBean(type, context);
            }
            return easyRandom.doPopulateBean(fieldType, context);
        }
    }
}

이 메서드를 보면 맨마지막 return문에 doPopulateBean 메서드를 만난다.

어디서 본거같은데 맞다 아까 우리가 맨처음 본 메서드이다.

위의 doPopulateBean의 메서드를 다시 보면 해당 구문이 보인다.

<T> T doPopulateBean(final Class<T> type, final RandomizationContext context) {

....
            // If the type has been already randomized, return one cached instance to avoid recursion.
        if (context.hasAlreadyRandomizedType(type)) {
            return (T) context.getPopulatedBean(type);
        }
        
        
        ...





    boolean hasAlreadyRandomizedType(final Class<?> type) {
        return populatedBeans.containsKey(type) && populatedBeans.get(type).size() == parameters.getObjectPoolSize();
    }

populatedBeans.containsKey(type): 이 부분은 populatedBeans라는 맵에 주어진 타입(type)의 키가 존재하는지 확인
이 맵에는 이미 생성된(랜덤화된) 객체들이 해당 객체의 타입을 키로 저장

populatedBeans.get(type).size() == parameters.getObjectPoolSize(): 이 부분은 해당 타입에 대해 랜덤화하여 저장된 객체의 수가 설정된 객체 풀 크기(parameters.getObjectPoolSize())와 동일한지 확인

그러니 주어진 타입의 객체가 이미 충분한 수만큼 랜덤화되었는지를 판단하고 맞다면 

캐시된 인스턴스를 반환하고 그 이후의 로직을 수행하지 않는다.

예를 들면, Person 객체가 Address 객체를 필드로 가지고 있다
Person 객체의 필드를 랜덤하게 채우는 중에 Address 필드에 도달했다면,
Address 객체 내부의 필드들 역시 랜덤하게 채워야 한다.

 

그런데 만약 Address객체 안에 Person객체가 또 있다면 무한으로 값들을 채워넣기 위해 계속 반복작업을 할 것이다.

이를 방지해주는 코드인 것이다.

그리고  그 후에 

context.getParameters().isBypassSetters()를 통해 필드 값을 직접 설정할지,  setter 메서드를 통해 설정할지를 결정
설정이 끝나면 스택에서 context.popStackItem 로 값을 빼낸다

이 정도면 EasyRandom의 핵심적인 부분은 다 본것같다.

이제 코드 단에서 어떻게 객체들을 쉽게 만들수 있는지 모듈을 만들어보자.