테스트에서 단일 객체만 필요한 경우는 드물다. 목록 조회, 페이징, 배치 처리 등 컬렉션 단위로 테스트 데이터가 필요한 상황이 훨씬 많다. 또한 여러 테스트에서 비슷한 객체를 반복 생성하게 되면 설정 코드가 중복된다. Instancio는 컬렉션 전용 API와 Model이라는 재사용 템플릿으로 이 두 문제를 해결한다.
컬렉션 생성
단축 메서드
가장 간단한 방법이다. 한 줄로 원하는 타입의 컬렉션을 만든다.
List<Person> people = Instancio.createList(Person.class);
Set<Person> uniquePeople = Instancio.createSet(Person.class);
Map<UUID, Person> personMap = Instancio.createMap(UUID.class, Person.class);
크기는 Instancio가 랜덤으로 정한다. 기본 설정에서는 보통 2~6개 정도가 생성된다.
Builder API로 크기 지정
크기를 직접 지정하거나 커스터마이징이 필요하면 ofList(), ofSet(), ofMap()을 사용한다.
List<Person> people = Instancio.ofList(Person.class)
.size(10)
.create();
당연히 2편에서 배운 모든 커스터마이징 API를 함께 쓸 수 있다.
List<Person> adults = Instancio.ofList(Person.class)
.size(5)
.generate(field(Person::getAge), gen -> gen.ints().range(18, 65))
.set(field(Address::getCountry), "한국")
.create();
size()로 전체 크기를 잡고, set()/generate()로 각 요소의 필드를 커스터마이징하는 패턴이다. 모든 요소에 동일한 설정이 적용된다는 점을 기억하자.
Map 생성
Map은 키 타입과 값 타입을 둘 다 지정한다.
Map<UUID, Address> addressMap = Instancio.ofMap(UUID.class, Address.class)
.size(3)
.set(field(Address::getCity), "서울")
.create();
generate() 안에서 Map 자체의 속성을 조절할 수도 있다.
```java
.generate(all(Map.class), gen -> gen.map()
.size(5)
.nullableKeys()
.nullableValues())
```
Stream 생성
대량의 테스트 데이터를 지연 생성하고 싶으면 Stream API를 사용한다.
List<Person> people = Instancio.stream(Person.class)
.limit(100)
.collect(Collectors.toList());
stream()은 Builder API도 지원한다.
List<Person> koreanAdults = Instancio.of(Person.class)
.generate(field(Person::getAge), gen -> gen.ints().range(18, 65))
.set(field(Address::getCountry), "한국")
.stream()
.limit(50)
.filter(p -> p.getName().length() > 3)
.collect(Collectors.toList());
of()로 설정을 잡고 stream()으로 전환하면, 각 요소가 독립적인 랜덤 값으로 생성된다. createList()와의 차이는 Java Stream의 filter(), map() 같은 중간 연산을 활용할 수 있다는 점이다.
stream()은 무한 스트림을 반환한다. limit()을 빠뜨리면 무한 루프에 빠진다. 반드시 크기를 제한하자.
Instancio.gen() — 단일 값 생성
객체가 아니라 단순한 값 하나만 필요할 때가 있다. 이럴 때 Instancio.gen()을 사용한다.
String email = Instancio.gen().net().email().get();
int age = Instancio.gen().ints().range(18, 65).get();
LocalDate pastDate = Instancio.gen().temporal().localDate().past().get();
리스트로 여러 개를 한 번에 생성할 수도 있다.
List<String> emails = Instancio.gen().net().email().list(10);
List<UUID> ids = Instancio.gen().text().uuid().list(5);
gen()은 2편에서 다룬 generate()의 내장 생성기와 동일한 API를 사용한다. 차이점은 객체 없이 값만 독립적으로 생성한다는 것이다.
Model — 재사용 가능한 템플릿
Model이 필요한 이유
여러 테스트에서 비슷한 설정의 객체를 반복 생성하는 코드를 보자.
@Test
void testA() {
Person person = Instancio.of(Person.class)
.ignore(field(Person::getId))
.set(field(Person::getLastName), "김")
.generate(field(Person::getAge), gen -> gen.ints().range(20, 60))
.set(field(Address::getCountry), "한국")
.create();
// ...
}
@Test
void testB() {
Person person = Instancio.of(Person.class)
.ignore(field(Person::getId))
.set(field(Person::getLastName), "김")
.generate(field(Person::getAge), gen -> gen.ints().range(20, 60))
.set(field(Address::getCountry), "한국")
.create();
// ...
}
ignore(), set(), generate() 설정이 완전히 동일하다. 이 중복을 제거하는 것이 Model이다.
Model 생성과 사용
toModel()로 설정을 캡슐화하고, Instancio.of(model)로 재사용한다.
Model<Person> koreanPersonModel = Instancio.of(Person.class)
.ignore(field(Person::getId))
.set(field(Person::getLastName), "김")
.generate(field(Person::getAge), gen -> gen.ints().range(20, 60))
.set(field(Address::getCountry), "한국")
.toModel();
toModel()은 create() 대신 호출한다. 이 시점에서 객체가 생성되지 않는다. Model은 "어떻게 만들지"를 담은 설계도일 뿐이다.
이 Model을 기반으로 객체를 생성한다.
// 기본 Model 그대로 사용
Person person = Instancio.create(koreanPersonModel);
// Model 위에 추가 커스터마이징
Person youngPerson = Instancio.of(koreanPersonModel)
.generate(field(Person::getAge), gen -> gen.ints().range(20, 25))
.create();
of(model)로 시작하면 Model의 설정을 상속받으면서 추가 설정을 덧붙일 수 있다. 기존 설정을 덮어쓰는 것도 가능하다. 위 예시에서 나이 범위가 20~60에서 20~25로 좁혀진다.
Model로 컬렉션 생성
Model은 컬렉션 생성에도 사용할 수 있다.
List<Person> koreanPeople = Instancio.ofList(koreanPersonModel)
.size(10)
.create();
setModel() — 중첩 객체에 Model 적용
Model을 특정 필드에만 적용하고 싶을 때 사용한다.
Model<Address> seoulAddressModel = Instancio.of(Address.class)
.set(field(Address::getCity), "서울")
.set(field(Address::getCountry), "한국")
.toModel();
Person person = Instancio.of(Person.class)
.setModel(field(Person::getHomeAddress), seoulAddressModel)
.create();
setModel()은 셀렉터가 지정한 필드에 해당 Model의 설정을 적용한다. Person의 homeAddress만 서울로 세팅되고, workAddress는 랜덤 값이 들어간다.
setModel()은 4.4.0에서 추가된 실험적 API다. 향후 시그니처나 동작이 변경될 수 있다.
Model 설계 패턴
실무에서 Model을 효과적으로 쓰는 패턴이다.
테스트 클래스의 필드로 선언
@ExtendWith(InstancioExtension.class)
class OrderServiceTest {
private final Model<Order> baseOrderModel = Instancio.of(Order.class)
.ignore(field(Order::getId))
.generate(field(Order::getAmount), gen -> gen.ints().range(1000, 100000))
.set(field(Order::getCurrency), "KRW")
.toModel();
@Test
void shouldProcessOrder() {
Order order = Instancio.create(baseOrderModel);
// ...
}
@Test
void shouldRejectExpiredOrder() {
Order order = Instancio.of(baseOrderModel)
.generate(field(Order::getExpiryDate), gen -> gen.temporal().localDate().past())
.create();
// ...
}
}
기본 설정은 baseOrderModel에 두고, 각 테스트에서 필요한 부분만 오버라이드한다.
유틸 클래스로 공유
여러 테스트 클래스에서 같은 Model을 쓴다면 별도 클래스로 분리한다.
public class TestModels {
public static final Model<Person> KOREAN_PERSON = Instancio.of(Person.class)
.ignore(field(Person::getId))
.set(field(Address::getCountry), "한국")
.generate(field(Person::getAge), gen -> gen.ints().range(20, 60))
.lenient()
.toModel();
}
lenient()를 붙이면 모든 셀렉터가 매칭되지 않아도 예외가 발생하지 않는다. 공유 Model은 다양한 컨텍스트에서 쓰이기 때문에 Lenient Mode가 적합하다.
자주 하는 실수
toModel()은 객체를 만들지 않는다. Model<Person> model = ...toModel()이면 model은 Person 인스턴스가 아니라 설계도다. 실제 객체가 필요하면 반드시 Instancio.create(model) 또는 Instancio.of(model).create()를 호출해야 한다.
[!DANGER] stream()에 limit() 빠뜨리기
Instancio.stream(Person.class).collect(Collectors.toList()) — 이 코드는 무한 루프에 빠진다. stream()은 무한 스트림이므로 반드시 limit()으로 크기를 제한하자.
[!DANGER] 공유 Model에 Strict Mode 적용
여러 테스트에서 공유하는 Model에 Strict Mode(기본값)가 적용되어 있으면, 특정 테스트의 객체 구조에 없는 셀렉터 때문에 UnusedSelectorException이 발생할 수 있다. 공유 Model에는 lenient()를 붙이는 것이 안전하다.