1편에서 Instancio.create()로 객체를 통째로 생성하는 법을 배웠다. 하지만 실제 테스트에서는 "나이는 18세 이상이어야 한다", "이메일은 특정 형식이어야 한다"처럼 특정 필드에 조건을 걸어야 하는 경우가 대부분이다. 이때 셀렉터와 커스터마이징 API가 필요하다.
셀렉터란
셀렉터는 객체 그래프에서 어떤 필드나 타입을 지정할지 결정하는 도구다. CSS에서 .class나 #id로 요소를 선택하듯, Instancio의 셀렉터는 필드 이름, 타입, 어노테이션 등으로 대상을 지정한다.
Person person = Instancio.of(Person.class)
.set(field(Person::getName), "홍길동") // 필드 셀렉터
.set(all(LocalDateTime.class), LocalDateTime.now()) // 타입 셀렉터
.create();
set()의 첫 번째 인자가 셀렉터, 두 번째가 값이다. 셀렉터 종류에 따라 하나의 필드를 정확히 찍을 수도 있고, 특정 타입의 모든 필드를 한꺼번에 지정할 수도 있다.
셀렉터 종류
field() — 특정 필드 지정
가장 많이 쓰는 셀렉터다. 메서드 레퍼런스로 필드를 지정한다.
field(Person::getName)
field(Person::getAge)
getter가 없거나 필드명과 메서드명이 다른 경우, 클래스와 필드명 문자열로 지정할 수도 있다.
field(Person.class, "name")
field(Phone.class, "countryCode")
문자열 기반 셀렉터는 필드명이 바뀌면 컴파일 타임에 잡히지 않는다. 리팩토링 안전성을 위해 메서드 레퍼런스 방식을 기본으로 쓰고, 불가능한 경우에만 문자열 방식을 사용하자.
all() — 타입 전체 지정
특정 타입의 모든 필드를 한 번에 지정한다. 객체 그래프 어디에 있든 해당 타입이면 전부 적용된다.
all(String.class) // 모든 String 필드
all(LocalDateTime.class) // 모든 LocalDateTime 필드
all(Address.class) // 모든 Address 타입 객체
Person 안에 homeAddress와 workAddress가 둘 다 Address 타입이면, all(Address.class)는 두 곳 모두에 적용된다.
편의 셀렉터
자주 쓰는 타입에는 축약 메서드가 있다.
allStrings()→all(String.class)allInts()→all(int.class)+all(Integer.class)allLongs()→all(long.class)+all(Long.class)allDoubles()→all(double.class)+all(Double.class)allBooleans()→all(boolean.class)+all(Boolean.class)
allInts()는 primitive int와 wrapper Integer를 동시에 잡아준다. 이걸 all(int.class)로만 쓰면 Integer 필드는 빠진다.
Predicate 셀렉터 — 조건 기반 지정
필드 이름 패턴이나 타입 조건으로 여러 필드를 한꺼번에 잡을 때 쓴다.
// 필드명에 "date"가 포함된 모든 필드
fields(f -> f.getName().contains("date"))
// Collection을 구현한 모든 타입
types(t -> Collection.class.isAssignableFrom(t))
빌더 패턴도 지원한다.
// Long 타입이면서 @Id 어노테이션이 붙은 필드
fields().ofType(Long.class).annotated(Id.class)
Predicate 셀렉터는 일반 셀렉터보다 우선순위가 낮다. 같은 필드에 field(Person::getName)과 fields(f -> ...)가 동시에 걸리면, field() 쪽이 이긴다. 이건 의도된 설계로, "구체적인 지정이 느슨한 지정보다 우선"이라는 원칙이다.
스코프와 깊이
스코프 — 같은 타입을 구분
Person에 homeAddress와 workAddress가 둘 다 Address 타입이라면, field(Address::getCity)만으로는 어느 주소의 city인지 구분할 수 없다. 이때 스코프를 사용한다.
Person person = Instancio.of(Person.class)
.set(field(Address::getCity).within(scope(Person::getHomeAddress)), "서울")
.set(field(Address::getCity).within(scope(Person::getWorkAddress)), "부산")
.create();
within()에 전달하는 scope()는 "어떤 경로를 통해 도달한 필드인지"를 한정한다. homeAddress 아래의 city와 workAddress 아래의 city를 구분하는 것이다.
깊이 — 객체 그래프의 레벨 지정
재귀적인 구조에서 특정 깊이의 노드만 선택할 수 있다.
// 깊이 1에 있는 Address만
all(Address.class).atDepth(1)
// 깊이 3 이상의 모든 String
allStrings().atDepth(depth -> depth >= 3)
깊이 0은 루트 객체 자신, 깊이 1은 루트의 직접 필드, 깊이 2는 그 필드의 필드다.
값 커스터마이징 API
셀렉터로 "어디를"를 정했다면, 이제 "어떤 값을"을 정해야 한다. Instancio는 네 가지 방법을 제공한다.
set() — 고정 값 할당
가장 직관적이다. 지정한 필드에 항상 같은 값을 넣는다.
Person person = Instancio.of(Person.class)
.set(field(Person::getName), "홍길동")
.set(field(Phone::getCountryCode), "+82")
.set(all(LocalDateTime.class), LocalDateTime.now())
.create();
all(LocalDateTime.class)로 타입 셀렉터를 쓰면, 객체 안의 모든 LocalDateTime 필드가 현재 시간으로 세팅된다.
generate() — 내장 생성기 활용
고정 값이 아니라 "범위"나 "패턴"으로 값을 생성하고 싶을 때 쓴다. 람다의 gen 파라미터로 내장 생성기에 접근한다.
Person person = Instancio.of(Person.class)
.generate(field(Person::getAge), gen -> gen.ints().range(18, 65))
.generate(field(Person::getEmail), gen -> gen.net().email())
.generate(field(Phone::getNumber), gen -> gen.text().pattern("#d#d#d-#d#d#d#d-#d#d#d#d"))
.create();
gen이 제공하는 주요 생성기를 정리하면 다음과 같다.
숫자
gen.ints().range(1, 100)— 정수 범위gen.longs().min(1L).max(1000L)— Long 범위gen.doubles().range(0.0, 1.0)— 실수 범위gen.math().bigDecimal()— BigDecimal
문자열
gen.string().minLength(3).maxLength(10)— 길이 제한gen.string().allowEmpty()— 빈 문자열 허용gen.string().nullable()— null 허용gen.text().pattern("#d#d#d")— 패턴 기반.#d는 숫자,#l은 소문자,#U는 대문자gen.text().uuid()— UUID 문자열
시간
gen.temporal().localDate().past()— 과거 날짜gen.temporal().localDate().future()— 미래 날짜gen.temporal().localDate().range(from, to)— 범위 지정gen.temporal().localDateTime().past()— 과거 일시gen.temporal().instant().past()— 과거 Instant
네트워크
gen.net().email()— 이메일gen.net().url()— URLgen.net().uri()— URI
Enum과 선택
gen.enumOf(Status.class)— Enum 값gen.enumOf(Status.class).excluding(DELETED)— 특정 값 제외gen.oneOf("A", "B", "C")— 주어진 목록 중 하나
컬렉션/배열
gen.collection().size(5)— 크기 지정gen.collection().minSize(2).maxSize(10)— 크기 범위gen.array().length(3)— 배열 길이gen.map().size(5)— 맵 크기
생성기의 결과를 다른 타입으로 변환할 수 있다.
```java
// LocalDate를 String으로 변환
.generate(field("dateString"), gen -> gen.temporal().localDate().past().asString())
// Enum을 대문자 문자열로 변환
.generate(field("statusName"), gen -> gen.enumOf(Status.class).as(e -> e.name().toUpperCase()))
```
supply() — Supplier/Generator 직접 제공
내장 생성기로 표현하기 어려운 값을 직접 만들어야 할 때 쓴다. 두 가지 형태가 있다.
Supplier 방식 — 외부 상태 활용
.supply(all(LocalDateTime.class), () -> LocalDateTime.now())
() -> ... 람다를 넘기는 형태다. 외부 라이브러리나 현재 시간 등 Instancio의 랜덤 시스템 밖의 값을 사용할 때 적합하다.
Generator 방식 — Random 파라미터 활용
.supply(all(Phone.class), random -> Phone.builder()
.countryCode(random.oneOf("+1", "+82", "+44"))
.number(String.valueOf(random.intRange(1000000, 9999999)))
.build())
random -> 람다는 Instancio가 관리하는 Random 인스턴스를 받는다. 이걸 쓰면 seed 기반 재현성이 보장된다.
Supplier(() -> ...)는 Instancio의 seed와 무관하게 동작한다. 같은 seed를 줘도 LocalDateTime.now()는 매번 다른 값을 반환한다. 재현성이 필요한 랜덤 값은 반드시 Generator(random -> ...)를 사용하자.
onComplete() — 생성 후 콜백
객체가 완전히 생성된 뒤에 추가 조작이 필요할 때 쓴다.
Person person = Instancio.of(Person.class)
.onComplete(all(Address.class), (Address address) -> {
address.setCity(address.getCity().toUpperCase());
})
.create();
set()이나 generate()로는 처리하기 어려운, 필드 간 관계가 있는 후처리에 유용하다.
- 대상이 null이면 콜백이 실행되지 않는다
- set()이나 supply(Supplier)로 제공된 객체에도 콜백이 실행되지 않는다
supply(Generator)로 제공된 객체에는 콜백이 실행된다. 이 차이는 Instancio가 "자기가 생성한 객체"에만 콜백을 적용하기 때문이다.
ignore() — 필드 제외
특정 필드를 아예 생성하지 않으려면 ignore()를 사용한다.
Person person = Instancio.of(Person.class)
.ignore(field(Person::getId))
.ignore(all(LocalDateTime.class))
.create();
ignore()가 적용된 필드는 초기화되지 않는다. 참조 타입은 null, primitive 타입은 기본값(0, false)이 된다.
ignore()는 모든 다른 API보다 우선순위가 높다. 같은 필드에 set()과 ignore()를 동시에 걸면, ignore()가 이긴다. 의도적으로 null이어야 하는 필드에 사용하자.
withNullable() — null 허용
기본적으로 Instancio는 모든 필드를 non-null로 생성한다. 특정 필드에 null이 올 수도 있게 하려면 withNullable()을 사용한다.
Person person = Instancio.of(Person.class)
.withNullable(field(Person::getMiddleName))
.withNullable(allStrings())
.create();
withNullable()은 "null일 수도 있다"는 뜻이지, "반드시 null"이라는 뜻이 아니다. 값이 생성될 수도 있고, null이 될 수도 있다.
generate() 안에서도 nullable을 지정할 수 있다.
.generate(field(Person::getEmail), gen -> gen.string().nullable())
Strict Mode와 Lenient Mode
Instancio는 기본적으로 Strict Mode로 동작한다. 셀렉터를 지정했는데 실제로 매칭되는 필드가 없으면 UnusedSelectorException을 던진다.
// Person에 "salary" 필드가 없으면 예외 발생
Person person = Instancio.of(Person.class)
.set(field(Person.class, "salary"), 50000)
.create();
이 설계는 의도적이다. 셀렉터 오타나 리팩토링 후 필드명 변경 같은 실수를 컴파일 시점 대신 테스트 실행 시점에라도 잡아주기 위함이다.
Strict Mode를 끄려면 lenient()를 사용한다.
Person person = Instancio.of(Person.class)
.set(field(Person.class, "salary"), 50000)
.lenient()
.create();
// 매칭 안 되는 셀렉터가 있어도 예외 없이 통과
주로 여러 테스트에서 공유하는 Model을 만들 때 유용하다. Model에 다양한 필드 설정을 넣어두고, 각 테스트에서 필요한 것만 쓰는 구조라면, 매칭되지 않는 셀렉터가 생길 수밖에 없다. 이런 경우에 Lenient Mode를 적용한다. Model은 다음 편에서 자세히 다룬다.
셀렉터 우선순위 정리
여러 셀렉터가 같은 필드에 적용될 때, 어떤 것이 이기는지 규칙이 있다.
ignore()> 다른 모든 API- 일반 셀렉터 > Predicate 셀렉터
field()셀렉터 >all()타입 셀렉터- 같은 종류의 셀렉터가 여럿이면 마지막에 선언된 것이 이긴다
Person person = Instancio.of(Person.class)
.set(allStrings(), "기본값") // 1순위: 모든 String
.set(field(Person::getName), "홍길동") // 2순위: 더 구체적
.create();
// person.getName()은 "홍길동"
// 나머지 String 필드는 "기본값"
"구체적인 것이 일반적인 것보다 우선한다"로 기억하면 된다.
자주 하는 실수
generate()는 내장 생성기(gen -> ...)를 사용하고, supply()는 직접 값을 제공(() -> ... 또는 random -> ...)한다. generate()에 () -> ... 람다를 넘기면 컴파일 에러가 난다. "내장 생성기를 쓸 것인가, 직접 만들 것인가"로 구분하자.
[!DANGER] Strict Mode에서 오타난 셀렉터
field(Person.class, "naem") 같은 오타는 UnusedSelectorException으로 잡힌다. 하지만 에러 메시지에 "unused selector"라고만 나와서 오타를 눈치채기 어려울 수 있다. 이럴 때 verbose()를 붙이면 셀렉터가 어떤 노드에도 매칭되지 않았다는 걸 확인할 수 있다.
[!DANGER] all() 셀렉터의 과도한 사용
set(allStrings(), "test")를 쓰면 객체 안의 모든 String 필드가 "test"가 된다. name, email, address 전부 같은 값이 되어 테스트가 무의미해질 수 있다. 타입 셀렉터는 "기본값 세팅" 용도로 쓰고, 중요한 필드는 field() 셀렉터로 개별 지정하자.