스프링, JPA 등 외부 기술을 도입하면서, 다양한 테스트 방법들을 자연스럽게 접하게 됐다.
스프링 프레임워크에서 제공하는 테스트 환경, HTTP 요청을 보내고 결과를 검증하는 라이브러리 등 테스트에 사용한 도구들이 많았는데, 개념끼리 뒤섞여서 내가 혼동하고 있는 것이 많았다. 한 번 정리해두면 좋을 것 같아서 포스팅하려 한다.
🍃 스프링 프레임워크가 제공하는 테스트용 애노테이션
스프링 프레임워크에서 DI는 컨테이너에 등록된 빈을 기반으로 동작한다. 따라서 스프링 애플리케이션을 실행하면 컴포넌트 스캔, 환경 설정을 통해 Bean Definition을 등록하는 과정이 필요하고, 우리는 필요한 컴포넌트를 직접 new로 생성하지 않고 @Autowired를 통해 주입받을 수 있다.
테스트 역시 필요한 빈들을 컨테이너에 등록하는 과정이 필요하다. 테스트 클래스에 @SpringBootTest 애노테이션을 붙여주면, 테스트를 실행할 때 @SpringBootApplication 하위 패키지를 스캔해서 모든 빈들을 등록한다. 실제 애플리케이션 환경과 동일한 환경에서 테스트할 수 있다.
그런데 항상 모든 빈을 등록하는 것이 좋을까? 모든 하위 패키지를 스캔하고 등록하는 과정을 모든 테스트가 거친다면 많은 시간이 소요될 것이다.
예를 들어, 특정 HTTP 요청에 대해 원하는 상태코드를 응답하는지 확인하고 싶은데(controller 계층 테스트) Repository, DataSource 같은 불필요한 빈들까지 등록하는 것은 비효율적이다. 혹은 Reopsitory에 작성한 커스텀 쿼리 메서드가 db에 잘 적용되는지만 확인하고 싶은데, ControllerAdvice, Interceptor, Service까지 등록하는 것도 비효율적이다.
그래서 스프링 프레임워크는 각 계층별로 필요한 빈만 등록해주는 애노테이션을 제공한다. 내가 사용해본 것은 @DataJpaTest, @WebMvcTest이다. 하나씩 직접 사용하면서 알아보자.
✔️ @DataJpaTest를 이용한 Repository 계층 테스트
Spring Data JPA가 만들어주는 Repository는 신뢰 가능하다고 생각하지만, 커스텀 쿼리 메서드에 대해서는 테스트를 작성하는 것이 바람직하다고 판단했다. (내가 작성한 쿼리는 일단 신뢰할 수 없다🙅🏿♀️)
Repository를 테스트하기 위해 모든 빈을 띄워주는 것은 비효율적이다. 이럴때 유용하게 사용할 수 있는 애노테이션이 @DataJpaTest이다. 이 애노테이션을 이용하면 Persistence Layer에서 필요한 빈들만 등록할 수 있다.
@DataJpaTest의 특징은 아래와 같다.
- Entity, Repository, DataSource, EntityManager 등 JPA와 연관된 빈들을 등록한다. (Controller, Service 등은 띄우지 않는다)
- 자동 트랜잭션을 통해 각 테스트를 독립적으로 실행한다.
- 기본적으로 인메모리 DB 환경을 사용한다.
테스트를 위해 ReservationRepository에 "어린이날, 어린이 예약 정보"를 조회하는 커스텀 쿼리 메서드를 작성해봤다.
@Query(value = """
SELECT r.*
FROM reservation r
JOIN schedule s ON r.schedule_id = s.id
JOIN member m ON r.member_id = m.id
WHERE s.date = CAST(:date AS DATE) AND m.age < 15;
""", nativeQuery = true)
List<Reservation> findByVisitDateAndIsChild(final LocalDate date);
그럼 이 메서드를 테스트하는 코드를 작성해보자.
@DataJpaTest
public class ReservationRepositoryTest {
@Autowired
private ReservationRepository reservationRepository;
@Autowired
private MemberRepository memberRepository;
@Autowired
private ScheduleRepository scheduleRepository;
@Test
@DisplayName("어린이날 어린이 예약 정보들을 조회할 수 있다")
void findByVisitDateAndIsChild() {
// given
LocalDate childDate = LocalDate.of(2025, 5, 5);
Member child = memberRepository.save(new Member("child@gmail.com", "1234", "어린이", 13));
Member adult = memberRepository.save(new Member("adult@gmail.com", "1234", "어른", 23));
Schedule childrensDaySchedule1 = scheduleRepository.save(new Schedule(1, SeatRank.TABLE, childDate));
Schedule childrensDaySchedule2 = scheduleRepository.save(new Schedule(2, SeatRank.TABLE, childDate));
Reservation childrenReservation = reservationRepository.save(new Reservation(child, childrensDaySchedule1, true));
Reservation adultReservation = reservationRepository.save(new Reservation(adult, childrensDaySchedule2, true));
// when & then
Assertions.assertThat(reservationRepository.findByVisitDateAndIsChild(childDate))
.containsExactly(childrenReservation);
}
}
내가 작성한 쿼리를 기반으로 db에 알맞은 쿼리가 날아가고, 원하는 결과를 얻을 수 있는지 테스트할 수 있다.
✔️ @WebMvcTest를 이용한 Controller 계층 테스트
Presentation Layer 테스트의 목적은 interceptor, exceptionHandler 등이 제대로 동작하는지 확인하고, HTTP 요청에 대해 원하는 HTTP 응답이 반환되는지 확인하는 것이라 생각한다.
그리고 @WebMvcTest 애노테이션은 여기에 필요한 빈들만 등록해준다. 특징은 아래와 같다.
- Controller, ControllerAdvice, WebMvcConfigurer 등 Controller와 연관된 빈들을 등록한다.
- 보통 MockMvc와 함께 사용된다. MockMvc를 활용하기 위한 환경까지 제공한다.
예약을 생성하는 controller 메서드에 대한 테스트 코드를 작성해보자.
@WebMvcTest(ReservationController.class)
public class ReservationControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private ReservationService reservationService;
@MockBean
private JwtProvider jwtProvider;
...
}
ReservationController의 예약 생성 POST 요청 메서드를 테스트하기 위해 필요한 설정이다. 위에서 언급했듯이 @WebMvcTest가 MockMvc에 대한 환경을 제공해주기 때문에 바로 주입받아 사용할 수 있다. 이 외에 @WebMvcTest가 등록하지 않지만 필요한 의존성은 @MockBean으로 등록해줬다.
@Nested
class Create {
@Test
@DisplayName("/reservations POST 예약 생성 요청에 성공하면 201 CREATED 코드와 금액을 응답한다")
void create_success() throws Exception {
// given
final String token = "iamtoken";
final LoginMember loginMember = new LoginMember(1L);
final ReservationCreateRequest request = new ReservationCreateRequest(SeatRank.TABLE.name(), 1, LocalDate.of(2025, 5, 5));
final int amount = 88000;
given(jwtProvider.isValidToken(token))
.willReturn(true);
given(jwtProvider.getSubjectByToken(token))
.willReturn(loginMember.id().toString());
given(reservationService.create(request, loginMember))
.willReturn(new ReservationCreateResponse(amount));
// when & then
mockMvc.perform(post("/reservations")
.cookie(new Cookie("token", token))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.amount").value(amount));
}
@Test
@DisplayName("/reservations POST 예약 생성 요청에서 인가에 실패하는 경우 401 UNAUTHORIZED 를 응답한다.")
void create_unauthorizedFail() throws Exception {
// given
final String token = "iamtoken";
final ReservationCreateRequest request = new ReservationCreateRequest(SeatRank.TABLE.name(), 1, LocalDate.of(2025, 5, 5));
given(jwtProvider.isValidToken(token))
.willReturn(false);
// when & then
mockMvc.perform(post("/reservations")
.cookie(new Cookie("token", token))
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isUnauthorized());
}
}
성공 상황과 실패 상황을 테스트해봤다. 이로써 원하는 데이터를 응답해주는지, token 인증 실패 시 원하는 예외를 던지고, 그 예외를 ExceptionHandler에서 잘 처리해서 원하는 응답 코드를 내려주는지를 확인할 수 있었다.
여기까지 스프링 프레임워크를 이용한 프로젝트에서 내가 각 계층을 어떻게 테스트했는지 정리해봤다. (Domain은 순수 자바 코드를 테스트할 때와 같이 단위 테스트했고, Service도 의존하는 부분을 전부 Stubbing해서 테스트했다)
마지막으로 통합 테스트를 작성하는 방법을 정리해보자.
✔️ @SpringBootTest를 이용한 통합 테스트
내가 생각하는 통합 테스트의 목적은, 한 기능을 수행하기 위해 계층끼리 유기적으로 이어지는지 확인하는 것이다. 일단 계층들이 독립적으로는 제대로 동작한다는 것을 위에 언급한 방법대로 테스트를 작성하여 확인한 뒤 통합 테스트를 작성했다.
@SpringBootTest를 이용해서 모든 빈들을 세팅해줬고, HTTP 요청을 보내고 응답을 검증하기 위해 RestAssured를 사용했다.
통합 테스트에서도 MockMvc를 사용할 수도 있지만 RestAssured를 사용한 이유는 아래와 같다.
- given() - when() - then()으로 이어져서 가독성이 좋다.(이런 형식을 BDD 스타일이라 한다)
- 실제 톰캣을 띄워서 요청을 보내기 때문에, 실제 요청에 가깝다. => 이 차이점에 대해서는 잘 몰라서 더 공부해봐야 한다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class ReservationIntegrationTest {
@Autowired
private ReservationRepository reservationRepository;
@Autowired
private MemberRepository memberRepository;
@LocalServerPort
private int port;
@BeforeEach
void setUp() {
RestAssured.port = port;
}
}
RestAssured를 사용할 때 주의할 점이 있다.
RestAssured는 기본적으로 스프링 애플리케이션이 localhost:8080에 떠 있다고 가정하고 8080 포트에 요청을 보낸다. 하지만 @SpringBootTest는 테스트 환경에서 8080 포트가 아닌 랜덤 포트에 띄운다. 그래서 현재 서버가 떠있는 포트를 @LocalServerPort로 가져와서, RestAssured가 여기에 요청을 보내게 설정해야 한다.
@Test
@DisplayName("/reservations GET 요청 성공 시, 200 OK를 응답한다")
void getAll() {
// given
Member member = memberRepository.save(new Member("메이", "may@gmail.com", "1234"));
reservationRepository.save(new Reservation(member, LocalDate.now()));
reservationRepository.save(new Reservation(member, LocalDate.now().plusDays(1)));
// when & then
given()
.when().get("/reservations")
.then().statusCode(200)
.body("size()", is(2));
}
@Test
@DisplayName("/reservations POST 요청 성공 시, 201 CREATED를 응답한다")
void save() {
// given
Member member = memberRepository.save(new Member("메이", "may@gmail.com", "1234"));
ReservationRequest request = new ReservationRequest(member.getId(), LocalDate.now());
// when & then
given().body(request).contentType(ContentType.JSON)
.when().post("/reservations")
.then().statusCode(201)
.body("id", notNullValue())
.body("status", equalTo("success"))
.body("price", equalTo(1000));
}
Reservation이 Member를 참조하는 구조에서, Reservation을 생성하기 전에 Member를 먼저 저장해야 한다. JdbcTemplate를 이용해 직접 쿼리를 날릴 수도 있고, data.sql에 초기 데이터를 설정할 수도 있다. 그런데 객체간의 관계를 명확하게 표현함으로써 가독성이 좋아진다 생각해서, Repository를 이용해서 초기 데이터를 저장했다.
이렇게 각 계층이 올바르게 동작하는지 테스트한 후 종합적인 테스트까지 진행하니 내 코드의 안정성이 한층 높아진 것 같다고 느꼈다!
다만 통합 테스트는 많은 시간이 소요되다 보니 모든 경우를 테스트하기는 어려울 것 같다. TDD 시작하기 책에서 단위 테스트로 최대한 커버를 해보고, 통합 테스트에서는 굵직한 상황에 대한 테스트를 해야한다고 하는데, 이 기준을 아직 잘 모르겠다.
의식의 흐름으로 주절주절 적어봤는데, 결국 테스트의 목적을 잘 설명할 수 있는지, 내가 작성한 테스트가 다른 사람이 봤을때도 신뢰도가 높은지가 중요한 것 같다. 많이 고민하면서 나만의 테스트 기준을 세우고 싶다!
'스프링' 카테고리의 다른 글
| [Spring] 트랜잭션, @Transactional (1) | 2025.07.06 |
|---|