Instancio (5/5)

이전 편: [Instancio] 4. 고급 기능

이 문서가 시리즈의 마지막 편입니다.

Instancio 시리즈

1. 테스트 객체, 직접 만들지 마라

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

3. 컬렉션과 Model

4. 고급 기능

5. 실전 활용과 면접 대비 (현재 편)

마지막 편에서는 Instancio를 실제 프로젝트에 도입할 때 알아야 할 실전 기능들을 다룬다. 커스텀 Generator로 도메인 특화 객체를 생성하고, Settings로 프로젝트 전역 설정을 관리하고, Bean Validation과 연동하여 유효한 데이터를 자동 생성하는 방법까지 살펴본다.

커스텀 Generator

내장 생성기로 표현할 수 없는 도메인 객체를 만들어야 할 때, Generator<T> 인터페이스를 직접 구현한다.

Generator 인터페이스

@FunctionalInterface
public interface Generator<T> {
    T generate(Random random);

    default Hints hints() {
        return null;
    }
}

generate() 메서드에서 객체를 만들어 반환한다. 파라미터로 받는 Random은 Instancio가 관리하는 인스턴스이므로, 이걸 써야 seed 기반 재현성이 보장된다.

public class KoreanPhoneGenerator implements Generator<Phone> {

    @Override
    public Phone generate(Random random) {
        String[] prefixes = {"010", "011", "016"};
        String prefix = random.oneOf(prefixes);
        int middle = random.intRange(1000, 9999);
        int last = random.intRange(1000, 9999);

        return new Phone("+82", prefix + "-" + middle + "-" + last);
    }
}

사용할 때는 supply()에 인스턴스를 넘긴다.

Person person = Instancio.of(Person.class)
    .supply(all(Phone.class), new KoreanPhoneGenerator())
    .create();

AfterGenerate — 생성 후 처리 방식

커스텀 Generator가 반환한 객체에 대해 Instancio가 추가로 무엇을 할지 결정하는 옵션이다. hints() 메서드로 지정한다.

@Override
public Hints hints() {
    return Hints.afterGenerate(AfterGenerate.POPULATE_NULLS);
}
  • APPLY_SELECTORS : 셀렉터로 지정한 필드만 덮어쓴다. 나머지는 Generator가 반환한 그대로 유지
  • POPULATE_NULLS : null인 필드를 랜덤 값으로 채운다 + 셀렉터도 적용
  • POPULATE_NULLS_AND_DEFAULT_PRIMITIVES : null 필드 + 기본값 primitive까지 채운다

이걸 활용하면 Generator가 핵심 필드만 세팅하고, 나머지는 Instancio에게 맡기는 전략을 쓸 수 있다. Generator에서 모든 필드를 일일이 세팅할 필요가 없어진다.

Random은 반드시 파라미터로 받은 것을 사용

new java.util.Random()이나 ThreadLocalRandom을 쓰면 seed 기반 재현성이 깨진다. Generator 안에서 랜덤 값이 필요하면 반드시 generate(Random random) 파라미터의 random을 사용하자.

Settings API

Settings는 Instancio의 전역 동작을 제어하는 설정 객체다. 문자열 최소 길이, 컬렉션 기본 크기, null 허용 여부 같은 프로젝트 전체에 공통으로 적용되는 규칙을 한 곳에서 관리한다.

코드에서 설정

Settings settings = Settings.create()
    .set(Keys.STRING_MIN_LENGTH, 5)
    .set(Keys.STRING_MAX_LENGTH, 20)
    .set(Keys.COLLECTION_MIN_SIZE, 1)
    .set(Keys.COLLECTION_MAX_SIZE, 5)
    .set(Keys.STRING_NULLABLE, false)
    .set(Keys.MODE, Mode.LENIENT);

Person person = Instancio.of(Person.class)
    .withSettings(settings)
    .create();

주요 Keys

문자열 관련

  • Keys.STRING_MIN_LENGTH — 기본 최소 길이 (기본값: 3)
  • Keys.STRING_MAX_LENGTH — 기본 최대 길이 (기본값: 10)
  • Keys.STRING_NULLABLE — null 허용 여부 (기본값: false)
  • Keys.STRING_ALLOW_EMPTY — 빈 문자열 허용 여부 (기본값: false)

컬렉션 관련

  • Keys.COLLECTION_MIN_SIZE — 기본 최소 크기 (기본값: 2)
  • Keys.COLLECTION_MAX_SIZE — 기본 최대 크기 (기본값: 6)
  • Keys.COLLECTION_ELEMENTS_NULLABLE — 요소 null 허용 (기본값: false)
  • Keys.MAP_KEYS_NULLABLE — Map 키 null 허용 (기본값: false)
  • Keys.MAP_VALUES_NULLABLE — Map 값 null 허용 (기본값: false)

동작 관련

  • Keys.MODE — Strict / Lenient 모드 (기본값: STRICT)
  • Keys.MAX_DEPTH — 객체 그래프 최대 깊이 (기본값: 8)
  • Keys.SEED — 전역 seed 고정

instancio.properties 파일

클래스패스 루트에 instancio.properties 파일을 두면 전역 설정으로 적용된다. 테스트 리소스 디렉토리인 src/test/resources/에 놓으면 된다.

string.min.length=5
string.max.length=20
collection.min.size=1
collection.max.size=5
mode=LENIENT
설정 우선순위

같은 키를 여러 곳에서 지정하면 다음 순서로 적용된다.

1. API 호출 (withSettings())

2. instancio.properties 파일

3. Instancio 내장 기본값

API 호출이 가장 높은 우선순위를 가진다. 즉 "전역은 properties 파일로, 개별 테스트는 API로" 관리하는 것이 일반적이다.

@WithSettings — JUnit 5에서 설정 주입

@WithSettings로 테스트 클래스나 메서드에 Settings를 주입할 수 있다.

@ExtendWith(InstancioExtension.class)
class OrderTest {

    @WithSettings
    private final Settings settings = Settings.create()
        .set(Keys.STRING_MIN_LENGTH, 5)
        .set(Keys.COLLECTION_MIN_SIZE, 1);

    @Test
    void shouldCreateOrder() {
        Order order = Instancio.create(Order.class);
        // 이 테스트 안의 모든 Instancio 호출에 settings 적용
    }
}

클래스 레벨 필드로 선언하면 해당 클래스의 모든 테스트에 적용된다.

Bean Validation 연동

Instancio는 Jakarta Bean Validation 어노테이션을 인식한다. 엔티티에 @NotNull, @Size, @Min, @Max, @Email, @Pattern 등이 붙어 있으면, 해당 제약 조건을 만족하는 값을 자동으로 생성한다.

public class User {
    @NotNull
    private String name;

    @Min(18)
    @Max(120)
    private int age;

    @Email
    private String email;

    @Size(min = 8, max = 20)
    private String password;

    @Pattern(regexp = "^\\d{3}-\\d{4}-\\d{4}$")
    private String phone;
}

이 클래스로 Instancio.create(User.class)를 호출하면 다음과 같은 결과가 나온다.

  • name → null이 아닌 랜덤 문자열
  • age → 18 이상 120 이하의 정수
  • email → 유효한 이메일 형식
  • password → 8자 이상 20자 이하의 문자열
  • phone\d{3}-\d{4}-\d{4} 패턴에 맞는 문자열

별도 설정 없이 어노테이션만으로 동작한다. Instancio가 클래스패스에서 Jakarta Validation API를 감지하면 자동으로 활성화된다.

수동으로 비활성화하려면

```java

Settings settings = Settings.create()

.set(Keys.BEAN_VALIDATION_ENABLED, false);

```

Bean Validation 연동을 끄면 어노테이션을 무시하고 순수 랜덤 값을 생성한다.

@InstancioSource — 파라미터화 테스트

JUnit 5의 @ParameterizedTest와 결합하여, 여러 랜덤 데이터 세트로 테스트를 반복 실행할 수 있다.

@ExtendWith(InstancioExtension.class)
class PersonServiceTest {

    @ParameterizedTest
    @InstancioSource
    void shouldValidatePerson(Person person) {
        boolean result = personService.validate(person);
        assertThat(result).isTrue();
    }
}

기본적으로 JUnit의 @RepeatedTest와 비슷하지만, 매번 다른 랜덤 데이터로 실행된다는 점이 다르다.

여러 파라미터도 지원한다.

@ParameterizedTest
@InstancioSource
void shouldProcessOrder(Person person, Order order, Address address) {
    // person, order, address 모두 랜덤으로 생성
}
반복 횟수 조절

@InstancioSource의 기본 반복 횟수는 JUnit의 기본값에 따른다. Settings나 어노테이션 속성으로 조절할 수 있다.

@Given — 테스트 파라미터 주입

@Given은 테스트 메서드의 파라미터에 Instancio가 생성한 객체를 주입한다.

@ExtendWith(InstancioExtension.class)
class UserServiceTest {

    @Test
    void shouldCreateUser(@Given User user) {
        UserResponse response = userService.create(user);
        assertThat(response).isNotNull();
        assertThat(response.getName()).isEqualTo(user.getName());
    }
}

@ParameterizedTest 없이 일반 @Test에서도 사용할 수 있다. 테스트 본문에서 Instancio.create()를 호출하는 것과 결과는 같지만, 파라미터로 받으면 테스트의 의도가 더 명확해진다.

Record와 Sealed Class 지원

Instancio는 Java 16+ Record와 Java 17+ Sealed Class를 별도 설정 없이 지원한다.

public record PersonRecord(String name, int age, String email) {}

PersonRecord person = Instancio.create(PersonRecord.class);
// name, age, email 모두 랜덤 값으로 채워진 Record

Record는 생성자를 통해 값이 주입되므로 모든 커스터마이징 API가 동일하게 동작한다.

PersonRecord person = Instancio.of(PersonRecord.class)
    .set(field(PersonRecord::name), "홍길동")
    .generate(field(PersonRecord::age), gen -> gen.ints().range(18, 65))
    .create();

Sealed Class의 경우, subtype()으로 어떤 구현체를 사용할지 지정한다.

public sealed interface Shape permits Circle, Rectangle {}

Shape shape = Instancio.of(Shape.class)
    .subtype(all(Shape.class), Circle.class)
    .create();

면접 Q&A

"Instancio의 랜덤 테스트는 비결정적이라 CI에서 불안정하지 않나요?"

그렇지 않다. Instancio는 seed 기반으로 동작한다. 같은 seed면 항상 같은 데이터가 생성되므로, 실패한 테스트를 @Seed로 정확히 재현할 수 있다. CI에서 불안정한 것이 아니라, 고정값으로는 발견하지 못했을 버그를 CI에서 먼저 발견하는 것이다. 실패가 발생하면 seed를 기록해두고, 해당 seed로 디버깅한 뒤 근본 원인을 수정하면 된다.

[!QUESTION] "테스트마다 데이터가 달라지면, 실패 원인을 어떻게 파악하나요?"

Instancio는 테스트 실패 시 seed를 로그에 출력한다. 그 seed를 @Seed 어노테이션에 넣으면 실패 당시와 동일한 데이터를 재현할 수 있다. verbose()를 함께 사용하면 어떤 값이 생성되었는지 상세 로그도 확인 가능하다. 핵심은 "랜덤이지만 재현 가능하다"는 것이다.

[!QUESTION] "Instancio와 테스트 Fixture를 직접 만드는 것, 언제 어떤 걸 써야 하나요?"

테스트에서 값 자체가 중요한 경우(예: 특정 금액에서 할인이 적용되는지 검증)는 직접 Fixture를 만드는 것이 낫다. 테스트의 의도가 코드에 명시적으로 드러나기 때문이다. 반면 값의 존재만 필요하고 구체적인 값은 상관없는 경우(예: 서비스 레이어의 CRUD 흐름 테스트)에는 Instancio가 적합하다. 실무에서는 둘을 혼용하는 것이 일반적이다.

[!QUESTION] (함정) "Instancio로 생성한 객체는 Bean Validation을 항상 통과하나요?"

기본적으로는 그렇지 않다. Bean Validation 연동은 클래스패스에 Jakarta Validation API가 있을 때 자동으로 활성화되지만, 이건 어노테이션이 붙은 필드에만 적용된다. 어노테이션이 없는 필드는 그냥 랜덤 값이 들어가므로, 비즈니스 로직 수준의 유효성(예: "시작일이 종료일보다 앞이어야 한다")은 보장하지 않는다. 필드 간 관계가 있는 유효성은 assign()이나 onComplete()으로 직접 처리해야 한다.

[!QUESTION] (함정) "Model은 불변인가요? 한 번 만든 Model의 설정을 나중에 바꿀 수 있나요?"

Model은 불변이다. toModel()로 생성된 이후에는 내부 설정을 변경할 수 없다. Instancio.of(model).set(...).create()는 Model 자체를 수정하는 것이 아니라, Model의 설정을 복사한 새로운 빌더를 만드는 것이다. 원본 Model은 그대로 유지된다. 이 불변성 덕분에 여러 테스트에서 동일한 Model을 안전하게 공유할 수 있다.

자주 하는 실수

커스텀 Generator에서 java.util.Random 사용

new Random(), Math.random(), ThreadLocalRandom을 쓰면 seed 재현성이 깨진다. Generator의 generate(Random random) 파라미터로 받은 random 인스턴스만 사용해야 한다.

[!DANGER] instancio.properties 위치 실수

src/main/resources/에 넣으면 프로덕션 코드에 포함된다. 반드시 src/test/resources/에 놓아야 테스트 실행 시에만 적용된다.

[!DANGER] Bean Validation 어노테이션을 과신

@Min(18)이 있다고 해서 모든 비즈니스 규칙이 충족되는 것은 아니다. 필드 간 관계(시작일 < 종료일, 총액 == 단가 × 수량)는 Bean Validation이 아닌 assign()이나 onComplete()로 별도 처리해야 한다.