[Spring] 스프링 테스트 어노테이션 알아보기 (feat. 슬라이스 테스트)

웹 애플리케이션은 Controller, Service, Repository 계층이 있고 각 계층 별로 역할이 있다.

스프링 테스트는 일전에 작성했던 경험이 없어 @SpringBootTest 어노테이션 밖에 몰랐는데

Controller, Service, Repository 계층을 테스트하는 상황에 매번 사용하지 모든 빈들을 등록하고 내장된 톰캣까지 실행해야 할까?

라는 생각이 들었고 이러한 문제를 해결할 수 있는 방법을 찾아 봤다.

그 결과 스프링은 이러한 문제를 해결할 수 있도록 도와주는

"레이어 별로 잘라서 특정 레이어에 대해서 Bean을 최소한으로 등록해 사용할 수 있는 테스트" 어노테이션을 제공하고 있었다.

그리고 이러한 테스트를 슬라이스 테스트라고 한다.

 

슬라이스 테스트를 적용하는 것이 무조건 좋은 것은 아니다

이 부분이 궁금하다면 통합 테스트와 슬라이스 테스트를 비교한 아래 포스팅을 참고하는 것을 추천한다.

2024.05.15 - [◼ JAVA/Spring] - [Spring] 통합 테스트 vs 슬라이스 테스트 (+ 개인적인 생각)

 

[Spring] 통합 테스트 vs 슬라이스 테스트 (+ 개인적인 생각)

통합 테스트와 슬라이스 테스트 비교이번에 통합 테스트와 슬라이스 테스트를 둘 다 적용해보며 직접 느낀점들을 작성하고자 한다.이 글을 읽기 전에 주의할점은 계층별 슬라이스 테스트가 작

hstory0208.tistory.com

 

스프링이 지원해주는 테스트 어노테이션을 알아보고 각 어노테이션이 어떤 계층에서 사용되는지 알아보자.

@SpringBootTest - 통합 테스트

전체 Spring Application Context를 알아 로드해 통합 테스트를 수행하는데 사용된다.

내장된 서블릿 컨테이너를 선택적으로 실행할 수 있는데, @SpringBootTest의 webEnvironment 속성을

RANDOM_PORT 또는 DEFINED_PORT로 설정하면

내장된 서블릿 컨테이너가 함께 구동되어 마치 실제 환경처럼 웹 요청과 응답을 테스트할 수 있다.

이 외에도 다음과 같은 설정이 있다.

WebEnvironment 설정 정보

MOCK 옵션은 아직 어떤 상황에서 유용하게 사용할 수 있는지 경험해보지 못해서 제외했다.

 

RANDOM_PORT

내장된 서블릿 컨테이너가 임의의 포트 번호로 구동된다.

즉, 서버가 임의의  포트 번호로 실행되는 것이다.

하지만 서버에 요청을 보내는 테스트는 기본적으로 8080 포트로 요청을 보낸다.

현재 구동되는 포트번호로 요청을 보낼 필요가 있다면

아래처럼 @LocalServerPort를 사용해 구동되는 서버의 현재 Port 번호를 알 수 있다.

 

DEFINED_PORT

내장된 서블릿 컨테이너가 프로퍼티에 정의된 Port 번호로 구동된다.

따로 Port 번호를 명시하지 않았다면 기본은 8080이다.

 

NONE

말 그대로 내장된 서블릿 컨테이너를 띄우지 않는 것이다.

웹 서버에 대한 테스트가 필요 없을 때 훨씬 가볍게 테스트할 수 있다.

 

주의점

테스트에 @Transactional을 적용할 경우, 각 테스트 메서드가 끝날 때 롤백된다.

하지만 RANDOM_PORT, DEFINED_PORT를 통해 실제 웹 환경을 사용하게 되면 

하나의 트랜잭션으로 관리되는 테스트 메서드의 스레드와 웹 서버의 스레드가 달라 별개의 물리 트랜잭션으로 실행되어 

테스트 메서드에서 실행된 메서드의 경우 트랜잭션 롤백이 되지 않는다.

 

MockMvc와 RestAssuerd

통합 테스트를 작성할 때 웹의 요청을 받아 응답이 정상적으로 나가는지 테스트하기 위해

사용할 수 있는 도구들이 있는데 대표적으로 MockMvc, RestAssuerd가 있다.

 

MockMvc

실제 환경이 아닌 MVC 환경을 모의하여 가짜 웹 요청을 받아 응답을 테스트하는 것으로

보통은 컨트롤러 슬라이스 테스트에 많이 사용한다.

 

RestAssuerd

실제 웹 요청을 받아 응답을 테스트하는 것으로 실제 환경과 동일한 환경에서 테스트할 수 있어 신뢰도가 높다.

또한 BDD (given - when - then) 테스트를 지원해 가독성 좋게 코드를 작성할 수 있는 장점도 있다.

 

무조건 이 것을 사용해야 한다는 정답은 없기에 상황과 목적에만 맞다면 무엇을 사용하든 문제는 없다고 생각한다.

다만 각각의 도구의 특징들을 잘 파악해야하기 때문에 자세히 알아보고 싶은 사람들을 위해 키워드만 남겨놓았다.

 

Controller 계층에서 사용하는 어노테이션

@WebMvcTest(HomeController.class) // 테스트할 컨트롤러 클래스명
public class HomeControllerTest {

    @MockBean
    private JoinService joinService;

    @Autowired
    private MockMvc mockMvc; // 요청 응답 테스트를 위한 자동으로 Bean 등록된 MockMvc 사용 
    
    @Autowired
    private ObjectMapper objectMapper; // Json으로 직렬화 및 역직렬화를 위한 객체
    
    @DisplayName("홈 View 페이지 반환 테스트")
    @Test
    public void testHomePage() throws Exception {
    	// given & when & then
        mockMvc.perform(get("/")) // "/" 경로로 GET 요청을 보냄
                .andExpect(status().isOk()) // HTTP 상태 코드 200이 반환되는지 검증
                .andExpect(view().name("home")); // 반환된 뷰의 이름이 "home"인지 검증
    }
    
    @DisplayName("회원 가입 API 테스트")
    @Test
    public void joinTest() throws Exception {
        // given
        User user = new User(1L, "카키");
        JoinRequest joinRequest = new JoinRequest("카키");
        JoinResponse joinResponse = new JoinResponse(user.getId(), user.getName());

        // when
        doReturn(joinResponse).when(joinService)
                .save(any(JoinResponse.class));

        // then
        mockMvc.perform(post("/join")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(joinRequest)))
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", "/join/1"))
                .andExpect(jsonPath("$.name").value(user.getName()))
    }
}

 

@WebMvcTest

이름 그대로 MVC를 위한 테스트로, Controller 계층 테스트에 특화되어 있는 테스트이다.

MVC 관련 부분만 스프링 컨테이너에 @Controller, @ControllerAdvice, Json 변환, 인터셉터, Filter 등 웹 계층에 관련된 Bean만 등록해 테스트 되며, 서블릿 컨테이너는 구동되지 않는다.

해당 테스트 어노테이션은 MockMvc도 Bean 등록되어 있어 사용가능하다.

MockMvc는 실제 서블릿 컨테이너를 실행하지 않더라도 MVC 동작을 Mocking하여 테스트할 수 있는 기능을 제공한다.

 

@MockBean

Mock이란 것은 실제 객체의 행동을 모방하는 가짜 객체이다.

MockBean은 스프링 컨테이너에 해당 어노테이션이 붙은 클래스를 가짜(모의) 객체로 Bean 등록하고 의존성 주입까지 해준다.

HomeController가 의존하는 JoinServcie 또한 스프링 컨테이너에 등록되어야 테스트가 가능해

의존성으로 필요한 Service도 @Mock이 아닌 @MockBean으로  Bean등록하고 의존성 주입 해준 것이다.

Mock은 가짜 객체이므로 실제 객체의 행동을 테스트할 순 없다.

그렇기 때문에 doRetrun(), when() 같은 Mockito의 메서드를 사용해 행동을 정해주어야 한다.

 

Service 계층에서 사용하는 어노테이션

@ExtendWith(MockitoExtension.class)
public class ReservationServiceTest {

    @Mock
    private ReservationRepository reservationRepository;

    @InjectMocks
    private ReservationService reservationService;

    @DisplayName("존재하지 않는 reservation id일 경우 예외가 발생한다.")
    @Test
    void findByIdExceptionByNotExistReservationIdTest() {
        // given
        Long reservationId = 1L;

        doReturn(Optional.empty()).when(reservationRepository)
                .findById(reservationId);

        // when & then
        assertThatThrownBy(() -> reservationService.findById(reservationId))
                .isInstanceOf(IllegalArgumentException.class);
    }
}

@ExtendWith

JUnit 5에서 제공하는 어노테이션으로 실행 환경을 제공하는 클래스를 선택할 수 있다.

@ExtendWith(SpringExtension.class)

스프링 컨테이너를 로드하지만 컴포넌트 스캔은 하지 않아 프로젝트 내에 정의된 컴포넌트들이 들어가있지 않다.

따라서 테스트 코드상에서 직접 Bean을 등록하고 @Autowired를 통해 의존성 주입해야한다.

스프링 컨테이너를 로드하기 때문에 Bean을 Mocking하기위한 @MockBean 기능도 사용 가능하다.

서블릿 컨테이너를 띄울 필요는 없지만 스프링 컨테이너 로딩이 필요할 때 사용한다.

 

@ExtendWith(MockitoExtension.class)

스프링 컨테이너를 로드하지않고 Mokito 프레임 워크를 사용해 테스트에서 Mocking이 필요한 경우 사용한다.

@Mock, @InjectMocks, @Spy 등의 어노테이션과 함께 사용한다.

스프링의 도움 없이 테스트에 Mocking을 사용한 순수한 단위 테스트가 필요할 때 사용한다.

 

서비스 레이어에서 @ExtendWith(MockitoExtension.class)를 사용한 이유

Servcie는 웹 계층이 아닌 비지니스 로직에 집중되어 있으며

Controller와 Repository의 중간에 있는 계층으로 이 계층만을 테스트하기 위해서는 두 의존관계를 끊어줄 필요가 있다.

따라서 이 어노테이션을 통해 비지니스 로직을 테스트하기 위해서 스프링과 관련된 추가 비용 없이

Service가 의존하는 Repository를 Mock으로 주입해 초기화 시 의존성 문제만 해결

순수한 자바 코드로 비즈니스 로직만을 테스트하는데 집중할 수 있다.

 

@Mock

Mockito 라이브러리에서 제공하는 어노테이션으로, Mock 객체를 생성하기 위해 사용된다.

MockBean과 다른 점으로는 스프링 컨테이너에 Bean으로 등록되지 않기 때문에

스프링 컨테이너를 로드할 필요가 없을 경우 사용되고 @InjectMocks을 사용해 직접 의존성 주입해주어야 한다.

말 그대로 특정 객체를 모방한 객체이므로 이 Mock 객체가 갖고 있는 메서드의 반환 값을 모의로 설정할 수 있다.

위 코드에서는 특정 Service의 비즈니스 로직이 정상적으로 작동되는지를 확인 하기 위해

Service가 의존하는 Mock 객체의 반환 값을 임의로 설정해 테스트한 것이다.

 

@InjectMocks

Mockito에서 제공하는 어노테이션으로, Mock 객체를 테스트하고자 하는 클래스에 자동으로 주입하기 위해 사용된다.

@Mock이나 @Spy로 생성된 객체들을 해당 어노테이션이 붙은 클래스의 인스턴스에 주입한다.

위 예시에서는 ReservationRepository Mock 객체를 ReservationService에 주입해 의존성 문제를 해결하고

ReservationService의 비즈니스 로직들을 테스트하고 있다.

 

Repository 계층에서 사용하는 어노테이션

@DataJpaTest
@Import(UserRepository.class)
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @DisplayName("회원 정보 저장 테스트")
    @Test
    void saveTest() {
        // given
        User user = new User("카키");

        // when
        Long id = userRepository.save(user);
        User savedUser = userRepository.findById(id);

        // then
        assertAll(
                () -> assertThat(savedUser.getId()).isEqualTo(1L),
                () -> assertThat(savedUser.getName()).isEqualTo("카키"),
        );
    }
}

 

@DataJpaTest

Spring Date Jpa를 테스트할 때 사용하는 어노테이션이다.

해당 테스트는 기본적으로 인메모리 임베디드 DB를 생성하고 @Entity가 붙은 엔티티 클래스들을 스캔한다.

또한 Spring Data JPA 관련 설정만 Bean 등록되며 내부에 @Transactional 어노테이션이 선언되어 있어

테스트마다 롤백되어 테스트의 독립성을 보장해준다.

추가로 테스트하기 위한 클래스를 Bean 등록해 사용해야 한다면 @Import 어노테이션을 사용할 수 있다.

 

@JdbcTest

@Entity가 붙은 엔티티 클래스를 스캔한다는 점만 제외하면

위와 동일하게 인메모리 임베디드 DB를 생성하고 JDBC 관련 설정만 Bean 등록하며 @Transactional 어노테이션이 선언되어있다.

이름 그대로 JdbcTemplate를 사용하는 Repository를 테스트할 때 이 어노테이션을 사용해 테스트한다.

 

@Import

설정 클래스나 특정 클래스를 Bean 등록하기 위해 사용하는 어노테이션이다.

@Import({SecurityConfig.class, UserRepository.class})

위 코드에서는 UserRepository를 테스트하기 위해 해당 클래스를 Bean 등록해 의존성 주입하여 사용했다.

 

@AutoConfigureTestDatabase

기본설정인 인메모리 임베디드 DB를 사용하지 않고 실제 사용하는 DB를 테스트하고자 한다면

다음과 같은 옵션을 통해 실제 사용되는 DB에 접근하여 테스트할 수 있다.

(properties 또는 yml에 설정 DB를 사용한다.)

@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)