본문 바로가기

Spring-Boot

테스트 코드의 여러가지 팁

깔끔한 Application.class 유지하기

@ComponentScan({"com.sample.product"})
@EnableJpaAuditing
@ConfigurationPropertiesScan
@SpringBootApplication
public class SampleApplication {}

메인 애플리케이션 클래스에 @ComponentScan과 같은 어노테이션을 추가하면, 애플리케이션이 로드될 때 불필요한 스캔이 이루어질 수 있다. 이는 특히 테스트 환경에서 문제가 될 수 있다.

 

예를 들어 @WebMvcTest를 사용하여 컨트롤러 단위 테스트를 수행할 때, 이 어노테이션은 기본적으로 컨트롤러와 관련된 컨포넌트(application.class 포함)만 스캔한다. 이때 @ComponentScan이 application.class 에 있으면, 불필요한 컨포넌트들이 스캔되면서 테스트에 불필요한 영향을 줄 수 있다.

 

마찬가지로, @EnableJpaAuditing도 JPA 관련 컨포넌트들을 스캔하게 만드는데, 테스트 환경에서 이러한 빈들이 존재하지 않으면 예외가 발생할 수 있다.

이러한 문제를 방지하기 위해, 애플리케이션 클래스와는 별도로 설정 클래스를 분리하는 것이 좋다

@Configuration
@ComponentScan({"com.sample.product"})
public class PackageComponentScan {}

@Configuration
@EnableJpaAuditing
public class JpaAuditingConfiguration {}

@ConfigurationPropertiesScan
@SpringBootApplication
public class SampleApplication {}

이렇게 분리함으로써, 테스트에서 필요하지 않은 빈들이 스캔되지 않도록 설정할 수 있다.

static 메서드를 사용한 유틸리티 코드 사용 자제

public class UuidGenerator {
    public static String generateUUID() {
        return UUID.randomUUID().toString();
    }
}

프로젝트에서는 Utils라는 이름으로 많은 static 메서드들이 사용되곤 하는데 이러한 코드는 가짜 구현체로 대체하거나 모킹하기가 어려워 테스트가 힘들어진다.

 

Mockito에서는 mockStatic을 제공하여 static 메서드를 모킹할 수 있지만 안티패턴이므로 권장하지 않는다.

흔히 사용되는 static 메소드인 LocalDateTime.now() 를 보면 테스트가 가능하도록 Clock을 받아 동작하는 메소드를 제공하고있다.

/**
	지정한 시계에서 현재 날짜-시간을 가져옵니다.
	이것은 현재 날짜-시간을 얻기 위해 지정된 클록을 질의할 것입니다. 
	이 방법을 사용하면 테스트를 위해 대체 클록을 사용할 수 있습니다.
	대체 클록은 의존성 주입을 사용하여 도입될 수 있습니다.
**/
public static LocalDateTime now(Clock clock) {
    Objects.requireNonNull(clock, "clock");
    final Instant now = clock.instant();  // called once
    ZoneOffset offset = clock.getZone().getRules().getOffset(now);
    return ofEpochSecond(now.getEpochSecond(), now.getNano(), offset);
}
public static LocalDateTime now() {
    return now(Clock.systemDefaultZone());
}

예를 들어

@Configuration
public class ClockConfig {
    @Bean
    public Clock clock() {
        return Clock.systemDefaultZone();
    }
}

@Service
@RequiredArgsConstructor
class TimeService {
    private final Clock clock;
    public void test() {
        LocalDateTime now = LocalDateTime.now(clock);
    }
}

이와 같이 의존성을 주입받아 사용하면, 테스트 환경에서 대체할 수 있는 구현체를 제공할 수 있어 테스트가 용이해진다.

 

static 메서드를 사용하지 않는 것이 가장 좋지만, 불가피한 경우에는 해당 static 메서드를 호출하는 Bean 객체를 만들어 사용하는 것을 권장한다.

public class UuidGenerator {
    public static String generateUUID() {
        return UUID.randomUUID().toString();
    }
}

@Component
public class IdGenerator {
    public String uuid(){
        return UuidGenerator.generateUUID();
    }
}

테스트하기 어려운 코드는 레이어의 가장 바깥에 위치시킬 것

테스트하기 쉬운 코드는 항상 같은 결과를 반환하며 외부 상태를 변경하지 않는 코드를 의미하며 반대로 외부 상태를 변경하거나 외부에 의존하는 코드는 테스트하기 어려운 코드라고 볼수있다. (데이터베이스 CRUD, 콘솔 출력 등)

테스트하기 어려운 코드가 레이어의 가장 안쪽에 위치하면, 그 레이어의 모든 코드가 테스트하기 어려워진다.

 

예를 들어, 회원가입 로직을 작성할 때 이메일 검사나 비밀번호 검사와 같은 테스트하기 쉬운 코드와, 데이터베이스에 저장하는 테스트하기 어려운 코드를 분리해야 한다.

 

테스트하기 쉬운 코드와 어려운 코드는 반드시 바운더리 레이어에서 만나야 한다.

💡 프로젝트 아키텍처가 레이어드 라면 facade , 헥사고날 이라면 service 이며 이 둘이 만나는 테스트는 인수테스트 또는 통합 테스트로 진행한다.

서비스 간 합성은 인터페이스를 통해 구현할 것

A 서비스에서 B 서비스를 사용하는 경우, A 서비스가 B 서비스의 구현체를 직접 참조하면, B 서비스를 생성하는 것도 복잡해진다. 이 경우, B를 인터페이스로 참조하면, 테스트에서 B를 쉽게 모킹하거나 테스트 더블 객체로 대체할 수 있다.

// Bad
class UserService {
  private final MessageHandler handler;
}
class MessageHandler {}

---
// Good
class UserService {
 private final MessageHandler handler;
}
class MessageResolver implements MessageHandler {}
class MessageHandler {}

리포지토리에서 엔티티를 찾지 못할 경우 예외를 던질 것

Service 레이어에서 Repository레이어에 Entity 를 요청했을 때 Entity를 찾지 못하는 경우 Repository에서 옵셔널을 내려주게 되면 
Service 레이어에서 옵셔널을 받았을 때에 대한 테스트 요소가 추가가 된다. Repository에서는 entity를 못 찾는 경우 예외를 전달하여 테스트 범위가 넘어가는 걸 방지하자

assertj 와 junit 중에 assertj를 사용하자

스프링팀은 assertj 를 사용하고 있으며 Junit 팀은 자신의 도구로도 충분하지만 만약 부족하다면 assertj를 쓰라고 권장하고 있다.

JUnit의 assertEquals와 같은 메서드는 기대값과 실제값을 두 개의 파라미터로 받는 반면, AssertJ는 단일 파라미터를 체인 형식으로 사용하여 가독성이 뛰어나고 의미를 이해하기 쉽다.

 

예를 들어, 다음과 같은 코드로 유연하고 명확하게 테스트할 수 있다:

assertThat("str")
	.isEqualTo("str")
	.withFailMessage("not equals str")

assertThat(2)
        .isNotZero()
        .isGreaterThan(1)
        .isGreaterThanOrEqualTo(1);

assertThat("hello")
        .as("해당 테스트 실패 시 출력할 설명 메시지")
        .isNotNull()
        .startsWith("h")
        .endsWith("o");

assertThatThrownBy(() -> {
    throw new RuntimeException("error message");
}).hasMessage("error message");

assertThatExceptionOfType(RuntimeException.class)
        .isThrownBy(() -> {
            throw new RuntimeException("error message");
        })
        .withMessage("error message");

List<String> list = List.of("first", "second");

assertThat(list)
        .isNotNull()
        .contains("first")
        .containsAll(list)
        .isEqualTo(list);

테스트 메서드 이름에 대상의 이름을 포함하지 말자

테스트 메서드의 명명 규칙은 다양하지만, 테스트 대상의 이름을 사용하여 작성하는 경우 대상의 이름이 변경 시 테스트 메서드 명도 변경해야 하기 때문에 좋지 않다.


또한 기능을 기술하여 작성한 이름이면 일관성이 떨어질 가능성이 있다.

Given, Should, When, Then 단어를 활용하여 작성하는 방법이 좋다고 생각한다. (Should_ThrowException_When_AgeLessThan18)

예: Should_ThrowException_When_AgeLessThan18

테스트 코드도 코드다. 상속 대신 합성을 사용하자

테스트 코드도 코드이므로, 중복을 줄이기 위해 상속을 사용하는 것은 바람직하지 않다. 상속은 테스트 코드에서도 불필요한 복잡성과 의존성을 유발할 수 있다. 따라서 중복 코드를 합성을 통해 적절히 분리하여 관리하는 것이 좋다.

반복 테스트에는 @ParameterizedTest를 활용하자

반복적인 테스트를 위해 @ParameterizedTest를 사용하는 것이 좋다.

public static Stream<Arguments> getInstanceParameters() {
    return Stream.of(
            arguments(null, "ABC", "DEF"),
            arguments(UUID.randomUUID(), null, "DEF"),
            arguments(UUID.randomUUID(), "ABC", null)
    );
}

@ParameterizedTest(name = "인스턴스 생성 시 Null 허용 안함: id = {0}, loginId = {1}, loginPwd = {2}")
@MethodSource("getInstanceParameters")
void failCreateInstance(UUID id, String loginId, String loginPwd) {
    Assertions.assertThatThrownBy(() -> Member.withId(id, loginId, loginPwd))
            .isInstanceOf(IllegalArgumentException.class);
}

테스트 메서드에는 public을 사용하지 말자

Junit5에는 test method에 public 메서드를 붙이지 말고 default 접근 제한자를 사용하자. SonarLint에서도 public test method를 사용하지 말고 default 접근 제한자를 사용하라고 권장한다.

테스트 코드는 쉬운 테스트부터 작성하자

테스트를 작성할 때는 예외 처리나 실패 테스트처럼 간단한 테스트부터 시작하는 것이 좋다. 
성공 테스트는 상대적으로 복잡한 로직을 테스트해야 하기 때문에 피드백이 느려질 수 있으며, 성공 테스트를 먼저 작성하고 나서 실패 테스트를 추가하면 기존 로직에 불필요한 분기를 추가하게 된다.

테스트 작성이 용이한 코드는 좋은 코드이며, 피드백이 빠른 테스트 코드는 좋은 테스트 코드이다.

💡 단, 테스트 대상이 올바른지 항상 확인하는 것이 중요.

하나의 클래스 파일에 하나의 테스트 클래스 파일로 관리하자

서비스 클래스에 두 개의 public 메서드가 있다고 가정해본다면 이 두 메서드를 각각 하나의 테스트 클래스에 넣을지, 아니면 하나의 클래스에 모두 포함시킬지 고민될 수 있다.

하나의 클래스에 모두 포함시키면 파일의 크기가 커지지만, 두 개의 클래스로 나누면 테스트 클래스와 대상 클래스 간의 관리가 복잡해질 수 있다. 대부분의 프로젝트에서는 테스트 클래스를 하나로 처리하고 파일 크기에 크게 신경 쓰지 않기에 이 방식을 따라가는게 좋아보인다.

Final 클래스는 Mockito로 Mocking할 수 없으니 MockMaker를 사용하자

Final 클래스는 기본적으로 Mocking할 수 없다. Final 키워드를 제거하는 대신, MockMaker를 통해 바이트 코드 조작으로 Mocking을 처리하는 것이 더 좋은 접근이다.

다음과 같은 경로와 파일을 생성한 후, 파일에 mock-maker-inline 내용을 추가한다.

touch /test/resources/mockito-extensions/org.mockito.plugins.MockMaker

이 설정을 통해 Final 클래스도 Mockito로 Mocking할 수 있다.

BDD 스타일로 테스트할 때는 BDDMockito를 사용하자

void test() {
    // given
    String loginId = "";
    Mockito.when(memberRepository.findById(loginId))
            .thenReturn(Optional.of(new Member()));

    // when
    Token token = memberService.login(loginId);
    // then
    Assertions.assertThat(token).isNotNull();
    Mockito.verify(memberRepository, Mockito.times(1)).findById(loginId);
}

위와 같은 given-when-then 구조의 테스트에서, Mockito.when() 메서드는 given 영역에 있어 부적절해 보일 수 있다. 이런 경우 BDDMockito를 사용하면 더 자연스럽고 읽기 쉬운 테스트 코드를 작성할 수 있다.

void test() {
    // given
    String loginId = "";
    BDDMockito.given(memberRepository.findById(loginId))
            .willReturn(Optional.of(new Member()));
    // when
    Token token = memberService.login(loginId);
    // then
    Assertions.assertThat(token).isNotNull();
    BDDMockito.then(memberRepository).should().findById(loginId);
}