본문 바로가기
Coding/Java Spring

스프링 테스트 코드를 위한 어노테이션

by Hide­ 2023. 8. 22.
반응형

개요

테스트 코드를 작성하다보면 중복코드가 많이 발생한다. 예를 들어 서비스 레이어를 테스트하는 슬라이스 테스트인 경우 @ExtendWith(MockitoExtension.class)를 통해 Mokcito를 활성화시켜주고, 필요하다면 @ActiveProfiles를 통해 프로필을 설정하는 등의 코드들이 계속 생겨나는 모습을 볼 수 있다.

본 포스팅에서는 통합 테스트/유닛/레포지토리 테스트 코드 작성 시 코드 로직 작성에만 집중할 수 있도록 각 테스트 대상에 맞는 통합 어노테이션을 만들고 적용하는 과정에 대해 기술한다.

@TestEnvironment

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@ActiveProfiles("test")
@TestConstructor(autowireMode = AutowireMode.ALL)
public @interface TestEnvironment {}

테스트 환경임을 명시하는 어노테이션이다.

  • @ActiveProfiles: 활성화시킬 프로필을 명시한다. 나는 test환경을 위한 application.yml파일을 따로 관리하고 있고 해당 파일을 통해 설정값을 읽어오기 위해 사용한다.
  • @TestConstructor: autowireMode를 ALL로 설정함으로써 테스트 코드에서 @Autowired 어노테이션을 생략하기 위해 사용한다.

@UnitTest

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(MockitoExtension.class)
@TestEnvironment
public @interface UnitTest {}

레이어 별 테스트를 진행할 때 사용한다. 일반적으로 서비스 레이어에 해당할텐데, 모킹이 필요할 때 사용한다고 생각하면 된다. 물론 모킹이 필요하지 않을때는 굳이 사용할 필요가 없다.

  • @ExtendWith: Mockito를 활성화시키기 위해 사용한다.
  • @TestEnvironment: 테스트 환경임을 명시한다.

레포지토리 테스트

RepositoryTestConfig

@TestConfiguration
public class RepositoryTestConfig {
    @PersistenceContext private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) {
        return new JPAQueryFactory(entityManager);
    }
}

먼저 @TestConfiguration 어노테이션을 붙인 레포지토리 테스트 관련 설정 클래스를 하나 생성한다. 아래에서 설명하겠지만, 레포지토리 테스트는 @DataJpaTest 어노테이션을 사용하는데 해당 어노테이션은 @SpringBootTest와는 다르게 스프링의 모든 빈을 로드하지 않는다. 따라서 위처럼 @TestConfiguration을 붙인 클래스를 하나 생성하고 실제 사용처에서 @Import를 통해 빈으로 등록해줘야 사용할 수 있다.

@RepositoryTest

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@DataJpaTest
@TestEnvironment
@AutoConfigureTestDatabase(replace = Replace.NONE)
@ImportAutoConfiguration(DataSourceConfig.class)
@Import(RepositoryTestConfig.class)
public @interface RepositoryTest {}

레포지토리 레이어를 테스트할 때 사용한다. 일반적으로 Data JPA 또는 QueryDSL을 통해 생성한 레포지토리를 테스트할 때 사용한다.

  • @DataJpaTest: @SpringBootTest와는 다르게 JPA에 관련된 로드하기 때문에 비교적 속도가 빠르다. 또한 내부적으로 @Transactional 어노테이션이 달려있기 때문에 각 테스트가 종료되면 자동으로 롤백이 수행된다. 관련해서 재밌는 영상이 있는데 토스의 김재민님이 공개한 유튜브 영상이다. 해당 영상에서는 @Transactional 을 테스트에 사용하지 말라고 권장한다. @RepositoryTest 에서도 @DataJpaTest 어노테이션을 제거하고 영상에서 소개한 대로 적용이 가능하다. 다만 본 포스팅에서 이야기하는것은, 각 레이어 또는 목적에 맞게 테스트 관련 어노테이션을 통합하여 사용하는 것이라는 것을 기억해줬으면 한다.
  • @AutoConfigureTestDatabase: NONE으로 설정하면 @ActiveProfiles에 설정한 프로필 설정값에 따라 DataSource를 적용시킨다. 나는 내장 DB가 아닌 실제 MySQL을 도커로 띄워놓고 테스트를 진행하기 때문에 NONE으로 설정했다. 기본값은 Any이며 해당 값 사용 시, 자동으로 내장된 임베디드 DB를 사용한다.
  • @ImportAutoConfiguration: 현재 DB관련 설정을 Reader/Writer를 분리시킨 Routing DataSource 형태로 사용하고 있다. 그리고 해당 설정이 DataSourceConfig 클래스에 들어있는데, 해당 클래스를 빈으로 등록해주기 위해 사용한다. 위에서 말했듯이 @DataJpaTest는 JPA관련 빈만 로드하기 때문에 이 부분이 필수적으로 들어가야한다.
  • @Import(RepositoryTestConfig.class): 위에서 생성한 레포지토리 테스트 관련 설정 파일을 임포트한다. 이렇게해야 테스트 코드에서 동일한 EntityManager를 사용하기 때문에 테스트 시 문제가 발생하지 않는다.

통합 테스트

통합 테스트는 하나의 어노테이션이 아니라 추가적인 클래스도 생성해서 작업한다.

@IntegrationTest

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest
@AutoConfigureMockMvc
@TestEnvironment
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ExtendWith(RestDocumentationExtension.class)
@TestExecutionListeners(
        value = {IntegrationTestExecutionListener.class},
        mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
public @interface IntegrationTest {}
  • @SpringBootTest: 통합 테스트를 위해 모든 스프링 빈을 띄우기 위해 사용한다.
  • @AutoConfigureMockMvc: MockMvc를 사용하기 위해 사용한다. 참고로 @WebMvcTest와 같이 사용할 수 없다.
  • @ExtendWith: 내부적으로 Spring Rest Docs를 적용하기 위해 사용한다.
  • @TestExecutionListeners: 각 테스트 코드는 독립적으로 동작해야하기 때문에 테스트가 종료된 후 데이터베이스를 Clean up해주는 작업이 필요하다. 그리고 스프링에서는 AbstractTestExecutionListener를 상속받은 후 원하는 메소드를 오버라이드하여 사용할 수 있도록 방법을 제공해주고 있다. 관련해서는 링크를 참고한다.

BaseIntegrationTest

@IntegrationTest
public class BaseIntegrationTest {
    @Autowired protected MockMvc mockMvc;

    @Autowired protected ObjectMapper objectMapper;

    @Autowired EntityManager em;

    protected JPAQueryFactory queryFactory;

    @BeforeEach
    void setUp(WebApplicationContext context, RestDocumentationContextProvider provider) {
        queryFactory = new JPAQueryFactory(em);
        this.mockMvc =
                MockMvcBuilders.webAppContextSetup(context)
                        .addFilter(new CharacterEncodingFilter("UTF-8", true))
                        .alwaysDo(print())
                        .alwaysDo(
                                MockMvcRestDocumentation.document(
                                        "{class-name}/{method-name}",
                                        preprocessRequest(prettyPrint()),
                                        preprocessResponse(prettyPrint())))
                        .apply(MockMvcRestDocumentation.documentationConfiguration(provider))
                        .build();
    }
}

통합 테스트는 BaseIntegrationTest 클래스를 상속받아서 구현한다. 내부적으로 사용할 MockMvc, ObjectMapper, EntityManager와 JPAQueryFactory를 주입시켜두었다. 또한 setUp() 메소드에서 Spring Rest Docs를 적용하기 위한 코드를 넣어줬다. 해당 클래스를 상속받은 테스트 클래스는 위에서 정의한 것 중 필요한 객체를 가져다가 쓰기만 하면 된다.

정리

  • 통합 테스트: BaseIntegrationTest 클래스를 상속받아서 사용
    • 내부에 정의해둔 MockMvc, ObjectMapper등을 상속받은 클래스에서 가져다가 쓸 수 있음
  • 슬라이스 테스트: @UnitTest 어노테이션 사용
    • Mockito를 활성화 시켜두었기 때문에 @Mock, @InjectMocks를 통해 모킹 테스트 가능
  • 레포지토리 테스트: @RepositoryTest 어노테이션 사용
    • 본 포스팅의 코드 상 내장 DB를 사용하지않고 application.yml에 설정되어있는 구성에 따라 DataSource를 구성하기 때문에 내장 DB를 사용하려면 추가적인 수정이 필요
    • @Transactional 어노테이션을 통해 각 테스트 이후 자동 롤백

마치며

본문에서는 컨트롤러 레이어에 대한 슬라이스 테스트를 수행하지 않았다. 통합 테스트를 통해 한번에 수행되기 때문에 제외시켰는데, 만약 해당 레이어도 테스트를 진행하고 싶다면 @ControllerTest와 같은 어노테이션을 생성하고 필요한 부분을 조합하여 사용하면 되지 않을까 싶다.