본문 바로가기

프로젝트/오디

🙉 Test Fixture 생성전략에 대한 고민 feat) FixtureMonkey

 

무엇이 문제였나?

팀 컨벤션으로 dummy sql을 사용하지 않기 + @Transaction을 사용하지 않기로 정했다.

즉, 매 test마다 데이터 셋업을 통해 테스트 격리성을 챙기기로 했다

 

그러나, domain 객체가 하나둘 많아지면서 테스트 작성시 데이터 셋업 과정이 매우 길어지게 되었다.

비슷한 도메인 객체를 초기화해주는 과정이 반복되었고, 이러한 불편함은 연관관계가 많은 객체일수록 심해져갔다.

 

[이전 코드 - given 절이 매우 길다]

    @DisplayName("내 약속 목록 조회 시 오름차순 정렬한다.")
    @Test
    void findAllByMember() {
        //given
        Member member = memberRepository.save(
                new Member(new DeviceToken("Bearer device-token=new-member-device-token")));

        Meeting meetingDayAfterTomorrowAt14 = meetingRepository.save(Fixture.ODY_MEETING4);
        Meeting meetingTomorrowAt12 = meetingRepository.save(Fixture.ODY_MEETING3);
        Meeting meetingTomorrowAt14 = meetingRepository.save(Fixture.ODY_MEETING5);

        mateRepository.save(
                new Mate(meetingDayAfterTomorrowAt14, member, new Nickname("제리1"), Fixture.ORIGIN_LOCATION, 10L)
        );
        mateRepository.save(
                new Mate(meetingTomorrowAt12, member, new Nickname("제리2"), Fixture.ORIGIN_LOCATION, 10L)
        );
        mateRepository.save(
                new Mate(meetingTomorrowAt14, member, new Nickname("제리3"), Fixture.ORIGIN_LOCATION, 10L)
        );

        //when
        List<MeetingFindByMemberResponse> meetings = meetingService.findAllByMember(member).meetings();

        List<Long> meetingIds = meetings.stream()
                .map(MeetingFindByMemberResponse::id)
                .toList();
        
        //then
        assertThat(meetingIds).containsExactly(
                meetingTomorrowAt12.getId(),
                meetingTomorrowAt14.getId(),
                meetingDayAfterTomorrowAt14.getId()
        );
    }

 

 


 

해결 1차 : Test Fixture 도입

A test fixture is a device used to consistently test some item, device, or piece of software. Test fixtures are used in the testing of electronics, software and physical devices.

by 위키피디아

 

테스트 픽스쳐란 간단히 말해 일관된 테스트를 보장하는 아이템을 의미한다.

 

When you are writing tests you will often find that you spend more time writing the code to set up the fixture than you do in actually testing values.

by https://junit.org/junit4/cookbook.html

Junit에서도 테스트 픽스쳐의 목적은 셋업 과정의 cost를 줄이는 것임을 명시하고 있다

 

우리 팀은 1차적으로 테스트 given 절에서 반복되는 테스트 객체들을 선언하여 중복되는 코드를 드러내고자 했다

 

[Test fixture 예시]

public class Fixture {

    public static Location ORIGIN_LOCATION = new Location(
            "서울 강남구 테헤란로 411",
            "37.505713",
            "127.505691"
    );

    public static Location TARGET_LOCATION = new Location(
            "서울 송파구 올림픽로35다길 42",
            "37.515298",
            "127.103113"
    );

    public static Meeting ODY_MEETING = new Meeting(
            "오디",
            LocalDate.now(),
            LocalTime.now(),
            TARGET_LOCATION,
            "초대코드"
    );

    public static Meeting SOJU_MEETING = new Meeting(
            "카키와 회식",
            LocalDate.now().plusDays(1),
            LocalTime.parse("18:00"),
            TARGET_LOCATION,
            "초대코드"
    );
}

 

 

그러나, 테스트가 많아지면서 몇가지 문제가 생겼다


문제1. 설정값이 다른 픽스처 생성에 한계가 있다

약속 객체에 대하여 약속 시간 1시간 단위로 픽스처를 만들려면 24개의 픽스처가 필요한 상황이 발생한다.


문제2. 연관관계가 있는 경우 선제적인 영속화가 필요한 객체에 대응하지 못한다

타 객체의 외래키를 참조하는 객체의 경우, 참조하는 객체가 먼저 영속화되어야 한다


문제3. Fixture 안에 어떤 값이 들어있는지 잊는다

픽스처 내부에 어떤 기본값이 있는지 잊어버리는 경우가 많았다.

실제로 테스트를 작성하다 팀원 중 한명이 주석을 달아주고 나서야 어떤 로직을 의도했는지 이해하는 경우도 있었다


해결 2차 : Fixture Generator 도입

테스트가 점차 더러워지자 팀원 중 한명인 제리가 fixtureGenerator라는 객체를 만들어주었다.

fixtureGenerator는 파라미터값에 따라 필요한 fixture를 생성해준다.

 

예를 들어 밑의 약속 생성 코드를 보자.

public Meeting generateMeeting(LocalDateTime meetingTime) {
        return meetingRepository.save(new Meeting(
                "약속",
                meetingTime.toLocalDate(),
                meetingTime.toLocalTime(),
                Fixture.TARGET_LOCATION,
                InviteCodeGenerator.generate()
        ));
    }

 

약속 시간을 넘겨주면 그 시간을 약속시간을 가지는 약속을 생성해준다.

이외에 불필요한 값들은 dummy 값을 넣어준다.

 

확실히 test 상에서 불필요한 코드들이 걷히니 훨씬 코드짜기가 편해졌다

 

앞선 지저분한 테스트코드도 조금은 읽기가 편해졌다

    @DisplayName("내 약속 목록 조회 시 오름차순 정렬한다.")
    @Test
    void findAllByMember() {
        Member member = fixtureGenerator.generateMember();

        LocalDate today = LocalDate.now();
        LocalDateTime dayAfterTomorrowAt14 = LocalDateTime.of(today.plusDays(2), LocalTime.parse("14:00"));
        LocalDateTime tomorrowAt12 = LocalDateTime.of(today.plusDays(1), LocalTime.parse("12:00"));
        LocalDateTime tomorrowAt14 = LocalDateTime.of(today.plusDays(1), LocalTime.parse("14:00"));

        attendMultipleMeetingByTimes(member, dayAfterTomorrowAt14, tomorrowAt12, tomorrowAt14);

        List<LocalDateTime> foundMeetingTimes = meetingService.findAllByMember(member)
                .meetings()
                .stream()
                .map(response -> LocalDateTime.of(response.date(), response.time()))
                .toList();

        assertThat(foundMeetingTimes).containsExactly(tomorrowAt12, tomorrowAt14, dayAfterTomorrowAt14);
    }    
    
    private void attendMultipleMeetingByTimes(Member member, LocalDateTime... meetingTimes) {
        for (LocalDateTime meetingTime : meetingTimes) {
            Meeting meeting = fixtureGenerator.generateMeeting(meetingTime);
            fixtureGenerator.generateMate(meeting, member);
        }
    }

 

여기서 generator는 크게 2가지 역할을 해주었다

장점1. 테스트에서 필요한 설정 값만을 넘겨 객체를 생성해준다
장점2.  Entity의 경우 repository 단에서 객체를 저장하여 id 값이 있는 객체를 반환한다 (id 생성전략이 identity일 경우)

 

 

이러한 로직은 재사용성이 뛰어났고, 간단했고 조금 더 테스트의 본질적인 로직에 집중하게 해주었다 

 

그러나 장점만 있을 줄 알았던 픽스처 제네레이터에도 무언가 아쉬운 점들이 보였다


첫째로, 도메인이 커짐에 따라 점차 제네레이터가 두꺼워졌다

 

픽스처 제네레이터는 영속화된 엔티티를 반환한다. 그렇기에 repository 단의 클래스 전체가 generator에 모이게 되었다

public class FixtureGenerator {
   ... final 필드들 중략...

    public FixtureGenerator(
            MeetingRepository meetingRepository,
            MemberRepository memberRepository,
            MateRepository mateRepository,
            NotificationRepository notificationRepository,
            EtaRepository etaRepository,
            ApiCallRepository apiCallRepository,
            JwtTokenProvider jwtTokenProvider
    ) {
        this.meetingRepository = meetingRepository;
        this.memberRepository = memberRepository;
        this.mateRepository = mateRepository;
        this.notificationRepository = notificationRepository;
        this.etaRepository = etaRepository;
        this.apiCallRepository = apiCallRepository;
        this.jwtTokenProvider = jwtTokenProvider;
    }
}

 

 

이게 우리 팀 픽스처 제네레이터의 생성자이다

생성자는 의존성을 의미한다. 또한 책임의 두께를 이야기하기도 한다.

 

만약 도메인이 더 커지면, 하나가 추가될 때마다 fixtureGenerator가 가지는 책임을 제어할 수 있을까? 라는 의문이 들었다

현재도 모든 엔티티 객체 픽스처를 이곳에서 생성해주어 특정 엔티티의 생성로직을 찾기 힘든 경우가 있었다.


둘째로, 필요한 설정 값에 따라 메서드 오버로딩이 너무 심해졌다

테스트별로 필요한 설정 파라미터가 다른 경우 픽스처 제네레이터에 새로운 메서드를 만들어주어야 했다

그러다 보니 점차 오버로딩이 필요해졌고 기본 4-5개의 오버로딩이 생겼다. 

    public Mate generateMate() {
        Meeting meeting = generateMeeting();
        Member member = generateMember();
        return generateMate(meeting, member);
    }

    public Mate generateMate(Meeting meeting) {
        Member member = generateMember();
        return generateMate(meeting, member);
    }

    public Mate generateMate(Meeting meeting, Member member) {
        return mateRepository.save(new Mate(meeting, member, Fixture.ORIGIN_LOCATION, 10L));
    }

    public Mate generateMate(Meeting meeting, Location location) {
        Member member = generateMember();
        return mateRepository.save(new Mate(meeting, member, location, 10L));
    }

 

만약 새로운 설정값이 필요한 테스트가 더 생기게 된다면? 

언제까지 오버로딩으로 버틸 수 있을까? 라는 생각이 들었다

 

특히 영속화여부에 따라서도 메서드를 분리해주어야 했다

    public Member generateUnsavedMember(String providerId, String rawDeviceToken) {
        return new Member(providerId, new Nickname("nickname"), "imageUrl", new DeviceToken(rawDeviceToken));
    }

    public Member generateSavedMember(String providerId, String rawDeviceToken) {
        return memberRepository.save(generateUnsavedMember(providerId, rawDeviceToken));
    }

 


 

셋째로, 범용적이지 않은 객체 초기화의 경우도 제네레이터에게 생성책임을 이전했다.

 

generator의 메서드 사용처를 보니 3 usage 처럼 특정 테스트 클래스에만 쓰이는 생성로직들이 있었다.

즉, 범용적으로 쓰이는 생성로직이 아님에도 불구하고 일관성을 위해 제네레이터에게 생성 책임을 이전했다

 

위의 메서드가 그 예시이다 위의 generateNotification은 단 2번 사용된다.

사실상 EtaServiceTest에서만 사용되는 로직임에도 generator 일관성을 위해 여기서 생성하고 있다

 

이러한 초기화 로직은 test 클래스 내에서 fixture 생성 로직을 찾아볼 수 없다는 문제와도 연관된다.

geneartor안에 fixture 생성로직이 캡슐화되어 있다보니

어떻게 생성되는지를 검토하려면 꼭 한번 generator를 들어와야 한다.

 


넷째로, default 초기화 값이 무엇인지 잊는다

 

1차로 시도했던 fixture와 마찬가지로 default 값에 어떤 값이 들어있는지를 자주 잊어버렸다.

그런데 오버로딩된 메서드 안에서 default 설정값을 찾는 것도 일이었다.

만약 테스트 로직에서 기본 설정값이 영향을 준다면 디버깅 하기 쉽지 않으리라 생각했다

 


다섯째로, 커버하지 못하는 엣지 케이스 발생

 

기본 default 값들을 해피케이스로 설정해놓다 보니 테스트의 randomness를 보장하지 못했다.


해결 3차 :🙉 FixtureMonkey 도입🙉

 

이후 조금 더 fixture 생성을 간편화할 수 있는 라이브러리를 찾아보다가 네이버가 만든 FixtureMonkey라는 라이브러리를 발견하게 되었고 관심이 생겼다

 

FixtureMonkey란 Naver가 내부 라이브러리로 처음 개발했다가 Naver Pay 아키텍처 개선 프로젝트인 Plasma 프로젝트에서  10,000개가 넘는 테스트를 효율적을 작성하고 중요한 엣지 케이스를 찾아내기 위해 만들어낸 라이브러리다.

이미지 출처 : FixtureMonkey 공식 홈페이지

 

올리브영 테크블로그에도 적용한 후기가 나와있어 한번 경험해볼만한 가치가 있다고 판단했다

 

FixtureMonkey가 공식 docs에서 소개하는 장점은 다음과 같았다.

 

1. 간결성

Product actual = fixtureMonkey.giveMeOne(Product.class);

 

한줄의 코드로 Fixture 생성이 가능하다. 특히 Bean Validation 애너테이션을 붙여놓았다면 이 검증 조건에 맞는 객체를 생성해주며, 사용자가 임의로 fixture 생성의 조건을 설정할 수 있다.

 

2. 재사용성

ArbitraryBuilder<Product> actual = fixtureMonkey.giveMeBuilder(Product.class)
    .set("id", 1000L)
    .set("productName", "Book");

 

여러 테스트에서 인스턴스 명세를 재사용 가능하다. 명세는 빌더에서 한번 정의된 이후 해당 인스턴스를 얻기 위해 재사용될 수 있다.

 

여기서 set은 리플렉션을 활용하여 id값을 가진 필드명에 1000L을 setting 해준다.

 

3. 랜덤성

ArbitraryBuilder<Product> actual = fixtureMonkey.giveMeBuilder(Product.class);

then(actual.sample()).isNotEqualTo(actual.sample());

 

FixtureMonkey는 무작위로 테스트 객체를 생성하여 엣지케이스 발견에 기여한다.

 


 

그럼 FixtureMonkey를 활용하여 구조를 개선해보기 전에 내가 겪은 문제들의 원인과 개선방향을 생각해보자고 마음먹었다.

 

첫번째로 fixtureGenerator가 분담해야 하는 생성 로직의 기준이다.

만약 두개의 test class 이상에서 반복적으로 사용되는 fixture 초기화 로직의 경우 generator가 생성해도 좋다고 생각했다. 그러나 test class 하나에서만 사용되는 초기화 로직의 경우 각 test class에서 private method를 통해 클래스 내에서 초기화 로직을 볼 수 있었으면 좋다고 생각했다.

 

예를 들어 26곳에서 사용되는 범용적인 초기화 로직인 generateMate의 경우, generator가 분담을 한다.

 

그러나, test class 하나에서만 사용되는 다음 메서드는 test class 내의 private method로 정의한다.

generateNotification(Mate mate, NotificationType type, NotificationStatus status)

 

 


두번째로 default 설정값이 한곳에서 관리되었으면 좋겠다

fixture 생성을 위한 기본 설정값을 한 곳에서 파악하고 관리하면 좋겠다고 생각했다

즉, default 값을 기본으로 하되 설정하고 싶은 파라미터만 setting 하는 로직이 들어가면 좋겠다고 생각했다.


 

그럼 본격적인 리팩터링 과정을 톺아보자

FixtureMonkey Refactoring

 

step1 : 기본 설정값인 FixtureBuilder 제작

fixture를 찍어낼 수 있는 기본 설정값 기반의 builder 들을 반환할 수 있는 FixtureBuilder를 구현했다.

 

먼저 FixtureMonkey 기본 introspector 설정과 bean validation을 fixture 생성시 반영해주는 plugin을 설정해준다.

public static final FixtureMonkeyBuilder fixtureMonkeyBuilder = FixtureMonkey.builder()
            .objectIntrospector(FieldReflectionArbitraryIntrospector.INSTANCE) //필드를 리플렉션을 활용해 주입
            .plugin(new JakartaValidationPlugin()); //자카르타 검증 범위에 맞게 객체 생성

 

 

이후 각 fixture의 기본 설정을 한 ArbitraryBuilder<T>들을 구현해주었다.

예를 들어 meetingBuilder의 경우는 다음 기본값들을 setting 해줄 수 있다.

     public static ArbitraryBuilder<Meeting> meetingBuilder() {
        return fixtureMonkeyBuilder.build()
                .giveMeBuilder(Meeting.class)
                .set("id", null)
                .set("target", Fixture.TARGET_LOCATION)
                .set("inviteCode", InviteCodeGenerator.generate())
                .set("date", TimeUtil.nowWithTrim().toLocalDate())
                .set("time", TimeUtil.nowWithTrim().toLocalTime())
                .set("overdue", false)
                .set("createdAt", TimeUtil.nowWithTrim())
                .set("updatedAt", TimeUtil.nowWithTrim());
    }

 

fixture의 기본 설정값들은 앞으로 이 메서드 하나에서 확인하고 관리할 수 있다


step2 : 필요한 설정값만 빌더에서 커스터마이즈한다

fixture를 만드는 빌더는 같은 필드를 대상으로 중복된 set을 할 경우 가장 최근 set 값으로 덮어씌워진다.

    @DisplayName("set을 덮어쓸 경우 덮어씌워진 값으로 초기화된다")
    @Test
    void setTest(){
        Meeting meeting = meetingBuilder().sample();

        assertThat(meeting.getDate()).isEqualTo(TimeUtil.nowWithTrim().toLocalDate());
        assertThat(meeting.getTime()).isEqualTo(TimeUtil.nowWithTrim().toLocalTime());

        Meeting renewMeeting = meetingBuilder()
                .set("date", LocalDate.of(2024, 10, 12))
                .set("time", LocalTime.of(9,0,0))
                .sample();

        assertThat(renewMeeting.getDate()).isEqualTo(LocalDate.of(2024, 10, 12));
        assertThat(renewMeeting.getTime()).isEqualTo(LocalTime.of(9,0,0));
    }

 

따라서 사용자는 기본 설정값이 들어간 builder를 반환받고 새로운 설정값이 필요한 부분에 한해서만 파라미터를 setting하여 원하는 fixture를 찍어낼 수 있게 된다.

 

예를 들어 기본 설정값을 가진 fixture를 반환하겠다고 하면 다음과 같이 fixture를 찍어내면 된다.

meetingBuilder().sample();

 

그러나, 약속 시간을 세팅하고 싶다면 기본 설정값을 기반으로 한 meetingBuilder에서 약속과 시간만 바꾸어 fixture를 찍어낸다.

Meeting renewMeeting = meetingBuilder()
                .set("date", LocalDate.of(2024, 10, 12))
                .set("time", LocalTime.of(9,0,0))
                .sample();

 

 


step3 : FixtureGenerator의 역할을 명확히 규정한다.

fixtureGenerator는 다음 3가지 역할을 맡도록 했다

 

1. 기본 설정값인 fixture 찍어내기

기본 설정값을 가진 fixture를 찍어낸다

 

 

2. test class에서 커스터마이즈한 객체 영속화하고 반환하기

이제 fixture 커스터마이즈의 역할은 각 테스트 클래스로 넘어간다. fixtureGenerator는 각자 테스트 클래스가 필요한 설정을 커스터마이즈하여 찍어낸 객체들을 저장하게만 해주었다.

 

 

3. 2개이상의 클래스에서 범용적으로 쓰이는 메서드만 크로스 커팅하기


범용적인 fixture 초기화 로직들만 generator에서 책임을 지도록 리팩터링 해보았다.

 

이외에 하나의 test class에서만 사용되는 초기화 로직들은 각 test class에서 초기화했다.

예를 들어 앞서 하나의 클래스에서만 사용했던 generateNotification은 test class 내에 다음과 같이 정의했다.


무엇을 얻었나?

 

1. default 설정값이 한곳에서 관리된다

Fixture의 기본 설정값이 보고 싶다? > FixtureBuilder에서 기본 설정값이 담긴 builder를 확인한다

 

2. given 절 생성코드를 줄인다는 본래 목적을 잘 달성한다

Fixture의 본래 목적인 setUP cost를 줄인다는 목적을 잘 달성할 수 있었다.

특히 필요한 부분만 setting이 가능하니 더욱 손쉬우면서도 test class 내에서 설정정보를 명확히 볼 수 있다.

 

3. fixtureGenerator를 조금은 가볍게 만들 수 있다

generator가 기본 fixture 반환 / 커스터마이즈 fixture 저장하고 반환 / 2가지 이상 test class에서 사용되는 fixture 생성 이렇게 3가지로 규정되면서 모든 fixture 생성로직이 generator에 몰리지 않는다

 

다만 그 효과가 체감될 정도인지는 아직 모르겠다

 


무엇이 우려되나?

그런데 도입을 하면서도 느꼈지만 연관관계가 복잡하거나 필드가 많은 객체의 경우 세팅하기가 쉽지 않았다.

fixtureMonkey를 사용하며 아쉬운 점들을 이야기해보자면

 

1. 자바의 흑마법 리플렉션 기반

기본적으로 리플렉션 기반 setting이라 private을 더이상 private이 아니게 만든다. 접근 제어자를 무용지물로 만드는 fixture 생성전략이라 OOP 패러다임과는 충돌이 난다.  이는 생성자의 의미를 퇴색하게 만들고 setter를 다 열어둔 것과 같은 느낌을 주게 된다.  

 

fixture 생성을 위해 이러한 부분을 포기하기엔 너무나 중요한 가치라 리팩터링을 하면서도 어딘가 찜찜했다.

 


2. 필드명을 문자열로 관리

fixture를 커스터마이징할 때 필드명을 문자열로 관리하게 된다.

그리고 이 문자열에 일치하지 않는 필드가 없더라도 오류를 반환하지 않는다.

 

만약 내가 필드명을 변경하게 된다면 어떻게 될까? > 테스트가 다 깨질 것이다.

만약 내가 필드명에 오타를 내었다면 어떻게 될까? > 테스트가 왜 통과하지 않는지 추적이 어렵다

 


3. 생성로직의 분산

private method와 fixtureGenerator 두 곳에서 fixture를 생성하는 로직이 분산되어 있다보니 책임이 분산되는 느낌도 있었다. 한 곳에서 fixture 생성로직이 관리되지 않다보니 어떻게 보면 관리 포인트를 다중화하는 것은 아닐까 하는 우려도 있었다.

 


느낀 점

테스트 Fixture에 대해서 깊게 고민한 순간이 많지 않았는데 프로젝트 테스트 관리에 관심을 가지고 개선하기 위해 노력했던 순간들을 기록했다는 것만으로 큰 의미가 있었다.

 

무언가를 디깅하는 순간에는 항상 어떤 결론에 대한 조그마한 확신이 들었는데 fixtureMonkey는 내가 적용을 잘못한 것인지 연관관계가 있는 Entity 상황에서 사용되기가 힘든 것인지 오히려 무언가 명확한 결론보단 혼란이 가중되었다.

 

리팩터링 과정에서 느꼈던 장점보다 앞으로 우려되는 단점들이 더 커졌고, 그 트레이드 오프가 조금 크다는 생각이 들었다. 리플렉션 기반 코드가 OOP에서 벗어나있다는 주관 때문에 느끼는 감정일 수도 있다.

 

내일 우테코 크루들과 이 주제에 대해 토론해보기로 하였는데 토론 후의 정리된 결론을 다시한번 녹여보고자 한다.