🟦Test Fixture
개발에 있어서 가장 중요한 요소 중 하나인 테스트 코드를 작성하다보면 여러가지 객체들의 상태나 행위에 대한 검증이 필요하게 되고, 이를 테스트하기 위한 테스트 객체(더미 객체)들을 만들게 됩니다. 그리고 우리는 이러한 객체들을 보통 Test Fixture
라고 부릅니다. Test Fixture의 정의는 무엇일까요?
A test fixture is a device used to consistently test some item, device, or piece of software. Test fixtures are used in the testing of electronics, software and physical devices.
위 위키피디아 정의를 정리하면 일관된 테스트를 수행하기 위해 필요한 상태나 환경을 정의한 장치라고 할 수 있겠습니다. 즉, 사전에 정의한 테스트 객체를 테스트에 사용하여 보다 효율적인 테스트를 진행하자는 의의를 가졌다고 볼 수 있을 것 같습니다.
public class AccountFixture {
public static Account createAccount() {
return new Account(Platform.KAKAO, "111111");
}
public static Account createAccountWithUser(User user) {
Account account = new Account(Platform.KAKAO, "111111");
account.registerUser(user);
return account;
}
}
저의 경우에는 Test Fixture 를 사용하기 위해 위와 같이 도메인별로 Fixture 생성용 클래스를 만들어 테스트 조건에 부합하는 객체를 생성하여 사용하는 방식을 사용하였습니다. 이렇게 Test Fixture 사용하면 여러 테스트에서 공유하는 객체에 대한 생성을 간단하게 진행할 수 있으며, 메서드 명을 통해 어떤 상태를 지닌 객체를 생성하는지 파악할 수 있기 때문에 가독성도 향상되어 보다 쾌적환 테스트 환경을 만들어 나갈 수 있었다고 생각합니다.
하지만, Test Fixture 방식에도 단점은 존재했습니다. 유사한 상태를 지닌 객체들이 사용되는 여러 테스트가 있을 경우에는 실제로 값을 검증하는 과정보다 Fixture 객체를 만드는 데 더 많은 시간을 소모하게 될 수도 있습니다.
즉, 여러 테스트가 같은 Fixtrue 객체를 공유할 수 있는 상황에서 빛을 볼 수 있는 구조인데, 같은 클래스지만 멤버의 값이 다른 여러 객체들
을 생성해야하는 경우 (컬렉션을 생성해야 하는 경우) 하나하나 Fixture를 만들자니 시간도 많이 소요되며 재사용성이 떨어지고, 그렇다고 일일이 생성하자니 코드 라인이 너무 길어져 가독성이 떨어지는 문제가 있었습니다.
🟦Reflection 을 사용하면 어떨까?
위와 같은 문제 상황에서 저는 그냥 객체의 필드 기반으로 테스트 객체를 생성할 수 있도록 Reflection을 통해 동적으로 객체를 생성할 수 있게 하면 되지 않을까? 라는 생각을 하게 됩니다.
Reflection 이란?
구체적인 클래스 정보를 알지 못해도 그 클래스의 메서드, 타입, 변수 등에 접근할 수 있도록 도와주는 Java 진영의 API이다. Java 클래스 파일은 바이트 코드로 컴파일 되어 Method Area (JVM 영역) 에 위치하게 되는데, 리플렉션은 해당 영역을 참고하여 클래스 정보들을 가져온다.
Reflection
을 통해 생성자와 변수들에 접근하여 변수 타입에 맞는 더미 값을 넣어주는 메서드를 만들면 되지 않을까? 하여 직접 구현에 나서게 되었습니다.
public static void generateFixture(String className) throws Exception {
Class<?> clazz = Class.forName(className);
Field[] fields = clazz.getFields();
for (Field field : fields) {
field.setAccessible(true);
if (field.getType().equals(String.class)) {
field.set(clazz, "더미 값");
}
if (field.getType().equals(Long.class)) {
field.set(clazz, 1L);
}
if (field.getType().equals(Integer.class)) {
field.set(clazz, 1);
}
// 타입별로 계속 확인..
...
}
}
대강 위와 같은 식으로 필드들의 타입을 확인하고 해당 타입에 어울리는 더미 값들을 넣어줄 계획이었는데 진행하면 할 수록 이게 맞나..? 라는 생각이 들게 되었습니다. 이런 방식으로 구현하면 프로젝트 내에 존재하는 모든 클래스를 비교해야 하고, 항상 같은 값이 들어가기 때문에 애초에 의도했던 멤버의 값이 다른 객체들을 만들어낼 수 없는 구조입니다.
이를 개선하기 위해서는 파라미터로 들어온 클래스 정보를 분석하고 해당 클래스의 깊이 (필드 클래스 내부에 또 클래스가 있는지) 를 파악해서 재귀 호출이나 반복문을 통해 내부의 필드들을 채워나가는 식으로 하면 되지 않을까 했지만, 제 수준으로 구현하기에는 너무 방대한 스케일과 난이도였습니다..
그래서 기존에 만들어진 테스트용 객체 생성 라이브러리가 있지 않을까 생각하게 되었고 운이 좋게도 제가 원하는 바를 모두 제공하는 두 가지 라이브러리를 찾게 되었습니다.
🟦Instancio와 Fixture Monkey
위에서 발견한 두 라이브러리는 Instancio
와 Fixture Monkey
였는데요. 여기서 Fixture Monkey 의 경우 네이버에서 개발한 오픈소스입니다. Instancio의 경우 Github Star가 Fixture Monkey 보다 약 2배 많음에도 불구하고 국내 커뮤니티에는 많이 알려지지 않은 것 같아 숨겨진 보물 같은 느낌이 들었습니다.
그렇기에 이번 글의 핵심인 두 라이브러리 모두를 살펴보는 시간을 가져보려고 합니다.
🟩Instancio (공식 문서 https://www.instancio.org/)
우선 인스턴시오부터 알아보도록 하겠습니다.
How does it work?
Instancio uses reflection to populate objects, including nested objects and collections. A single method call provides you with a fully populated instance of a class, ready to be used as an input to your test case.
어떻게 동작하는지에 대한 공식 문서 설명에 따르면 인스턴시오는 리플렉션
을 사용하여 단일 메서드 호출만으로 중첩 객체 및 컬렉션까지 포함하여 완전히 값이 채워진 테스트용 객체를 생성할 수 있다고 합니다.
제가 리플렉션을 통해서 만들려고 했던 기능과 정확히 일치하네요!!
Why use it?
The goal is to reduce the time and lines of code spent on manual data setup in unit tests, and potentially to uncover bugs that may have gone unnoticed with static test data.
사용 이유에 대해서는 다음과 같이 말합니다.
"목표는 단위 테스트에서 수동 데이터 설정에 소요되는 시간과 코드 줄을 줄이고 잠재적으로 정적 테스트 데이터에서 발견되지 않았을 수 있는 버그를 발견하는 것."
즉, 리플렉션을 통해서 동적으로 필드를 채워주고 필드에 삽입되는 값들이 랜덤하기 때문에 정적 테스트 객체에서는 발견되지 않았을 수 있는 버그까지 찾을 수 있다고 합니다. (Good...)
💡 Fixture 객체는 정적인 상태를 유지해야 하는 거 아닌가? 💡
Instancio를 보면서 도중에 이러한 의문이 들었고 제가 내린 결론은 다음과 같습니다.
우선, Test Fixture는 어떠한 테스트 메서드가 존재할 때 의도한 결과가 나오도록 사전에 정의한 정적 객체입니다. 그런데 테스트 객체의 값이 임의로 변경되면 테스트 메서드가 실행될 때마다 의도한 결과가 나온다는 보장을 할 수 없기 때문에 고정된
이라는 뜻의 Fixtrue
를 사용한 것 같습니다.
그런데, 항상 변하지 않는 정적인 값만을 사용하면 예상하지 못한 내용에 대해서는 테스트를 진행할 수 없게 됩니다. 우리는 우리가 작성한 코드를 100% 신뢰할 수 없습니다. 그렇기에 때에 따라서는 동적으로 값이 변하는 객체가 필요하게 됩니다.
이를 위해 Intancio는 내부적으로 타입에 해당하는 랜덤 값을 적절한 Seed
를 통해 생성하고 있는 것으로 보이는데, 이 때문에 정말 말도 안되는 터무니 없는 값이 생성되어 테스트가 깨지게 되는 경우는 거의 없는 것으로 보입니다. 이 Seed 역시 커스텀이 가능하며 랜덤한 값을 생성하는 Generator
역시 커스텀이 가능하기 때문에 이를 적절히 사용한다면 정말 좋은 테스트 라이브러리라고 생각합니다.
✔사용 방법
dependencies {
testImplementation 'org.instancio:instancio-junit:4.1.0'
}
Junit 환경에서는 위와 같이 의존성만 넣어주면 초기 설정은 끝입니다.
Instancio를 적용하면 아래와 같이 사용이 가능합니다.
public static BoardUpdateRequest generateBoardUpdateRequest(Long stackId, Long positionId) {
return Instancio.of(BoardUpdateRequest.class)
//필드 값에 접두사 추가
.generate(field(BoardUpdateRequest::title), gen -> gen.string().prefix("게시글 이름"))
//필드 값에 접두사 추가
.generate(field(BoardUpdateRequest::content), gen -> gen.string().prefix("게시글 내용"))
// 필드 값의 랜덤한 범위 지정
.generate(field(BoardUpdateRequest::participantLimit), gen -> gen.ints().range(2, 10))
// 랜덤한 미래의 LocalDate 범위 지정
.generate(field(BoardUpdateRequest::deadline), gen -> gen.temporal().localDate().future())
// 직접 생성한 값을 추가
.set(field(BoardUpdateRequest::stacks), List.of(stackId))
// 직접 생성한 값을 추가
.set(field(BoardUpdateRequest::positions), List.of(positionId))
.create();
}
Instancio의 경우 정말 많고 다양한 기능을 제공하기 때문에 이번 포스팅에서 다 소개해드리기는 어려울 것 같아 제가 사용해봤거나 사용해보려고 했던 주요 기능들에 대해서 소개해드리도록 하겠습니다.
우선 기본적인 객체 생성 방식입니다.
Person person = Instancio.create(Person.class);
Person personWithoutAgeAndAddress = Instancio.of(Person.class)
.ignore(field(Person::getAddress))
.create();
위와 같이 Instancio의 정적 메서드 create()
또는 of()
메서드를 호출하고 파라미터로는 클래스 데이터를 넘겨주면 끝입니다. 클래스 내부의 필드는 자동으로 필드 타입에 따라 랜덤한 값이 생성됩니다. 이를 통해 정적 테스트 데이터에서 발견하지 못한 버그를 찾을 수 있게 되는 것입니다.
of()
메서드를 사용하면 객체 내부에 생성되는 필드들의 값을 조작할 수 있습니다. 위 예시에서는 Person 객체의 Address 필드는 생성하지 않겠다는 의미로 ignore()
가 사용된 것입니다.
다음은 컬렉션 생성 방식입니다. 이곳까지 흘러오게 된 계기이죠!
// List 생성
List<Person> list = Instancio.ofList(Person.class)
.size(10)
.create();
// Map 생성
Map<UUID, Address> map = Instancio.ofMap(UUID.class, Address.class)
.size(3)
.set(field(Address::getCity), "Vancouver")
.create();
// Set 생성
Set<Person> set = Instancio.ofSet(personModel)
.size(5)
.create();
기본적인 객체 생성 방식과 마찬가지로 Instancio의 정적 메서드를 호출하여 정말 간단하게 컬렉션을 생성할 수 있습니다. Fixture 방식에서 큰 단점이라고 생각했던 다양한 상태를 가진 여러 객체들의 생성을 Instancio에서는 위와 같이 간단하게 생성이 가능하고, 컬렉션 내부의 객체는 모두 랜덤한 필드 값을 지니게 되기 때문에 Set 또는 Map 컬렉션에 담더라도 중복으로 인한 제거도 이루어지지 않습니다.
세 번째 라인에서 Map 생성 방식을 보면 set()
키워드가 사용된 것을 볼 수 있는데, 위에서 사용한 ignore()
와 마찬가지로 of()
메서드를 통해 객체를 생성할 때 직접 필드에 값을 넣어주고 싶은 경우 사용할 수 있습니다.
다음은 객체 생성 템플릿 사용법입니다.
// 공유할 모델
Model<Person> simpsonsModel = Instancio.of(Person.class)
.ignore(field(Person::getId))
.set(field(Person::getLastName), "Simpson")
.set(field(Address::getCity), "Springfield")
.generate(field(Person::getAge), gen -> gen.ints().range(40, 50))
.toModel();
// 모델 활용
Person homer = Instancio.of(simpsonsModel)
.set(field(Person::getFirstName), "Homer")
.create();
Person marge = Instancio.of(simpsonsModel)
.set(field(Person::getFirstName), "Marge")
.create();
Instancio에서는 Model<?>
타입을 사용하여 기존에 Instancio를 통해 생성했던 객체 생성 방식을 템플릿처럼 저장하여 사용할 수 있습니다. 이를 통해 코드의 재사용성을 높여 보다 효율적인 테스트 객체를 생성할 수 있습니다.
또한, 위 예시에서 다섯 번째 코드라인을 보면 generate()
가 사용된 것을 볼 수 있는데 해당 메서드는 Instancio가 제공하는 임의의 랜덤 값이 아닌 커스텀한 문자열, 숫자, 날짜, 배열, 컬렉션 등을 생성할 수 있도록 도와줍니다.
이외에도 JPA 를 위한 Instancio-JPA, 순환 참조 객체 생성, Custom Generator를 생성하여 지정한 Random 범위 내의 값으로 객체 생성, Bean Validtion이 적용된 객체 생성 등 정말 다양한 기능을 제공하고 있습니다. 공식 문서의 설명도 상당히 자세한 편이기 때문에 러닝 커브도 그렇게 높지 않아 사용하기 좋은 라이브러리라고 생각되됩니다!
🟩Fixture Monkey (공식 문서 https://naver.github.io/fixture-monkey/)
다음은 픽스쳐 몽키에 대해서 알아보도록 하겠습니다.

픽스쳐 몽키에서 강조하는 세 가지 특징은 다음과 같습니다.
1. 간편함
손쉽게 한 줄의 코드로 테스트 객체를 생성할 수 있습니다. 복잡한 테스트 객체를 빌더 패턴
을 사용하여 생성할 수 있습니다.
2. 재사용성
복잡한 사양을 한 번 정의해두면 재사용 할 수 있습니다. 인스턴스의 설정은 다양한 테스트에서 재사용 될 수 있습니다.
3. 무작위성
픽스쳐 몽키를 통해 생성한 테스트 객체를 통해 테스트를 보다 다이나믹하고 랜덤하게 만들 수 있습니다. 정적 데이터를 사용할 때 발견하지 못한 엣지 케이스를 커버할 수 있습니다.
요약하면 픽스쳐 몽키 역시 동적
으로 변하는 테스트 객체를 만들 수 있도록 하며 재사용성을 높이고 랜덤한 값을 채워 넣는 방식을 통해 사전에 발견하지 못한 엣지 케이스를 발견할 수 있게 해줍니다. 전체적으로는 인스턴시오와 매우 유사한 특징을 가지고 있다는 것을 알 수 있습니다.
✔사용 방법
testImplementation("com.navercorp.fixturemonkey:fixture-monkey-starter:1.0.0")
우선, 위의 의존성을 추가해줍니다.
픽스쳐 몽키는 인스턴시오와는 다르게 픽스쳐 몽키 객체를 생성해서 사용해야 합니다.
FixtureMonkey fixtureMonkey = FixtrueMonkey.create();
Person person = fixtureMonkey.giveMeOne(Person.class);
픽스쳐 몽키를 좀 더 잘 활용하기 위해서는 기능 설명에 앞서 Introspector
라는 개념에 대해서 알아볼 필요가 있을 것 같아 해당 부분부터 소개를 해드리도록 하겠습니다. Introspector는 Fixture Monkey가 객체를 생성하는 방식을 정의합니다. 어떤 Introspector를 적용하냐에 따라 Fixture Monkey의 객체 생성 방식이 달라지게 됩니다. 공식 문서에 따르면 Introspector에는 다섯 가지 종류가 존재합니다.
1. BeanArbitraryIntrospector
기본 설정에 해당하는 Introspector 입니다. 이름에서 알 수 있듯이 자바 빈즈 규약에 따라 객체를 생성해주는 Introspector 입니다. 따라서 클래스에는 인수가 없는 생성자와 setter가 있어야 합니다. 리플렉션과 setter 메서드를 사용하여 새 인스턴스를 생성하며 기본 설정이기 때문에 FixtureMonkey.create()
는 위 방식을 사용합니다.
2. ConstructorPropertiesArbitraryIntrospector
FixtureMonkey fixtureMonkey = FixtureMonkey.builder()
.objectIntrospector(ConstructorPropertiesArbitraryIntrospector.INSTANCE)
.build();
지정된 생성자를 사용하는 Introspector 입니다. 객체 생성에 사용하고 싶은 생성자에 @ConstructorProperties
를 선언하거나 롬복 설정에 lombok.anyConstructor.addConstructorProperties=true
를 추가해주면 적용할 수 있습니다.
3. FieldReflectionArbitraryIntrospector
FixtureMonkey fixtureMonkey = FixtureMonkey.builder()
.objectIntrospector(FieldReflectionArbitraryIntrospector.INSTANCE)
.build();
필드 리플렉션을 사용하는 Introsepctor 입니다. 따라서 생성할 클래스에는 인수가 없는 생성자와 getter 또는 setter 중 하나가 있어야 합니다. 리플렉션을 사용하기 때문에 final
, transient
키워드가 붙은 필드에는 적용할 수 없습니다. JPA를 사용하고 있다면 엔티티 클래스에는 final 키워드를 사용할 수 없기 때문에 엔티티 클래스를 Fixture 객체로 생성할 때 적용하면 좋을 것 같습니다.
4. BuilderArbitraryIntrospector
롬복의 @Builder
를 기반으로 하는 Introspector 입니다. 당연히 빌더 패턴이 적용된 클래스만 대상이 될 수 있습니다.
FixtureMonkey fixtureMonkey = FixtureMonkey.builder()
.objectIntrospector(BuilderArbitraryIntrospector.INSTANCE)
.build();
5. FailoverArbitraryIntrospector
FixtureMonkey sut = FixtureMonkey.builder()
.objectIntrospector(new FailoverIntrospector(
Arrays.asList(
FieldReflectionArbitraryIntrospector.INSTANCE,
ConstructorPropertiesArbitraryIntrospector.INSTANCE
)
))
.build();
때로는 프로덕션 코드에 구성이 다른 여러 클래스가 포함되어 단일 Introspector 로 모든 객체를 생성하기 어려울 수 있습니다. 이 경우 FailoverArbitraryIntrospector
를 사용할 수 있습니다. Introspector를 리스트로 담아서 사용할 수 있으며, 생성 과정에서 실패한다면 리스트내에 다른 Introspector를 사용하여 객체를 생성합니다.
다음으로는 활용 방법입니다.
FixtureMonkey fixtureMonkey = FixtureMonkey.create();
// 단일 객체 생성
Person person = fixtureMonkey.giveMeOne(Person.class);
// 복수 객체 생성
List<Person> personList = fixtureMonkey.giveMe(Person.class, 10);
위와 같이 픽스쳐 몽키 객체를 생성한 후 giveMeOne()
메서드 또는 giveMe()
메서드를 통해 간편하게 객체 또는 컬렉션을 생성할 수 있습니다.
Person person = fixtureMonkey.giveMeBuilder(Person.class)
.set("name", Arbitraries.strings().withCharRange('a', 'z').ofMaxLength(10))
.set("age", Arbitraries.integers().between(5, 10))
.set("gener", Arbitraries.of(Gender.MALE, Gender.FEMALE))
.sample();
// 위에서 만들어진 객체 재활용
Person withMyName = fixtureMonkey.giveMeBuilder(person)
.set("name", "나그네")
.sample();
객체 생성 과정에서의 커스터마이징이 필요한 경우 위와 같이 giveMeBuilder()
를 사용하여 위와 같이 사용할 수 있으며, Arbitraries
를 사용하여 랜덤하게 생성하고 싶은 값들의 범위나 형식을 커스터마이징 할 수 있습니다.
또한, 기존에 만들어 둔 객체를 인자로 넘겨주기만 하면 템플릿처럼 사용하여 재사용할 수도 있으며 이외에도 Bean Validation 적용하기, 커스텀 Builder 만들기, Jackson 어노테이션을 이용한 객체 생성하기, 객체 내부의 인터페이스 또는 추상 클래스에 MockBean 주입하기 등 다양한 기능을 사용할 수 있기 때문에 Fixture Monkey 역시 뛰어난 라이브러리라고 할 수 있겠습니다.
🟦정리
지금까지 인스턴시오와 픽스쳐 몽키에 대해서 알아보았는데요. 전반적으로 유사한 점이 많기 때문에 사용을 한다면 어떤 라이브러리를 사용해야 할까에 대한 고민이 생기게 되어서 다음과 같이 정리하였습니다.
1. 러닝 커브
저는 인스턴시오의 공식 문서가 보다 잘 정리되어 있다는 느낌이 들었습니다. 거의 모든 기능들에 대한 설명이 User Guide에 존재하기 때문에 내가 원하는 기능을 검색하고 가이드에 나온 내용을 토대로 프로젝트에 적용해보는 것이 가능했습니다. 반면에, 픽스쳐 몽키의 경우 설명이 생략된 부분들이 존재하기 때문에 직접 사용해보며 익혀야 되는 부분들이 인스턴시오 보다는 많다는 느낌이 들었습니다. 따라서 러닝 커브 면에서는 인스턴시오가 유리하다고 할 수 있겠습니다.
2. 객체 생성 방식의 다양성
픽스쳐 몽키의 경우 위에서 설명드렸듯이 여러가지 Inspector를 토대로 다양한 생성 방식을 지정할 수 있습니다. 생성 방식의 다양성은 유연한 프로그래밍이 가능하며 기존 코드의 활용 범위가 넓어지기 때문에 이러한 부분이 큰 장점이 된다고 생각합니다. 이에 반해 인스턴시오의 경우 리플렉션을 통한 생성만을 지원하기 때문에 활용성과 성능적인 부분에서 픽스쳐 몽키가 좀 더 우세하다고 볼 수 있을 것 같습니다.
3. 코틀린 지원
최근 Kotlin을 이용한 개발이 늘어남에 따라 Kotlin 지원 여부도 중요한 요소 중 하나라고 생각됩니다. 인스턴시오의 경우 Java만을 지원하고 있으며, 픽스쳐 몽키의 경우 Java 와 Kotlin 모두 지원하고 있습니다. 만약 Kotlin 을 사용하고 있으며 Kotest가 아닌 JUnit 환경의 프로젝트라면 결국 선택지는 픽스쳐 몽키밖에 없을 것 같습니다.
위 정리를 토대로 저는 인스턴시오를 사용하게 되었는데요. 적용 당시에 팀 프로젝트를 진행 중이었고 정해진 기한 내에 라이브러리를 이해하고 프로젝트에 적용을 해야했기 때문에 비교적 러닝 커브가 낮은 인스턴시오에 대한 간단한 소개와 설명을 진행한 후 프로젝트에 적용 하기를 제안하였고 팀원들도 금새 익혀 어렵지 않게 프로젝트에 바로 적용할 수 있었습니다!!👍
여러분들도 이를 참고하여 각자의 프로젝트 환경에 맞는 라이브러리를 선택하여 적용해보시면 좋을 것 같습니다. 긴 글 읽어주셔서 감사합니다!
- 위 내용에 대한 오류 지적과 피드백은 언제나 환영입니다! -
'Spring' 카테고리의 다른 글
이벤트를 좀 더 제대로 다뤄보자 (feat. MSA & EDA) (2) | 2024.04.22 |
---|---|
동시성 테스트를 통한 성능 개선하기 (2) - Spring Event (2) | 2024.04.04 |
조회 성능 최적화(3) - QueryDsl : SQL (0) | 2024.03.16 |
조회 성능 최적화 (1) - 쿼리 발생 줄이기 (1) | 2024.02.13 |
🟦Test Fixture
개발에 있어서 가장 중요한 요소 중 하나인 테스트 코드를 작성하다보면 여러가지 객체들의 상태나 행위에 대한 검증이 필요하게 되고, 이를 테스트하기 위한 테스트 객체(더미 객체)들을 만들게 됩니다. 그리고 우리는 이러한 객체들을 보통 Test Fixture
라고 부릅니다. Test Fixture의 정의는 무엇일까요?
A test fixture is a device used to consistently test some item, device, or piece of software. Test fixtures are used in the testing of electronics, software and physical devices.
위 위키피디아 정의를 정리하면 일관된 테스트를 수행하기 위해 필요한 상태나 환경을 정의한 장치라고 할 수 있겠습니다. 즉, 사전에 정의한 테스트 객체를 테스트에 사용하여 보다 효율적인 테스트를 진행하자는 의의를 가졌다고 볼 수 있을 것 같습니다.
public class AccountFixture {
public static Account createAccount() {
return new Account(Platform.KAKAO, "111111");
}
public static Account createAccountWithUser(User user) {
Account account = new Account(Platform.KAKAO, "111111");
account.registerUser(user);
return account;
}
}
저의 경우에는 Test Fixture 를 사용하기 위해 위와 같이 도메인별로 Fixture 생성용 클래스를 만들어 테스트 조건에 부합하는 객체를 생성하여 사용하는 방식을 사용하였습니다. 이렇게 Test Fixture 사용하면 여러 테스트에서 공유하는 객체에 대한 생성을 간단하게 진행할 수 있으며, 메서드 명을 통해 어떤 상태를 지닌 객체를 생성하는지 파악할 수 있기 때문에 가독성도 향상되어 보다 쾌적환 테스트 환경을 만들어 나갈 수 있었다고 생각합니다.
하지만, Test Fixture 방식에도 단점은 존재했습니다. 유사한 상태를 지닌 객체들이 사용되는 여러 테스트가 있을 경우에는 실제로 값을 검증하는 과정보다 Fixture 객체를 만드는 데 더 많은 시간을 소모하게 될 수도 있습니다.
즉, 여러 테스트가 같은 Fixtrue 객체를 공유할 수 있는 상황에서 빛을 볼 수 있는 구조인데, 같은 클래스지만 멤버의 값이 다른 여러 객체들
을 생성해야하는 경우 (컬렉션을 생성해야 하는 경우) 하나하나 Fixture를 만들자니 시간도 많이 소요되며 재사용성이 떨어지고, 그렇다고 일일이 생성하자니 코드 라인이 너무 길어져 가독성이 떨어지는 문제가 있었습니다.
🟦Reflection 을 사용하면 어떨까?
위와 같은 문제 상황에서 저는 그냥 객체의 필드 기반으로 테스트 객체를 생성할 수 있도록 Reflection을 통해 동적으로 객체를 생성할 수 있게 하면 되지 않을까? 라는 생각을 하게 됩니다.
Reflection 이란?
구체적인 클래스 정보를 알지 못해도 그 클래스의 메서드, 타입, 변수 등에 접근할 수 있도록 도와주는 Java 진영의 API이다. Java 클래스 파일은 바이트 코드로 컴파일 되어 Method Area (JVM 영역) 에 위치하게 되는데, 리플렉션은 해당 영역을 참고하여 클래스 정보들을 가져온다.
Reflection
을 통해 생성자와 변수들에 접근하여 변수 타입에 맞는 더미 값을 넣어주는 메서드를 만들면 되지 않을까? 하여 직접 구현에 나서게 되었습니다.
public static void generateFixture(String className) throws Exception {
Class<?> clazz = Class.forName(className);
Field[] fields = clazz.getFields();
for (Field field : fields) {
field.setAccessible(true);
if (field.getType().equals(String.class)) {
field.set(clazz, "더미 값");
}
if (field.getType().equals(Long.class)) {
field.set(clazz, 1L);
}
if (field.getType().equals(Integer.class)) {
field.set(clazz, 1);
}
// 타입별로 계속 확인..
...
}
}
대강 위와 같은 식으로 필드들의 타입을 확인하고 해당 타입에 어울리는 더미 값들을 넣어줄 계획이었는데 진행하면 할 수록 이게 맞나..? 라는 생각이 들게 되었습니다. 이런 방식으로 구현하면 프로젝트 내에 존재하는 모든 클래스를 비교해야 하고, 항상 같은 값이 들어가기 때문에 애초에 의도했던 멤버의 값이 다른 객체들을 만들어낼 수 없는 구조입니다.
이를 개선하기 위해서는 파라미터로 들어온 클래스 정보를 분석하고 해당 클래스의 깊이 (필드 클래스 내부에 또 클래스가 있는지) 를 파악해서 재귀 호출이나 반복문을 통해 내부의 필드들을 채워나가는 식으로 하면 되지 않을까 했지만, 제 수준으로 구현하기에는 너무 방대한 스케일과 난이도였습니다..
그래서 기존에 만들어진 테스트용 객체 생성 라이브러리가 있지 않을까 생각하게 되었고 운이 좋게도 제가 원하는 바를 모두 제공하는 두 가지 라이브러리를 찾게 되었습니다.
🟦Instancio와 Fixture Monkey
위에서 발견한 두 라이브러리는 Instancio
와 Fixture Monkey
였는데요. 여기서 Fixture Monkey 의 경우 네이버에서 개발한 오픈소스입니다. Instancio의 경우 Github Star가 Fixture Monkey 보다 약 2배 많음에도 불구하고 국내 커뮤니티에는 많이 알려지지 않은 것 같아 숨겨진 보물 같은 느낌이 들었습니다.
그렇기에 이번 글의 핵심인 두 라이브러리 모두를 살펴보는 시간을 가져보려고 합니다.
🟩Instancio (공식 문서 https://www.instancio.org/)
우선 인스턴시오부터 알아보도록 하겠습니다.
How does it work?
Instancio uses reflection to populate objects, including nested objects and collections. A single method call provides you with a fully populated instance of a class, ready to be used as an input to your test case.
어떻게 동작하는지에 대한 공식 문서 설명에 따르면 인스턴시오는 리플렉션
을 사용하여 단일 메서드 호출만으로 중첩 객체 및 컬렉션까지 포함하여 완전히 값이 채워진 테스트용 객체를 생성할 수 있다고 합니다.
제가 리플렉션을 통해서 만들려고 했던 기능과 정확히 일치하네요!!
Why use it?
The goal is to reduce the time and lines of code spent on manual data setup in unit tests, and potentially to uncover bugs that may have gone unnoticed with static test data.
사용 이유에 대해서는 다음과 같이 말합니다.
"목표는 단위 테스트에서 수동 데이터 설정에 소요되는 시간과 코드 줄을 줄이고 잠재적으로 정적 테스트 데이터에서 발견되지 않았을 수 있는 버그를 발견하는 것."
즉, 리플렉션을 통해서 동적으로 필드를 채워주고 필드에 삽입되는 값들이 랜덤하기 때문에 정적 테스트 객체에서는 발견되지 않았을 수 있는 버그까지 찾을 수 있다고 합니다. (Good...)
💡 Fixture 객체는 정적인 상태를 유지해야 하는 거 아닌가? 💡
Instancio를 보면서 도중에 이러한 의문이 들었고 제가 내린 결론은 다음과 같습니다.
우선, Test Fixture는 어떠한 테스트 메서드가 존재할 때 의도한 결과가 나오도록 사전에 정의한 정적 객체입니다. 그런데 테스트 객체의 값이 임의로 변경되면 테스트 메서드가 실행될 때마다 의도한 결과가 나온다는 보장을 할 수 없기 때문에 고정된
이라는 뜻의 Fixtrue
를 사용한 것 같습니다.
그런데, 항상 변하지 않는 정적인 값만을 사용하면 예상하지 못한 내용에 대해서는 테스트를 진행할 수 없게 됩니다. 우리는 우리가 작성한 코드를 100% 신뢰할 수 없습니다. 그렇기에 때에 따라서는 동적으로 값이 변하는 객체가 필요하게 됩니다.
이를 위해 Intancio는 내부적으로 타입에 해당하는 랜덤 값을 적절한 Seed
를 통해 생성하고 있는 것으로 보이는데, 이 때문에 정말 말도 안되는 터무니 없는 값이 생성되어 테스트가 깨지게 되는 경우는 거의 없는 것으로 보입니다. 이 Seed 역시 커스텀이 가능하며 랜덤한 값을 생성하는 Generator
역시 커스텀이 가능하기 때문에 이를 적절히 사용한다면 정말 좋은 테스트 라이브러리라고 생각합니다.
✔사용 방법
dependencies {
testImplementation 'org.instancio:instancio-junit:4.1.0'
}
Junit 환경에서는 위와 같이 의존성만 넣어주면 초기 설정은 끝입니다.
Instancio를 적용하면 아래와 같이 사용이 가능합니다.
public static BoardUpdateRequest generateBoardUpdateRequest(Long stackId, Long positionId) {
return Instancio.of(BoardUpdateRequest.class)
//필드 값에 접두사 추가
.generate(field(BoardUpdateRequest::title), gen -> gen.string().prefix("게시글 이름"))
//필드 값에 접두사 추가
.generate(field(BoardUpdateRequest::content), gen -> gen.string().prefix("게시글 내용"))
// 필드 값의 랜덤한 범위 지정
.generate(field(BoardUpdateRequest::participantLimit), gen -> gen.ints().range(2, 10))
// 랜덤한 미래의 LocalDate 범위 지정
.generate(field(BoardUpdateRequest::deadline), gen -> gen.temporal().localDate().future())
// 직접 생성한 값을 추가
.set(field(BoardUpdateRequest::stacks), List.of(stackId))
// 직접 생성한 값을 추가
.set(field(BoardUpdateRequest::positions), List.of(positionId))
.create();
}
Instancio의 경우 정말 많고 다양한 기능을 제공하기 때문에 이번 포스팅에서 다 소개해드리기는 어려울 것 같아 제가 사용해봤거나 사용해보려고 했던 주요 기능들에 대해서 소개해드리도록 하겠습니다.
우선 기본적인 객체 생성 방식입니다.
Person person = Instancio.create(Person.class);
Person personWithoutAgeAndAddress = Instancio.of(Person.class)
.ignore(field(Person::getAddress))
.create();
위와 같이 Instancio의 정적 메서드 create()
또는 of()
메서드를 호출하고 파라미터로는 클래스 데이터를 넘겨주면 끝입니다. 클래스 내부의 필드는 자동으로 필드 타입에 따라 랜덤한 값이 생성됩니다. 이를 통해 정적 테스트 데이터에서 발견하지 못한 버그를 찾을 수 있게 되는 것입니다.
of()
메서드를 사용하면 객체 내부에 생성되는 필드들의 값을 조작할 수 있습니다. 위 예시에서는 Person 객체의 Address 필드는 생성하지 않겠다는 의미로 ignore()
가 사용된 것입니다.
다음은 컬렉션 생성 방식입니다. 이곳까지 흘러오게 된 계기이죠!
// List 생성
List<Person> list = Instancio.ofList(Person.class)
.size(10)
.create();
// Map 생성
Map<UUID, Address> map = Instancio.ofMap(UUID.class, Address.class)
.size(3)
.set(field(Address::getCity), "Vancouver")
.create();
// Set 생성
Set<Person> set = Instancio.ofSet(personModel)
.size(5)
.create();
기본적인 객체 생성 방식과 마찬가지로 Instancio의 정적 메서드를 호출하여 정말 간단하게 컬렉션을 생성할 수 있습니다. Fixture 방식에서 큰 단점이라고 생각했던 다양한 상태를 가진 여러 객체들의 생성을 Instancio에서는 위와 같이 간단하게 생성이 가능하고, 컬렉션 내부의 객체는 모두 랜덤한 필드 값을 지니게 되기 때문에 Set 또는 Map 컬렉션에 담더라도 중복으로 인한 제거도 이루어지지 않습니다.
세 번째 라인에서 Map 생성 방식을 보면 set()
키워드가 사용된 것을 볼 수 있는데, 위에서 사용한 ignore()
와 마찬가지로 of()
메서드를 통해 객체를 생성할 때 직접 필드에 값을 넣어주고 싶은 경우 사용할 수 있습니다.
다음은 객체 생성 템플릿 사용법입니다.
// 공유할 모델
Model<Person> simpsonsModel = Instancio.of(Person.class)
.ignore(field(Person::getId))
.set(field(Person::getLastName), "Simpson")
.set(field(Address::getCity), "Springfield")
.generate(field(Person::getAge), gen -> gen.ints().range(40, 50))
.toModel();
// 모델 활용
Person homer = Instancio.of(simpsonsModel)
.set(field(Person::getFirstName), "Homer")
.create();
Person marge = Instancio.of(simpsonsModel)
.set(field(Person::getFirstName), "Marge")
.create();
Instancio에서는 Model<?>
타입을 사용하여 기존에 Instancio를 통해 생성했던 객체 생성 방식을 템플릿처럼 저장하여 사용할 수 있습니다. 이를 통해 코드의 재사용성을 높여 보다 효율적인 테스트 객체를 생성할 수 있습니다.
또한, 위 예시에서 다섯 번째 코드라인을 보면 generate()
가 사용된 것을 볼 수 있는데 해당 메서드는 Instancio가 제공하는 임의의 랜덤 값이 아닌 커스텀한 문자열, 숫자, 날짜, 배열, 컬렉션 등을 생성할 수 있도록 도와줍니다.
이외에도 JPA 를 위한 Instancio-JPA, 순환 참조 객체 생성, Custom Generator를 생성하여 지정한 Random 범위 내의 값으로 객체 생성, Bean Validtion이 적용된 객체 생성 등 정말 다양한 기능을 제공하고 있습니다. 공식 문서의 설명도 상당히 자세한 편이기 때문에 러닝 커브도 그렇게 높지 않아 사용하기 좋은 라이브러리라고 생각되됩니다!
🟩Fixture Monkey (공식 문서 https://naver.github.io/fixture-monkey/)
다음은 픽스쳐 몽키에 대해서 알아보도록 하겠습니다.

픽스쳐 몽키에서 강조하는 세 가지 특징은 다음과 같습니다.
1. 간편함
손쉽게 한 줄의 코드로 테스트 객체를 생성할 수 있습니다. 복잡한 테스트 객체를 빌더 패턴
을 사용하여 생성할 수 있습니다.
2. 재사용성
복잡한 사양을 한 번 정의해두면 재사용 할 수 있습니다. 인스턴스의 설정은 다양한 테스트에서 재사용 될 수 있습니다.
3. 무작위성
픽스쳐 몽키를 통해 생성한 테스트 객체를 통해 테스트를 보다 다이나믹하고 랜덤하게 만들 수 있습니다. 정적 데이터를 사용할 때 발견하지 못한 엣지 케이스를 커버할 수 있습니다.
요약하면 픽스쳐 몽키 역시 동적
으로 변하는 테스트 객체를 만들 수 있도록 하며 재사용성을 높이고 랜덤한 값을 채워 넣는 방식을 통해 사전에 발견하지 못한 엣지 케이스를 발견할 수 있게 해줍니다. 전체적으로는 인스턴시오와 매우 유사한 특징을 가지고 있다는 것을 알 수 있습니다.
✔사용 방법
testImplementation("com.navercorp.fixturemonkey:fixture-monkey-starter:1.0.0")
우선, 위의 의존성을 추가해줍니다.
픽스쳐 몽키는 인스턴시오와는 다르게 픽스쳐 몽키 객체를 생성해서 사용해야 합니다.
FixtureMonkey fixtureMonkey = FixtrueMonkey.create();
Person person = fixtureMonkey.giveMeOne(Person.class);
픽스쳐 몽키를 좀 더 잘 활용하기 위해서는 기능 설명에 앞서 Introspector
라는 개념에 대해서 알아볼 필요가 있을 것 같아 해당 부분부터 소개를 해드리도록 하겠습니다. Introspector는 Fixture Monkey가 객체를 생성하는 방식을 정의합니다. 어떤 Introspector를 적용하냐에 따라 Fixture Monkey의 객체 생성 방식이 달라지게 됩니다. 공식 문서에 따르면 Introspector에는 다섯 가지 종류가 존재합니다.
1. BeanArbitraryIntrospector
기본 설정에 해당하는 Introspector 입니다. 이름에서 알 수 있듯이 자바 빈즈 규약에 따라 객체를 생성해주는 Introspector 입니다. 따라서 클래스에는 인수가 없는 생성자와 setter가 있어야 합니다. 리플렉션과 setter 메서드를 사용하여 새 인스턴스를 생성하며 기본 설정이기 때문에 FixtureMonkey.create()
는 위 방식을 사용합니다.
2. ConstructorPropertiesArbitraryIntrospector
FixtureMonkey fixtureMonkey = FixtureMonkey.builder()
.objectIntrospector(ConstructorPropertiesArbitraryIntrospector.INSTANCE)
.build();
지정된 생성자를 사용하는 Introspector 입니다. 객체 생성에 사용하고 싶은 생성자에 @ConstructorProperties
를 선언하거나 롬복 설정에 lombok.anyConstructor.addConstructorProperties=true
를 추가해주면 적용할 수 있습니다.
3. FieldReflectionArbitraryIntrospector
FixtureMonkey fixtureMonkey = FixtureMonkey.builder()
.objectIntrospector(FieldReflectionArbitraryIntrospector.INSTANCE)
.build();
필드 리플렉션을 사용하는 Introsepctor 입니다. 따라서 생성할 클래스에는 인수가 없는 생성자와 getter 또는 setter 중 하나가 있어야 합니다. 리플렉션을 사용하기 때문에 final
, transient
키워드가 붙은 필드에는 적용할 수 없습니다. JPA를 사용하고 있다면 엔티티 클래스에는 final 키워드를 사용할 수 없기 때문에 엔티티 클래스를 Fixture 객체로 생성할 때 적용하면 좋을 것 같습니다.
4. BuilderArbitraryIntrospector
롬복의 @Builder
를 기반으로 하는 Introspector 입니다. 당연히 빌더 패턴이 적용된 클래스만 대상이 될 수 있습니다.
FixtureMonkey fixtureMonkey = FixtureMonkey.builder()
.objectIntrospector(BuilderArbitraryIntrospector.INSTANCE)
.build();
5. FailoverArbitraryIntrospector
FixtureMonkey sut = FixtureMonkey.builder()
.objectIntrospector(new FailoverIntrospector(
Arrays.asList(
FieldReflectionArbitraryIntrospector.INSTANCE,
ConstructorPropertiesArbitraryIntrospector.INSTANCE
)
))
.build();
때로는 프로덕션 코드에 구성이 다른 여러 클래스가 포함되어 단일 Introspector 로 모든 객체를 생성하기 어려울 수 있습니다. 이 경우 FailoverArbitraryIntrospector
를 사용할 수 있습니다. Introspector를 리스트로 담아서 사용할 수 있으며, 생성 과정에서 실패한다면 리스트내에 다른 Introspector를 사용하여 객체를 생성합니다.
다음으로는 활용 방법입니다.
FixtureMonkey fixtureMonkey = FixtureMonkey.create();
// 단일 객체 생성
Person person = fixtureMonkey.giveMeOne(Person.class);
// 복수 객체 생성
List<Person> personList = fixtureMonkey.giveMe(Person.class, 10);
위와 같이 픽스쳐 몽키 객체를 생성한 후 giveMeOne()
메서드 또는 giveMe()
메서드를 통해 간편하게 객체 또는 컬렉션을 생성할 수 있습니다.
Person person = fixtureMonkey.giveMeBuilder(Person.class)
.set("name", Arbitraries.strings().withCharRange('a', 'z').ofMaxLength(10))
.set("age", Arbitraries.integers().between(5, 10))
.set("gener", Arbitraries.of(Gender.MALE, Gender.FEMALE))
.sample();
// 위에서 만들어진 객체 재활용
Person withMyName = fixtureMonkey.giveMeBuilder(person)
.set("name", "나그네")
.sample();
객체 생성 과정에서의 커스터마이징이 필요한 경우 위와 같이 giveMeBuilder()
를 사용하여 위와 같이 사용할 수 있으며, Arbitraries
를 사용하여 랜덤하게 생성하고 싶은 값들의 범위나 형식을 커스터마이징 할 수 있습니다.
또한, 기존에 만들어 둔 객체를 인자로 넘겨주기만 하면 템플릿처럼 사용하여 재사용할 수도 있으며 이외에도 Bean Validation 적용하기, 커스텀 Builder 만들기, Jackson 어노테이션을 이용한 객체 생성하기, 객체 내부의 인터페이스 또는 추상 클래스에 MockBean 주입하기 등 다양한 기능을 사용할 수 있기 때문에 Fixture Monkey 역시 뛰어난 라이브러리라고 할 수 있겠습니다.
🟦정리
지금까지 인스턴시오와 픽스쳐 몽키에 대해서 알아보았는데요. 전반적으로 유사한 점이 많기 때문에 사용을 한다면 어떤 라이브러리를 사용해야 할까에 대한 고민이 생기게 되어서 다음과 같이 정리하였습니다.
1. 러닝 커브
저는 인스턴시오의 공식 문서가 보다 잘 정리되어 있다는 느낌이 들었습니다. 거의 모든 기능들에 대한 설명이 User Guide에 존재하기 때문에 내가 원하는 기능을 검색하고 가이드에 나온 내용을 토대로 프로젝트에 적용해보는 것이 가능했습니다. 반면에, 픽스쳐 몽키의 경우 설명이 생략된 부분들이 존재하기 때문에 직접 사용해보며 익혀야 되는 부분들이 인스턴시오 보다는 많다는 느낌이 들었습니다. 따라서 러닝 커브 면에서는 인스턴시오가 유리하다고 할 수 있겠습니다.
2. 객체 생성 방식의 다양성
픽스쳐 몽키의 경우 위에서 설명드렸듯이 여러가지 Inspector를 토대로 다양한 생성 방식을 지정할 수 있습니다. 생성 방식의 다양성은 유연한 프로그래밍이 가능하며 기존 코드의 활용 범위가 넓어지기 때문에 이러한 부분이 큰 장점이 된다고 생각합니다. 이에 반해 인스턴시오의 경우 리플렉션을 통한 생성만을 지원하기 때문에 활용성과 성능적인 부분에서 픽스쳐 몽키가 좀 더 우세하다고 볼 수 있을 것 같습니다.
3. 코틀린 지원
최근 Kotlin을 이용한 개발이 늘어남에 따라 Kotlin 지원 여부도 중요한 요소 중 하나라고 생각됩니다. 인스턴시오의 경우 Java만을 지원하고 있으며, 픽스쳐 몽키의 경우 Java 와 Kotlin 모두 지원하고 있습니다. 만약 Kotlin 을 사용하고 있으며 Kotest가 아닌 JUnit 환경의 프로젝트라면 결국 선택지는 픽스쳐 몽키밖에 없을 것 같습니다.
위 정리를 토대로 저는 인스턴시오를 사용하게 되었는데요. 적용 당시에 팀 프로젝트를 진행 중이었고 정해진 기한 내에 라이브러리를 이해하고 프로젝트에 적용을 해야했기 때문에 비교적 러닝 커브가 낮은 인스턴시오에 대한 간단한 소개와 설명을 진행한 후 프로젝트에 적용 하기를 제안하였고 팀원들도 금새 익혀 어렵지 않게 프로젝트에 바로 적용할 수 있었습니다!!👍
여러분들도 이를 참고하여 각자의 프로젝트 환경에 맞는 라이브러리를 선택하여 적용해보시면 좋을 것 같습니다. 긴 글 읽어주셔서 감사합니다!
- 위 내용에 대한 오류 지적과 피드백은 언제나 환영입니다! -
'Spring' 카테고리의 다른 글
이벤트를 좀 더 제대로 다뤄보자 (feat. MSA & EDA) (2) | 2024.04.22 |
---|---|
동시성 테스트를 통한 성능 개선하기 (2) - Spring Event (2) | 2024.04.04 |
조회 성능 최적화(3) - QueryDsl : SQL (0) | 2024.03.16 |
조회 성능 최적화 (1) - 쿼리 발생 줄이기 (1) | 2024.02.13 |