Instancio 랜덤 데이터 테스트 픽스처로 고정값을 걷어낸다

테스트 픽스처를 열어 보면 이런 코드가 있다

public static MemberRegisterRequest createMemberRegisterRequest(String email) {
    return new MemberRegisterRequest(email, "KimWhat", "verysecret");
}

public static MemberRegisterRequest createMemberRegisterRequest() {
    return createMemberRegisterRequest("kim@gmail.com");
}

닉네임은 KimWhat이고 비밀번호는 verysecret이다. 이 값이 무슨 의미인지는 아무도 설명하지 않는다. 그냥 형식 검증을 통과할 만한 길이로 아무렇게나 정한 값이다. 그런데 이 값은 검증 코드에도 그대로 박혀 있다

assertThat(member.verifyPassword("verysecret", passwordEncoder)).isTrue();

이 테스트가 통과한다고 해서 비밀번호 검증 기능이 옳다고 말하기는 어렵다. 확인한 건 verysecret이라는 문자열 하나뿐이다

결론부터 말하면 이런 값은 랜덤으로 만드는 편이 낫다. 값 자체가 테스트의 관심사가 아니기 때문이다. 그리고 랜덤으로 바꾸는 순간, 우연히 특정 값에 의존하고 있던 테스트들이 드러난다. 이 글은 Instancio를 적용하면서 픽스처를 바꾸고, 그 과정에서 깨진 테스트 네 곳을 수습한 기록이다

이 글은 토비님의 스프링 백엔드 강의에서 다루는 온라인 학습 서비스 예제 프로젝트 Splearn을 따라 구현하면서, 강의 내용과 직접 작성한 코드를 함께 정리한 것이다. 개념과 설계 판단의 근거는 강의를 따르되, 코드와 해석은 직접 구현한 저장소 기준이다

이 글에서 자주 나오는 용어

  • 테스트 픽스처(Test Fixture): 테스트가 필요로 하는 데이터와 준비 상태를 만들어 주는 코드. 여기서는 MemberFixture 같은 정적 팩터리 클래스를 말한다
  • 예제 기반 테스트: 테스트 값을 미리 정해 놓고 그 값에 대한 결과를 검증하는 방식
  • 랜덤 데이터 테스트: 값을 고정하지 않고 허용 범위 안에서 임의로 생성해 검증하는 방식
  • 프로퍼티 기반 테스트(Property-based Test): 대상이 항상 만족해야 하는 성질을 규칙으로 정의하고, 대량의 랜덤 값으로 그 규칙이 깨지지 않는지 확인하는 방식
  • 학습 테스트(Learning Test): 새 라이브러리의 동작을 확인하기 위해 작성하는 테스트. 프로덕션 코드를 검증하는 게 아니라 내 이해가 맞는지를 검증한다

테스트 데이터를 준비하는 방식은 네 단계로 나뉜다

Instancio를 왜 쓰는지 이해하려면 테스트 데이터를 준비하는 방식들을 먼저 구분해야 한다. 오른쪽으로 갈수록 검증의 폭이 넓어지고 준비 비용이 달라진다

방식값을 정하는 주체검증하는 대상
예제 기반개발자가 직접 지정그 값에 대한 결과
랜덤 데이터도구가 범위 안에서 생성여러 값에 대한 결과
프로퍼티 기반도구가 대량 생성항상 성립해야 할 성질
형식적 검증값을 쓰지 않음조건의 논리적 참

예제 기반 테스트가 가장 흔하다. add(1, 2)3을 반환하는지 확인하는 식이다. 명확하고 항상 같은 조건으로 실행되지만, 하나 통과했다고 기능이 완전하다고 보기는 어렵다. 그래서 값을 여러 개 넣어 테스트를 늘리거나, 값의 범위가 바뀌는 지점을 노리는 경계값 테스트를 만들거나, 데이터 테이블을 만들어 열 가지 스무 가지를 돌린다. 준비하는 게 번거롭다는 게 한계다

랜덤 데이터 테스트는 값을 정확히 정하지 않고 허용 범위 안의 임의 값을 넣는다. 이메일이면 형식을 만족하는 이메일 주소를, 성인 나이라면 18부터 150 사이의 값을 매번 다르게 넣는다. 이 글에서 다루는 방식이 여기다

프로퍼티 기반 테스트는 한 단계 더 나간다. 랜덤 값을 쓴다는 점은 같지만, 관심사는 개별 결과가 아니라 대상이 항상 만족해야 하는 성질이다. 그 성질을 규칙으로 정의해 두고 대량의 랜덤 값을 넣어 규칙이 깨지지 않는지 확인한다. 자바에서는 jqwik이 이 목적으로 만들어졌다

형식적 검증은 데이터를 넣지 않는다. 프로그램이 만족해야 할 조건을 수학적으로 표현하고 그 조건이 항상 참임을 증명한다. 함수형 프로그래밍 쪽에서 쓰는 접근이다. 값을 넣어 확인하는 방식은 놓치는 엣지 케이스가 있을 수 있지만, 증명은 그렇지 않다

정리하면 우리가 지금 있는 위치는 첫 번째 칸이고, 옮겨 갈 곳은 두 번째 칸이다

자바의 랜덤 데이터 도구는 성격이 갈린다

랜덤 값을 만드는 코드를 직접 짤 필요는 없다. 자바 쪽 선택지는 대략 다섯 가지다

도구생성 단위특징
DataFaker값 하나이름·주소·이메일 등 100가지 넘는 형식을 지원. 데모 데이터 준비에도 쓴다
Fixture Monkey객체 그래프네이버에서 만들었다. 복잡하고 현실적인 데이터 생성에 강하다. 한글 자료가 많다
EasyRandom객체한동안 널리 쓰였다
jqwik프로퍼티프로퍼티 기반 테스트 전용 프레임워크
Instancio객체 그래프전 세계적으로 사용량이 가장 많은 편이다. 이 글에서 쓴다

DataFaker는 값 하나를 만드는 도구이고, Fixture Monkey와 Instancio는 클래스를 주면 그 객체 그래프 전체를 채워 준다. 목적이 다르므로 같이 쓸 수도 있다. Fixture Monkey는 한국에서 개발되어 한글 자료가 많으니 선택지로 충분히 검토할 만하다

Instancio를 고른 이유는 세 가지다. 완전 자동 객체 그래프 생성을 지원해서 중첩 객체, 컬렉션, 배열까지 채워 준다. 레코드를 잘 지원한다. 그리고 Bean Validation 애노테이션과 JPA 매핑 애노테이션을 읽어서 값 생성 조건으로 삼는다. 세 번째가 이 프로젝트에서 특히 크다. 요청 DTO가 전부 레코드이고 검증 제약이 애노테이션으로 붙어 있기 때문이다

의존성을 추가하고 학습 테스트부터 만든다

Gradle 설정에 의존성을 추가한다

// build.gradle.kts
testImplementation("org.instancio:instancio-core:6.0.0-RC2")

메인 코드에 바로 적용하기 전에 학습 테스트를 먼저 만들었다. 라이브러리가 어떻게 동작하는지, 내가 이해한 게 맞는지를 확인하는 테스트다. 이 프로젝트에서는 ArchUnit을 도입할 때도 같은 방식을 썼다

학습 테스트용 클래스를 따로 만든다. 메인 모듈의 엔티티나 DTO를 쓰면 바꿔 가며 실험하기 어렵기 때문이다

// 학습 테스트용 엔티티 흉내
@Getter
@ToString
public class User {
    private Long id;
    private String name;
    private String email;
    private UserStatus status;
}

public enum UserStatus {
    PENDING, ACTIVE, DEACTIVATED
}

id, 문자열 두 개, 이넘 하나를 담았다. 기본 타입뿐 아니라 이넘도 값을 골라 주는지 확인하려는 구성이다

아무 설정 없이 만들면 형식이 지켜지지 않는다

Instancio의 가장 기본 사용법은 팩터리 메서드에 클래스를 넘기고 create()를 호출하는 것이다

User user = Instancio.of(User.class).create();
System.out.println(user);

출력해 보면 Long 타입인 id에는 숫자가, 문자열에는 임의의 문자가, 이넘에는 세 상수 중 하나가 들어간다. 문제는 email 필드다. 이름이 email일 뿐 그냥 문자열이므로 이메일 형식이 전혀 지켜지지 않는다. 아무 설정도 하지 않았을 때 나오는 결과가 대략 이 수준이다. 엔티티로 쓰려면 조정이 필요하다

조정 수단은 네 가지다

Instancio가 제공하는 조정 수단은 성격에 따라 네 가지로 나뉜다

메서드하는 일언제 쓰나
ignore()값을 만들지 않고 null로 둔다DB가 채울 id 필드
set()값을 완전히 고정한다초기 상태처럼 항상 같아야 하는 값
generate()Instancio 내장 생성기로 만든다이메일, 문자열 길이 제약 등
supply()직접 짠 코드로 만든다내장 생성기가 지원하지 않는 규칙

학습 테스트에 세 가지를 적용했다

@Test
void user() {
    User user = Instancio.of(User.class)
                         .ignore(field(User::getId))
                         .generate(field(User::getEmail), gen -> gen.net().email())
                         .set(field(User::getStatus), UserStatus.PENDING)
                         .create();

    assertThat(user.getId()).isNull();
    assertThat(user.getEmail()).isNotEmpty();
    assertThat(user.getName()).isNotEmpty();
    assertThat(user.getStatus()).isEqualTo(UserStatus.PENDING);
}

id는 DB 저장 시 자동 생성되는 방식을 쓰기로 했으므로 null이어야 한다. 그래서 ignore()다. 이메일은 generate()로 내장 생성기를 지정했다. gen.net().email()은 값을 반환하는 게 아니라 이메일을 만들어 내는 스펙을 반환하고, Instancio가 그 스펙을 보고 값을 생성한다. 상태는 set()으로 PENDING에 고정했다. 최초 생성된 회원은 무조건 등록 대기 상태여야 한다는 도메인 규칙을 픽스처가 지키게 하려는 것이다. 여러 상태의 사용자가 필요한 테스트라면 고정하지 않으면 된다

여기서 눈여겨볼 부분은 필드를 지정하는 방법이다. field()에는 클래스와 필드 이름 문자열을 넘기는 방식도 있다

// 타입 안전하지 않다. 필드 이름이 바뀌어도 컴파일 에러가 나지 않는다
.ignore(field(User.class, "id"))

// 타입 안전하다. getter 메서드 레퍼런스로 필드를 찾는다
.ignore(field(User::getId))

문자열 방식은 필드 이름이 바뀌었을 때 리팩터링이 따라오지 않고, 컴파일 시점에 검증되지도 않는다. getter 메서드 레퍼런스를 넘기면 Instancio가 그걸 보고 필드를 찾아 주므로 타입 안전하다. 레코드는 getter 규약을 따르지 않으므로 MemberRegisterRequest::email처럼 접근자 이름을 그대로 쓰면 된다

supply()는 위 예시에 넣지 않았지만 성격만 짚어 둔다. generate()가 Instancio 내장 생성기 안에서만 고를 수 있는 반면, supply()Supplier를 받아 직접 짠 로직으로 값을 만든다. 호출될 때마다 새로 실행되므로 반복 생성 시 매번 다른 값이 나온다. 내장 생성기가 지원하지 않는 형식이나 우리 팀만의 값 생성 규칙이 있을 때 쓰면 된다

Model로 생성 스펙을 재사용한다

사용자를 100명 만들어야 한다면 위 코드를 100번 반복해도 된다. 다만 “어떻게 생성할 것인가”를 정의하는 앞부분을 매번 똑같이 쓰는 건 번거롭다. 이럴 때 toModel()로 스펙을 뽑아 둔다

@Test
void userModel() {
    Model<User> model = Instancio.of(User.class)
                                 .ignore(field(User::getId))
                                 .generate(field(User::getEmail), gen -> gen.net().email())
                                 .set(field(User::getStatus), UserStatus.PENDING)
                                 .toModel();

    for (int i = 0; i < 100; i++) {
        User user = Instancio.of(model).create();

        System.out.println(user);

        assertThat(user.getId()).isNull();
        assertThat(user.getEmail()).isNotEmpty();
        assertThat(user.getName()).isNotEmpty();
        assertThat(user.getStatus()).isEqualTo(UserStatus.PENDING);
    }
}

Model<User>는 생성 설정을 담은 객체다. Instancio.of(model).create()로 몇 번이든 다시 만들 수 있다. 100번을 돌려도 우리가 정한 규칙이 항상 지켜진다는 걸 확인하는 테스트이면서, 시나리오 테스트를 위해 대량의 데이터를 만들 때 쓰는 방법이기도 하다

Bean Validation 애노테이션을 읽어 값을 만든다

Instancio의 특징 중 실무에서 가장 유용한 건 표준 검증 애노테이션을 참조하는 기능이다. 학습 테스트용 요청 DTO를 레코드로 만들어 확인했다

public record UserRegisterRequest(
    @Email String email,
    @Size(min = 5, max = 20) String nickname,
    @Size(min = 8, max = 100) String password) {
}

그냥 생성하면 기대와 다른 결과가 나온다. 이메일은 이메일 형식이 아니고, 비밀번호는 최소 길이도 지키지 않는다. 기본값이 꺼져 있기 때문이다. 매번 옵션을 주는 대신 설정 파일을 만들어 전역으로 켰다

# src/main/resources/instancio.properties
bean.validation.enabled=true

이 파일을 두면 Instancio가 읽어 설정으로 반영한다. 다시 실행하면 필드에 아무 지정을 하지 않았는데도 이메일 형식이 맞고 비밀번호는 8자 이상이 된다

@Test
void annotation() {
    UserRegisterRequest userRegisterRequest = Instancio.of(UserRegisterRequest.class).create();

    assertThat(userRegisterRequest.email()).isNotEmpty();
    assertThat(userRegisterRequest.nickname()).isNotEmpty();
    assertThat(userRegisterRequest.password()).hasSizeBetween(8, 100);
}

hasSizeBetween(8, 100)은 AssertJ가 제공하는 검증이다. @Size(min = 8, max = 100) 제약이 실제로 반영됐는지 범위로 확인한다. 애노테이션을 읽지 않았다면 이 검증에서 걸린다.

JPA를 쓴다면 @Column의 길이 제약 같은 것도 같은 방식으로 인식된다. 다만 이 프로젝트는 도메인 엔티티에 DB 관련 상세 애노테이션을 남기지 않기로 했으므로, 이 혜택은 주로 DTO 쪽에서 받는다. 엔티티는 필요한 부분을 수동으로 지정해야 한다

픽스처에 적용하면 하드코딩은 두 줄로 줄어든다

학습 테스트로 확인한 내용을 MemberFixture에 적용했다

public static MemberRegisterRequest createMemberRegisterRequest(String email) {
    return Instancio.of(MemberRegisterRequest.class)
                    .set(field(MemberRegisterRequest::email), email)
                    .create();
}

public static MemberRegisterRequest createMemberRegisterRequest() {
    return createMemberRegisterRequest(gen().net().email().get());
}

이메일을 파라미터로 받는 쪽은 그 값을 set()으로 고정하고, 닉네임과 비밀번호는 @Size 제약을 참조해 랜덤으로 채워진다. 파라미터를 받지 않는 쪽은 이메일까지 랜덤으로 만든다. 여기서 Instancio.gen()은 객체가 아니라 값 하나를 만들 때 쓰는 진입점이다. gen().net().email()까지는 스펙이므로 .get()을 호출해야 실제 문자열이 나온다

픽스처 안의 다른 메서드들은 이미 Member.register(...)를 거쳐 요청 객체로부터 회원을 만들고 있었기 때문에 손댈 필요가 없었다. 하드코딩된 값은 이 두 메서드에만 있었다

랜덤 값이 깨뜨린 테스트는 네 곳이었다

값이 고정에서 랜덤으로 바뀌면, 검증 코드에 값이 박혀 있던 테스트가 깨진다. 이걸 찾아내는 게 이 작업의 실제 내용이다. 네 곳이 걸렸고 원인은 두 가지였다

첫 번째는 검증에 값이 박혀 있는 경우다. 도메인 단위 테스트에서 비밀번호를 하드코딩된 문자열로 검증하고 있었다

// 기존
assertThat(member.verifyPassword("verysecret", passwordEncoder)).isTrue();

비밀번호가 랜덤이 됐으니 verysecret과 맞을 리 없다. 비교할 값을 어디서 가져올지 생각하면 답이 나온다. 회원을 만들 때 쓴 요청 객체가 그 값을 갖고 있다. 그래서 요청 객체를 필드로 저장하도록 바꿨다

class MemberTest {
    Member member;
    PasswordEncoder passwordEncoder;
    MemberRegisterInfo registerRequest;

    @BeforeEach
    void setUp() {
        this.passwordEncoder = createPasswordEncoder();
        registerRequest = createMemberRegisterRequest().toInfo();
        member = Member.register(registerRequest, passwordEncoder);
    }

    @Test
    void verifyPassword() {
        assertThat(member.verifyPassword(registerRequest.password(), passwordEncoder)).isTrue();
        assertThat(member.verifyPassword("hello", passwordEncoder)).isFalse();
    }
}

회원을 만들 때 쓴 비밀번호로 검증한다. 실패를 기대하는 두 번째 줄은 그대로 뒀다. hello는 최소 길이 8자부터 어긋나므로 어떤 랜덤 값과도 일치하지 않는다

두 번째는 픽스처를 두 번 호출하던 경우다. 이메일 중복 테스트가 여기 해당한다

// 기존. 전에는 두 호출이 같은 값을 반환했다
memberRegister.register(MemberFixture.createMemberRegisterRequest());

assertThatThrownBy(() -> memberRegister.register(MemberFixture.createMemberRegisterRequest()))
    .isInstanceOf(DuplicateEmailException.class);

이 코드는 픽스처가 항상 같은 값을 반환한다는 전제 위에 서 있었다. 두 번 호출하면 같은 이메일이 나오니 중복 예외가 발생했다. 이제는 호출할 때마다 다른 이메일이 나오므로 중복이 성립하지 않는다

전제를 코드에 명시하는 방향으로 고쳤다. 같은 요청 객체를 저장해 두고 두 번 쓴다

@Test
void duplicateEmailFail() {
    MemberRegisterRequest memberRegisterRequest = MemberFixture.createMemberRegisterRequest();

    memberRegister.register(memberRegisterRequest);

    assertThatThrownBy(() -> memberRegister.register(memberRegisterRequest))
        .isInstanceOf(DuplicateEmailException.class);
}

같은 패턴이 세 계층에서 반복됐다. 애플리케이션 서비스 테스트, 리포지토리 테스트, 그리고 웹 API 테스트다. API 테스트는 등록에 쓴 요청 객체를 JSON으로 변환해 다시 보내는 방식으로 바꿨다.

@Test
void duplicateEmail() throws JsonProcessingException {
    MemberRegisterRequest memberRegisterRequest = MemberFixture.createMemberRegisterRequest();
    memberRegister.register(memberRegisterRequest);

    String requestJson = objectMapper.writeValueAsString(memberRegisterRequest);
    // ... POST 요청 후 CONFLICT 응답 확인
}

수정 후 전체 테스트가 통과한다. 이메일이나 비밀번호가 매번 새로 할당되어도 문제가 없다

눈여겨볼 점은 이 네 곳이 원래 있던 결함이라는 것이다. “픽스처가 항상 같은 값을 반환한다”는 전제에 기대고 있었지만 그 전제는 코드 어디에도 적혀 있지 않았다. 픽스처 구현을 바꾸면 소리 없이 깨지는 상태였다. 랜덤 값 도입이 그 결합을 드러냈고, 고친 코드는 전제를 명시적으로 표현한다

이 방식이 그대로 통하지 않는 조건

강의에서 다루지 않았거나, 실제로 적용하면서 확인해야 했던 지점이 몇 가지 있다

버전이 정식 릴리스가 아니다. 이 프로젝트는 6.0.0-RC2를 쓰고 있는데, 릴리스 이력을 확인해 보면 이후에 6.0.0-RC3가 나왔고 6.0.0 정식 버전은 아직 없다. 안정 버전은 5.6.0 계열이다. RC를 쓰는 판단 자체는 큰 문제가 아니지만, 테스트 전용 의존성이라 프로덕션 영향이 없다는 전제 위에서만 그렇다. 정식 릴리스가 나오면 올려 두는 편이 좋다

랜덤 값을 쓰면 실패를 재현하기 어려워진다. 어제는 통과했는데 오늘 깨졌고 그 값이 뭐였는지 모르는 상황이 생길 수 있다. Instancio는 객체 그래프마다 일관된 시드를 쓰고, 그 시드로 같은 데이터를 다시 만들 수 있게 되어 있다. 다만 실패 시 시드를 보고해 주는 기능은 instancio-junitInstancioExtension이 제공한다. 이 프로젝트는 instancio-core만 의존하고 있으므로 그 혜택을 받지 못한다. 랜덤 픽스처가 늘어나면 instancio-junit을 추가할 이유가 생긴다

애노테이션 자동 인식의 혜택은 DTO에 몰린다. 도메인 엔티티에 JPA 상세 애노테이션을 남기지 않기로 한 프로젝트 방침 때문이다. 엔티티 쪽 픽스처는 앞으로도 상당 부분 수동 지정이 필요하다

랜덤 데이터가 검증의 폭을 늘려 주는 것도 아니다. 이 작업으로 바뀐 건 “픽스처가 값에 의존하지 않게 된 것”이지 “더 많은 경우를 검증하게 된 것”이 아니다. 경계값이나 특정 조건을 노리는 검증은 여전히 예제 기반으로 따로 만들어야 한다. 두 방식은 대체 관계가 아니라 역할이 다르다

정리하면 테스트가 값 자체에 관심이 없다면 그 값은 랜덤으로 만들고, 관심이 있다면 예제 기반으로 명시하면 된다. 픽스처를 랜덤으로 바꿨을 때 깨지는 테스트가 있다면 그건 원래 숨어 있던 결합이다

출처 – 토비의 클린 스프링 – 도메인 모델 패턴과 헥사고날 아키텍처 Part 2