Test Fixture Factory 구현을 통한 테스트 코드 가독성, 생산성 개선

문제 상황 정의

 

생산성 저하

 
우리 팀에서는 땅콩 개발 초기에, 테스트 코드를 작성할 때 위와 같은 init-test.sql 파일을 사용했었다.
 
초반에는 위와 같이 사용함으로 인해 테스트 코드 작성 시에 굉장히 길어질 수 있는 데이터 초기화 로직을 제거할 수 있었다.
미리 init-sql에 정의되어 있는 값만 가져다가 사용하면 되어서 편리하다고도 생각했다.
 
하지만 위 방식에서는 문제점이 있었다.
 

 
데이터가 얼마 없을 때는 편리할 줄 알았으나
테스트 코드에서 사용할 데이터를 참조할 때 매번 init-test.sql 파일에 접근해서 사용할 데이터의 ID값을 확인해야하는 것이 굉장히 불편했다.
 
이로 인해 테스트 코드 작성의 생산성이 저하 되었다.
 
 

가독성 저하

 
생산성 저하로 인해서 잘 사용하지 않게 되자, 결국 테스트 코드는 점점 init-test.sql 파일을 사용하지 않았던 형태로 돌아가게 되었다.
 
그러다보니 위와 같은 코드가 나타나게 되었는데, 매번 직접 테스트 코드에 필요한 데이터를 테스트 코드에 작성해주고 있으니
이번에는 가독성에 이어 생산성이 함께 저하 되었다.
 
 

테스트 코드를 위해 열려있는 생성자

public static Room createNewRoom() {
        String uuid = UUID.randomUUID().toString().replace("-", "");
        return new Room(uuid, START_ROUND, RoomStatus.READY, RoomSetting.createNewRoomSetting());
}

 
비즈니스 로직에서 Room 생성은 정해진 비즈니스 규칙 에 따라 위와 같이 세팅된 특정 값으로 생성되어야 했다.
하지만 createNewRoom() 만 열어두게 되면 테스트 코드에서 특정 상태를 가진 Room 객체 생성이 불가능한 문제가 있었다.
 
 

public Room(String uuid, int currentRound, RoomStatus status, RoomSetting roomSetting) {
        this.uuid = uuid;
        this.currentRound = currentRound;
        this.status = status;
        this.roomSetting = roomSetting;
}

 
때문에 어쩔 수 없이 위와 같은 생성자를 하나 만들어서 다양한 형태의 Room 객체 생성을 가능하게 열어둔 상태였다.
이러한 문제점을 가지고 작성되어있는 테스트 코드가 약 230개 정도 있었고, 테스트가 더 늘어나기 전에 이를 처리 해야겠다고 생각했다.
 
 


 
 

문제 해결 방안 선정

문제 해결을 위해서 Test Fixture Factory 객체를 만들어서 사용하는 방법을 선택하게 되었다.
 

Test Fixture란?

테스트 픽스처(Test Fixture)는 테스트가 매번 일관성있게 작동할 수 있도록 환경을 준비하고 유지하는데 필요한 코드, 데이터, 설정 등을 의미한다.
나는 이러한 테스트 픽스처를 세팅해주는 Fixture Factory 객체를 만들어 테스트가 일관성있게 작동할 수 있는 환경을 쉽고 간편하게 구성할 수 있도록 개선해볼 생각이다.
 
 

Fixture Monkey

Fixture Monkey는 제어 가능한 임의의 테스트 객체를 생성하도록 설계된 Java 및 Kotlin 라이브러리이다.
이 기술도 사용해볼 수 있을 것 같았지만, 굳이 직접 처리할 수 있는 작업을 외부 라이브러리에 의존하고 싶진 않았다. 정말 없어선 안되는 것이 아니라면 최대한 외부 라이브러리 의존은 지양하고 있다. (직접 만들어서 쓰고 말지)
또 세부적인 Fixture 객체 값 컨트롤을 위해 그냥 직접 Fixture 클래스를 구현해서 사용하고자 하였다.
 
 


 

테스트 코드 개선

 

Fixture 관련 코드

Fixture를 생성하기 위한 코드로, 테스트를 위한 생성자 사용 문제 해결이 시급한 Room 을 기준으로 작성해보겠다.
 

RoomFixture.java

@Component
public class RoomFixture {

    private final RoomRepository roomRepository;

    public RoomFixture(RoomRepository roomRepository) {
        this.roomRepository = roomRepository;
    }

    public Room createNotStartedRoom() {
        return roomRepository.save(Room.createNewRoom());
    }
}

 
먼저 Room 픽스처를 생성하기 위한 RoomFixture 클래스를 생성하였다.
 
Room의 상태는 READY , PROGRESS , FINISH 가 존재한다.
그 중 READY 상태의 Room을 생성하기 위한 메서드 createNotStartedRoom() 을 먼저 만들어주었다.
이후에 필요에 따라 점점 픽스처 생성 메서드를 추가하면서 아래와 같이 변화되었다.
 
 

@Component
public class RoomFixture {

    private static final String CURRENT_ROUND_FIELD = "currentRound";
    private static final String STATUS_FIELD = "status";
    private static final String ROOM_SETTING_FIELD = "roomSetting";

    private final FixtureSettingManager fixtureSettingManager;
    private final RoomRepository roomRepository;

    public RoomFixture(FixtureSettingManager fixtureSettingManager, RoomRepository roomRepository) {
        this.fixtureSettingManager = fixtureSettingManager;
        this.roomRepository = roomRepository;
    }

    public Room createNotStartedRoom(int currentRound, int totalRound, int timeLimit, Category category,
                                     RoomStatus roomStatus) {
        RoomSetting roomSetting = new RoomSetting(totalRound, timeLimit, category);
        return createNotStartedRoom(currentRound, roomSetting, roomStatus);
    }

    public Room createNotStartedRoom(int currentRound, RoomSetting roomSetting, RoomStatus roomStatus) {
        Room room = Room.createNewRoom();
        setRoom(currentRound, roomSetting, roomStatus, room);
        return roomRepository.save(room);
    }

    public Room createNotStartedRoom() {
        return roomRepository.save(Room.createNewRoom());
    }

    public Room createProgressRoom() {
        int currentRound = 1;
        return createProgressRoom(currentRound);
    }

    public Room createProgressRoom(int currentRound) {
        int totalRound = 5;
        int timeLimit = 10_000;
        Category category = Category.IF;
        RoomStatus roomStatus = RoomStatus.PROGRESS;

        Room room = Room.createNewRoom();
        RoomSetting roomSetting = new RoomSetting(totalRound, timeLimit, category);
        setRoom(currentRound, roomSetting, roomStatus, room);

        return roomRepository.save(room);
    }

    public Room createProgressRoom(int currentRound, RoomSetting roomSetting) {
        RoomStatus roomStatus = RoomStatus.PROGRESS;

        Room room = Room.createNewRoom();
        setRoom(currentRound, roomSetting, roomStatus, room);

        return roomRepository.save(room);
    }

    public Room createFinishedRoom() {
        int currentRound = 5;
        int totalRound = 5;
        int timeLimit = 10_000;
        Category category = Category.IF;
        RoomStatus roomStatus = RoomStatus.FINISH;

        Room room = Room.createNewRoom();
        RoomSetting roomSetting = new RoomSetting(totalRound, timeLimit, category);
        setRoom(currentRound, roomSetting, roomStatus, room);

        return roomRepository.save(room);
    }

    private void setRoom(int currentRound, RoomSetting roomSetting, RoomStatus roomStatus, Room room) {
        fixtureSettingManager.setField(room, CURRENT_ROUND_FIELD, currentRound);
        fixtureSettingManager.setField(room, STATUS_FIELD, roomStatus);
        fixtureSettingManager.setField(room, ROOM_SETTING_FIELD, roomSetting);
    }
}

 
3가지 상태의 Room을 생성하기 위한 다양한 메서드를 Overloading 을 활용하여 객체지향적으로 구현하였다.
 
 

private void setRoom(int currentRound, RoomSetting roomSetting, RoomStatus roomStatus, Room room) {
        fixtureSettingManager.setField(room, CURRENT_ROUND_FIELD, currentRound);
        fixtureSettingManager.setField(room, STATUS_FIELD, roomStatus);
        fixtureSettingManager.setField(room, ROOM_SETTING_FIELD, roomSetting);
}

 
또 눈여겨볼 점은 최하위에 있는 setRoom() 메서드이다.
Reflection을 사용하는 FixtureSettingManager를 구현했고, 이를 통해 객체의 특정 필드에 특정 값을 채워넣을 수 있도록 구현하였다.
 
 

FixtureSettingManager.java

@Component
public class FixtureSettingManager {

    public void setField(Object targetObject, String fieldName, Object newValue) {
        try {
            Field field = targetObject.getClass().getDeclaredField(fieldName);
            field.setAccessible(true);
            field.set(targetObject, newValue);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new RuntimeException("Failed to set field value", e);
        }
    }
}

 
FixtureSettingManager 클래스의 내부이다.
 
Reflection을 사용하는 경우 필드의 네이밍이 변경되는 경우 관련된 테스트 코드를 찾아 모두 변경해주어야 한다.
이때 setField() 메서드를 구현하고 이를 사용해서 필드 값을 초기화하도록 하면, 이후에 Reflection을 수행하고 있는 코드를 찾기 수월할 것이라고 생각했다.
 
또 Reflection이 필요한 곳마다 생기는 코드 중복 제거되어 유지보수성도 증가된다.
 
 


개선 결과

<개선 전>

 

<개선 후>

 
위와 같이 Test Fixture 클래스 구현을 통해 결론적으로 코드는 위와 같은 형태로 개선되었다.
 
Fixture를 하나하나 생성하던 과정을 Fixture 객체에 위임함으로써 위와 같이 가볍고 가독성 좋은 형태로 개선 할 수 있었다.
또한 메서드에 객체 생성의 세부적인 로직을 위임하게되니 테스트 코드 생산성이 굉장히 향상됨을 느낄 수 있었다.
 
 

PR 링크

https://github.com/woowacourse-teams/2024-ddangkong/pull/400
전체적인 개선 내용이 궁금하다면 PR 참조