Test를 잘짜기 위해서 알아야 할 것은 Test Fixture입니다.

김재연·2025년 8월 30일
post-thumbnail

Test는 왜 중요할까요?

실무에서 개발을 진행하다 보면 테스트는 정말 중요하다는 것을 느낍니다.

우아한테크코스에서 교육을 받을 때를 회상해보면, 코치님이 테스트 코드가 없으면 불안해서 배포를 못 하기에 테스트 코드를 짠다고 말씀하셨을 때, 완전하게 공감이 되지는 않았던 것 같습니다.

하지만, 현재는 완전하게 공감하고 있습니다.

테스트가 없는 코드를 배포할 때면, 마치 낭떠러지 위에 서 있는 것 같은 불안함을 느끼기도 합니다.

하지만, 테스트 코드와 함께라면 불안하지 않습니다. 심지어 운영 환경에서 어떠한 에러가 발생했다고 하더라도, 코드에는 문제가 없을 것이라는 것을 확신할 수 있습니다.

Test Fixture 왜 사용해야 할까요?

이렇게 테스트는 개발자의 정신 건강을 지켜줍니다.

하지만, 테스트 짜는 것에 익숙하지 않은 상태에서 테스트를 짜는 것은 쉽지 않습니다.

처음 테스트를 접하게 되면 이것을 도대체 왜 짜는지에 대한 의문과, 테스트 코드 짜는 시간으로 인한 생산성도 굉장히 떨어지기 때문입니다.

물론 테스트에 어느 정도 익숙해지면 알게 됩니다. 테스트 코드를 짜는 시간은 오히려 생산성을 높이고 있는 시간이라는 것을 말입니다.

조금 더 복잡한 일을 맡게 되면 맡게 될수록 로직이 점차 어려워질 가능성이 높습니다. 그렇게 되면, 본인의 코드를 확인하기 위해 실제로 애플리케이션을 가동하고 테스트를 진행해봐야 합니다.

하지만, 테스트는 이 검증 과정을 굉장히 신속하게 만들어줍니다.

Test Run 딸깍 한 번이면, 로직을 검증해주죠.

마치 코딩 테스트를 볼 때, 코드를 작성하고 제출을 하게 되면 어떠한 테스트 케이스가 틀렸고, 맞았는지를 보여주며 나의 코드가 잘못되었는지 잘못되지 않았는지를 확인해주는 것과 동일합니다.

이렇게 좋은 테스트 코드를 조금 더 쉽게 작성하는 방법이 있습니다.

이는, 바로 Test Fixture를 사용하는 것입니다.

Test Fixture란 테스트에서 사용될 Mock Data를 미리 만들어두는 행위를 말합니다.

class Fixture {
	public static final Port port = new Port(...)
}

이런 식으로 Port를 생성해놓으면

사용처에서는 Fixture.port 이런 식으로 사용하면 됩니다.

Test Fixture Static으로 그냥 선언해 놓는 것 맞을까요?

하지만, 이렇게 static하게 미리 만들어두는 형식은 좋지 않습니다.

만일 JPA를 사용하고, 저장 행위를 위 Fixture를 사용해서 진행하게 된다면, 해당 객체의 id가 주입될 것입니다.

이는, 다른 테스트에게 영향을 줄 수 있으며 그렇다면 테스트의 독립성을 깨트려 테스트를 더욱 짜기 어렵게 만들 것입니다.

그래서, 다음과 같이 메서드로 Fixture를 제공하는 편이 좋습니다.

public static Port port() {  
    return new Port(...);  
}

이렇게 되면 위의 경우를 방지할 수 있습니다.

Test Fixture 굉장히 편하지 않나요? 근데, 뭔가 생성할 때 공을 조금 덜 들이고 싶다는 생각도 했습니다.

Test Fixture를 구현해보자.

저희 회사의 테스트 코드는 최대한 예상치 못한 경우도 테스트하려 노력합니다.

그렇기 때문에, 객체의 필드에 무작위의 값을 집어넣기도 합니다.

이는 Naver의 Fixture Monkey라는 라이브러리를 먼저 사용해보고, 여기서 아이디어를 착안해왔습니다.

랜덤 값을 넣기 위해 저희는 다음과 같은 코드를 사용합니다.

public class RandomGenerator {  
  
    private static final Random RANDOM = new Random();  
    private static final double NULL_VALUE_PROBABILITY = 0.1;  
  
    public static String generateNonNullString(int length) {  
        return RandomStringUtils.randomAlphabetic(length);  
    }  
  
    public static String generateString(int length) {  
        return generateNullAbleObject(() -> RandomStringUtils.randomAlphabetic(length));  
    }  
  
    public static Integer generateNonNullNumeric(int length) {  
        return Integer.parseInt(RandomStringUtils.randomNumeric(length));  
    }  
  
    public static Integer generateNumeric(int length) {  
        return generateNullAbleObject(() -> Integer.parseInt(RandomStringUtils.randomNumeric(length)));  
    }  
  
    public static Long generateNonNullLong(int length) {  
        return Long.parseLong(RandomStringUtils.randomNumeric(length));  
    }  
  
    private static <T> T generateNullAbleObject(Supplier<T> supplier) {  
        double probability = RANDOM.nextDouble();  
  
        if (probability < NULL_VALUE_PROBABILITY) {  
            return null;  
        }  
  
        return supplier.get();  
    }  
  
    public static Double generateNonNullDouble(int numberLength, int decimalLength) {  
        return getRandomDouble(numberLength, decimalLength);  
    }  
  
    private static double getRandomDouble(int numberLength, int decimalLength) {  
        return Double.parseDouble(  
                RandomStringUtils.randomNumeric(numberLength) +  
                        "." + RandomStringUtils.randomNumeric(decimalLength)  
        );  
    }  
  
    public static Double generateDouble(int numberLength, int decimalLength) {  
        return generateNullAbleObject(() -> getRandomDouble(numberLength, decimalLength));  
    }  
  
    public static boolean generateBoolean() {  
        return RANDOM.nextBoolean();  
    }  
  
    public static <ENUM extends Enum> ENUM generateEnum(Class<ENUM> enumClass) {  
        ENUM[] enumConstants = enumClass.getEnumConstants();  
        return generateNullAbleObject(() -> enumConstants[RANDOM.nextInt(enumConstants.length)]);  
    }  
  
    public static <ENUM extends Enum> ENUM generateNonNullEnum(Class<ENUM> enumClass) {  
        ENUM[] enumConstants = enumClass.getEnumConstants();  
        return enumConstants[RANDOM.nextInt(enumConstants.length)];  
    }  
  
    public static Character generateNonNullCharacter() {  
        return RandomStringUtils.randomAlphabetic(1)  
                .charAt(0);  
    }  
  
    public static Character generateCharacter() {  
        return generateNullAbleObject(  
                () -> RandomStringUtils.randomAlphabetic(1).charAt(0)  
        );  
    }  
  
    public static UUID generateNonNullUUID() {  
        return UUID.randomUUID();  
    }  
  
    public static UUID generateUUID() {  
        return generateNullAbleObject(UUID::randomUUID);  
    }  
  
}

무작위로 Null을 발생시키는 로직도 존재합니다.

이는, Nullable한 필드가 존재할 때, Null이 아닌 값과 Null인 값을 테스트를 짜는 이가 굳이 신경 쓰지 않아도 고루고루 테스트할 수 있도록 하기 위한 하나의 수단입니다.

암튼, 이를 활용하여 Test Fixture를 최대한 팀에서 편하게 사용할 수 있게 다음과 같이 진행했습니다.

public class ShipFixture {  
  
    private static Ship generate() {  
        return Ship.builder()  
                .shipId(UUID.randomUUID())  
                ...  
                .build();  
    }  
  
    public static Ship create() {  
        return generate();  
    }  
  
    public static <T> Ship createWithSetColumn(ShipColumn columnName, T value) {  
        return FixtureSetFieldUtils.createWithSetColumn(ShipFixture::generate, columnName, value);  
    }  
  
    @SafeVarargs  
    public static Ship createWithSetColumns(Pair<ShipColumn, ?>... columns) {  
        return FixtureSetFieldUtils.createWithSetColumns(ShipFixture::generate, columns);  
    }  
  
}
public enum ShipColumn implements FixtureColumn {  
  
    SHIP_ID("shipId"),  
    ...  
    ;  
  
    private final String columnName;  
  
    ShipColumn(String columnName) {  
        this.columnName = columnName;  
    }  
  
    public String getColumnName() {  
        return columnName;  
    }  
  
}
public interface FixtureColumn {  
  
    String getColumnName();  
  
}

이처럼 Fixture를 구현하고 사용처에서는 다음과 같이 사용하였습니다.

  • MMSI 지정
Ship ship = ShipFixture.createWithSetColumn(ShipColumn.MMSI, "mmsi");
  • MMSI, SHIP ID 컬럼 지정
Ship ship = ShipFixture.createWithSetColumns(  
        Pair.of(ShipColumn.MMSI, "mmsi"),  
        Pair.of(ShipColumn.SHIP_ID, UUID.randomUUID())  
);
  • 완전 무작위
Ship ship = ShipFixture.create();

처음에는 이렇게 진행하였습니다.

구현도 나름 복잡하다면 복잡하지만, 정말 단점은 Reflection을 통해 추후 Value를 지정한 값으로 주입해주는 구조로 인해, 불변 객체의 경우 수정할 수 없었고 (Record는 Fixture로 만들지 못했습니다.), Pair 객체의 특성으로 인해 여러 개의 컬럼 값을 지정하는 경우, Null을 주입해줄 수 없었습니다. (Pair는 Null을 Value로 받게 되면 Exception이 터지는 구조입니다.)

무엇보다.. 너무 준비 과정이 길었습니다. 저희 도메인 특성상 하나의 객체가 가지는 필드가 너무나도 많고, 또한 시시각각 새로운 Entity가 추가되어갔습니다. 이런 모든 Entity의 Fixture를 모두 하나하나 구현하고 있다면 시간이 너무나도 많이 필요했습니다.

그래서 다음과 같은 형식으로 Fixture 생성 방식을 변경하였습니다.

public static Ship.ShipBuilder generate() {  
    return Ship.builder()  
            .shipId(UUID.randomUUID())  
            ...  
            ;  
}

Builder를 반환하는 형식입니다.

이렇게 하게 되면 사용처에서는 어떻게 사용하면 될까요?

Ship ship = ShipFixture.generate()  
        .mmsi("mmsi")  
        .build();

이렇게 하게 되면 설정한 mmsi 값을 제외하고는 모두 랜덤 값으로 세팅되게 됩니다.

이전과 동일한 성질을 가진 Mock Data를 얻어낼 수 있는 창구를 단 몇 줄만으로 이루어냈습니다.

이처럼 이 방식의 장점은 구현이 정말 간단하다는 점에 있습니다.

하지만, 단점으로는 Builder를 구현해주어야 한다는 단점이 있어요.

Fixture로 만들고 싶은 객체에는 Builder 어노테이션을 붙여주어야 하죠.

하지만, Kotlin으로 진행하게 되면 어떻게 될까요?

Kotlin에서 Test Fixture를 구현해보자.

fun generate(  
    id: Long = RandomGenerator.generateNonNullNumeric(2).toLong(),  
    name: String = RandomGenerator.generateNonNullString(5),  
    schoolType: SchoolType = RandomGenerator.generateNonNullEnum(SchoolType::class),  
    region: String = RandomGenerator.generateNonNullString(5),  
    address: String = RandomGenerator.generateNonNullString(5),  
): School {  
    return School(  
        id = id,  
        name = name,  
        schoolType = schoolType,  
        region = region,  
        address = address  
    )  
}

저는 Wespot이라는 사이드 프로젝트를 운영하고 있습니다. 거기에서 사용되었던 Fixture의 일부분을 예시로 가져와 봤는데요.

default parameter로 이전과 동일한 동작을 구현할 수 있음을 보여주고 있습니다.

이를 사용하기 위해서 사용처는 어떻게 사용할 수 있을까요?

val school = SchoolFixture.generate(id = 1, name = "위스팟 학교")

이렇게 사용할 수가 있습니다.

정말 간단해졌죠? Kotlin에서는 Java에서의 단점마저 이처럼 해결할 수 있습니다.

Kotlin의 매력은 이 이외에도 참 여러 가지가 있습니다. 회사에서도 정말 코틀린을 사용하고 싶은 마음이 굴뚝같아집니다.

마무리

긴 글 읽어주셔서 감사합니다.

Test Fixture의 도움을 받아 에러 없는 멋진 서비스를 모두 만드셨으면 합니다.

profile
끊임없이 '성장'하는 개발자 김재연입니다.

0개의 댓글