Instancio 시리즈

1. 테스트 객체, 직접 만들지 마라 (현재 편)

2. 셀렉터와 커스터마이징

3. 컬렉션과 Model

4. 고급 기능

5. 실전 활용과 면접 대비

테스트를 작성하다 보면 객체를 세팅하는 코드가 테스트 로직보다 길어지는 순간이 온다. new Person()에 setter를 열 줄 넘게 호출하고 있다면, 그건 테스트가 아니라 데이터 입력 작업이다. Instancio는 이 보일러플레이트를 한 줄로 줄여준다.

Instancio가 해결하는 문제

테스트 코드에서 흔히 볼 수 있는 패턴이다.

@Test
void shouldCalculateDiscount() {
    Person person = new Person();
    person.setName("홍길동");
    person.setAge(30);
    person.setEmail("hong@test.com");

    Address address = new Address();
    address.setCity("서울");
    address.setStreet("강남대로");
    address.setZipCode("06000");
    person.setAddress(address);

    Phone phone = new Phone();
    phone.setCountryCode("+82");
    phone.setNumber("010-1234-5678");
    person.setPhones(List.of(phone));

    // 실제 테스트 로직은 여기서부터
    double discount = discountService.calculate(person);
    assertThat(discount).isGreaterThan(0);
}

이 테스트에서 정말 중요한 값은 무엇인가? discountService.calculate()가 제대로 동작하는지 확인하는 것이지, 이름이 "홍길동"인지 "김철수"인지는 상관없다. 대부분의 단위 테스트는 값 자체가 아니라 값의 존재만 필요로 한다.

그런데 직접 세팅하면 세 가지 문제가 생긴다.

  • 보일러플레이트 폭발 : 필드가 추가될 때마다 모든 테스트의 세팅 코드를 수정해야 한다
  • 테스트 범위 축소 : 항상 같은 고정값으로만 테스트하면, 특정 입력에서만 발생하는 버그를 놓친다
  • 의도 파악 어려움 : 세팅 코드가 길어지면 "이 테스트가 뭘 검증하는 건지" 한눈에 안 보인다

Instancio란

Instancio는 테스트용 객체를 자동으로 생성해주는 Java 라이브러리다. 한 줄이면 모든 필드가 랜덤 값으로 채워진 객체가 만들어진다.

Person person = Instancio.create(Person.class);

이게 전부다. name, age, email, 중첩된 Address, Phone까지 전부 랜덤 값으로 채워진 완전한 객체가 생성된다.

핵심 설계 철학은 다음과 같다.

  • Zero Configuration : 프로덕션 코드를 건드릴 필요 없다. 어노테이션도, 인터페이스 구현도 불필요하다
  • 랜덤 기반 테스트 : 매번 다른 값으로 테스트하기 때문에, 고정값으로는 잡지 못하는 엣지 케이스를 발견할 수 있다
  • Seed 재현성 : 랜덤이지만 실패한 테스트를 정확히 재현할 수 있다. 이건 뒤에서 자세히 다룬다

의존성 설정

Gradle

testImplementation 'org.instancio:instancio-junit:5.5.1'

Maven

<dependency>
    <groupId>org.instancio</groupId>
    <artifactId>instancio-junit</artifactId>
    <version>5.5.1</version>
    <scope>test</scope>
</dependency>

instancio-junit 모듈은 instancio-core를 포함하고 있어서, 별도로 core를 추가할 필요 없다. Java 17 이상이 필요하다.

모듈 선택 기준

- JUnit 5 사용 → instancio-junit (대부분 이걸 쓴다)

- JUnit 4 / TestNG / 프레임워크 없이 사용 → instancio-core

JUnit 5 통합

InstancioExtension을 등록하면 Instancio의 JUnit 5 기능을 활용할 수 있다.

@ExtendWith(InstancioExtension.class)
class PersonServiceTest {

    @Test
    void shouldCreatePerson() {
        Person person = Instancio.create(Person.class);
        assertThat(person.getName()).isNotNull();
        assertThat(person.getAge()).isNotNull();
    }
}

이 Extension이 해주는 일은 크게 두 가지다.

  • Seed 관리 : 각 테스트마다 고유한 seed를 할당하고, 실패 시 seed를 로그에 출력한다
  • 어노테이션 처리 : @WithSettings, @Given, @InstancioSource 같은 어노테이션을 인식한다
Extension 없이도 동작은 한다

Instancio.create()는 Extension 없이도 호출할 수 있다. 하지만 seed 기반 재현성, 어노테이션 지원 등 핵심 기능을 쓰려면 Extension 등록이 필수다.

기본 객체 생성

create() — 기본값으로 바로 생성

가장 단순한 형태다. 클래스를 넘기면 모든 필드가 랜덤으로 채워진 인스턴스를 돌려준다.

Person person = Instancio.create(Person.class);

String 필드에는 랜덤 문자열, int 필드에는 랜덤 정수, 중첩 객체도 재귀적으로 생성한다. null이 아닌 완전히 채워진 객체가 만들어진다는 점이 핵심이다.

of() — 커스터마이징 적용

특정 필드만 원하는 값으로 지정하고 싶다면 of()로 Builder API를 사용한다.

Person person = Instancio.of(Person.class)
    .set(field(Person::getName), "홍길동")
    .create();

of()는 Builder를 반환하고, 마지막에 create()를 호출해야 실제 객체가 생성된다. set(), generate(), ignore() 같은 커스터마이징 메서드는 다음 편에서 자세히 다룬다.

제네릭 타입 생성

제네릭 타입은 TypeToken을 사용한다. Java의 타입 소거 때문에 Pair<String, Long>.class 같은 표현이 불가능하기 때문이다.

Pair<String, Long> pair = Instancio.create(new TypeToken<Pair<String, Long>>() {});

TypeToken은 익명 클래스로 생성해야 한다. 뒤의 {}를 빠뜨리면 컴파일 에러가 난다.

Seed와 재현성

"랜덤이면 테스트가 매번 다른 결과를 내는 거 아닌가?" 당연한 의문이다. Instancio는 이걸 Seed 기반 재현성으로 해결한다.

모든 랜덤 값은 seed라는 숫자 하나로부터 파생된다. 같은 seed를 쓰면 항상 같은 객체가 만들어진다. 테스트가 실패하면 Instancio는 해당 seed를 로그에 출력한다.

Instancio test seed: 12345 (set 'instancio.seed' system property to reproduce)

이 seed를 지정하면 실패 상황을 정확히 재현할 수 있다.

@Test
@Seed(12345)
void reproducibleTest() {
    Person person = Instancio.create(Person.class);
    // seed 12345로 생성된 동일한 객체
}

API에서 직접 지정하는 방법도 있다.

Person person = Instancio.of(Person.class)
    .withSeed(12345)
    .create();
Seed 고정은 디버깅용이다

@Seed를 걸어두면 매번 같은 값으로 테스트하게 되어 랜덤 테스트의 장점이 사라진다. 실패를 재현하고 원인을 파악한 뒤에는 반드시 제거하자.

verbose()로 디버깅하기

생성된 객체가 예상과 다를 때, verbose()를 붙이면 Instancio가 내부에서 어떤 일을 했는지 상세하게 출력한다.

Person person = Instancio.of(Person.class)
    .set(field(Person::getName), "홍길동")
    .verbose()
    .create();

출력에는 다음 정보가 포함된다.

  • 셀렉터가 어떤 노드에 매칭되었는지
  • 각 필드에 어떤 값이 할당되었는지
  • 사용된 seed 값

"내가 지정한 셀렉터가 왜 안 먹히지?" 싶을 때 가장 먼저 시도할 방법이다.

자주 하는 실수

Extension 없이 @Seed를 사용

@ExtendWith(InstancioExtension.class) 없이 @Seed 어노테이션을 붙이면 아무 효과가 없다. 에러도 나지 않아서 "seed를 고정했는데 왜 값이 바뀌지?"라는 혼란에 빠지게 된다. Instancio 어노테이션을 사용하려면 반드시 Extension을 등록하자.

[!DANGER] TypeToken의 중괄호 누락

Instancio.create(new TypeToken<List<String>>()) — 이렇게 쓰면 컴파일 에러가 난다. TypeToken은 추상 클래스이기 때문에 반드시 new TypeToken<List<String>>() {}처럼 익명 클래스의 {}를 붙여야 한다.

[!DANGER] Seed를 프로덕션 테스트에 고정해두기

디버깅이 끝나면 @Seed를 제거해야 한다. 고정된 seed는 "랜덤 값으로 다양한 케이스를 커버한다"는 Instancio의 핵심 가치를 무력화시킨다. CI에서 매번 같은 값으로만 돌아가는 테스트는 수동 세팅과 다를 바 없다.