-
[JUnit] REST API 테스트 하기DEV/JUnit 2025. 1. 7. 20:55
REST란 (representational state transfer) 웹 서비스를 구현하기 위한 소프트웨어 아키텍처 스타일로
REST를 만족하기 위해서는 많은 조건을 따라야한다. 이러한 조건을 따르는 웹 서비스를 RESTful 웹 서비스라고 한다.
REST 아키텍처 스타일은 다음과 같이 6가지 요건을 정의한다.
1. 클라이언트-서버 관계
: 클라이언트와 서버는 서로 분리되어 있으며 각각 다른 역할을 한다.
보통 클라이언트는 사용자에게 보여지는 부분과 관련 있고, 서버는 데이터 저장이나 도메인 모델 로직과 관련된다.
2. 무상태성
: 서버는 클라이언트의 요청과 요청 사이에 클라이언트에 관한 상태 정보를 따로 보관해 두지 않는다.
그러므로 클라이언트의 요청은 해당 요청에 응답하기 위해 필요한 모든 정보를 포함해야한다.
즉, 클라이언트가 자신의 상태 정보를 가지고 있어야 한다.
3. 일관된 인터페이스
: 클라이언트와 서버는 일관된 인터페이스 덕분에 서로 독립적이며 느슨하게 결합할 수 있다.
4. 계층적 시스템
: 클라이언트는 자신과 상호작용하는 대상이 서버인지 중개자인지 상관하지 않는다.
그러므로 클라이언트와 서버 사이에는 여러 계층을 동적으로 추가, 제거 할 수 있다.
이때 각 계층은 보안, 로드밸런싱, 캐싱 등의 다양한 기능을 가질 수 있다.
5. 캐시 가능성
: 클라이언트는 응답을 캐시할 수 있으며, 각 응답은 캐시 가능 여부를 정의할 수 있다.
6. 주문형 코드(선택사항)
: 서버는 클라이언트의 기능을 일시적으로 사용자 정의하거나 확장할 수 있다. 서버는 자바스크립트로 만든 클라이언트 단의 스크립트나 자바 애플릿 같이 클라이언트에서 수행할 수 있는 로직을 전송할 수 있다.
RESTful 웹 애플리케이션은 리소스를 제공할 수 있으며 리소스는 URL로 식별할 수 있는데,
클라이언트는 리소스를 생성(create), 조회(read), 수정(update), 삭제(delete)하는 기능을 수행 할 수 있다.
REST 아키텍처는 특정 프로토콜에 국한되지 않는다. 그러나 현재 가장 많이 사용하는 프로토콜은 HTTP라 할 수 있다.
HTTP는 요청(request)과 응답(response)을 기반으로 하는 동기식 네트워크 프로토콜이다.
클라이언트는 API를 사용할 때 다음 정보를 서버로 전송한다.
- 접근하려는 리소스의 식별자(URL)
- 서버가 해당 리소스에 수행할 연산. 보통 HTTP 동사로 표현되며 GET, POST, PUT, PATCH, DELETE가 자주 사용된다.
이때 URL은 네이밍 규칙이 있으므로 찾아서 적용 해보길 바란다.
샘플 코드
RestApplicationTest 클래스
@SpringBootTest @AutoConfigureMockMvc @Import(FlightBuilder.class) public class RestApplicationTest { @Autowired private MockMvc mvc; @Autowired private Flight flight; @Autowired private Map<String, Country> countryMap; @MockitoBean private PassengerRepository passengerRepository; @MockitoBean private CountryRepository countryRepository; .. }
1. @SpringBootTest : 테스트 클래스의 현재 패키지와 그 이하 패키지의 모든 스프링 빈을 스캔한다.
2. @AutoConfigureMockMvc : MockMvc 객체와 관련한 모든 자동 구성을 활성화 한다.
3. MockMvc 객체 오토와이어 : MockMvc는 주로 서버 기능을 테스트하기 위해 사용한다. 여기서는 MockMvc를 가지고 REST API를 실행한다.
4. @MockitoBean : 스프링 애플리케이션 콘텍스트에 모의 객체를 추가하는데 사용한다.
GET 테스트
@Test void testGetAllCountries() throws Exception { when(countryRepository.findAll()).thenReturn( new ArrayList<>(countryMap.values())); mvc.perform(get("/countries")) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$", hasSize(3))); verify(countryRepository, times(1)).findAll(); }
1. findAll 메서드가 실행될 때 countryRepository 빈이 countryMap에서 값을 반환하도록 모의
2. /countries URL에 대하여 HTTP GET 요청을 모사하고, 반환한 HTTP 상태코드, 예상 콘텐츠 유형, 반환한 JSON 결괏값의 사이즈를 검증한다.
3. verify를 통해 findAll 메서드가 countryRepository 빈에서 정확히 한번만 실행되었는지 검증한다.
GET 테스트
@Test void testGetAllPassengers() throws Exception { when(passengerRepository.findAll()).thenReturn( new ArrayList<>(flight.getPassengers())); mvc.perform(get("/passengers")) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$", hasSize(20))); verify(passengerRepository, times(1)).findAll(); }
위 설명과 동일
Exception 테스트
@Test void testPassengerNotFound() { Throwable throwable = assertThrows(ServletException.class, () -> mvc.perform(get("/passengers/30")) .andExpect(status().isNotFound())); assertEquals(PassengerNotFoundException.class, throwable.getCause().getClass()); }
1. 아이디가 30인 승객 정보를 조회하려는 도중 ServletException이 발생하고, 반환한 HTTP 상태코드가 NotFound인지 검증한다.
2. ServletException 발생 원인이 PassengerNotFoundException인지 검증한다.
POST 테스트
@Test void testPostPassenger() throws Exception { Passenger passenger = new Passenger("Peter Michelsen"); passenger.setCountry(countryMap.get("US")); passenger.setIsRegistered(false); when(passengerRepository.save(passenger)).thenReturn(passenger); mvc.perform(post("/passengers") .content(new ObjectMapper().writeValueAsString(passenger)) .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.name", is("Peter Michelsen"))) .andExpect(jsonPath("$.country.codeName", is("US"))) .andExpect(jsonPath("$.country.name", is("USA"))) .andExpect(jsonPath("$.registered", is(Boolean.FALSE))); verify(passengerRepository, times(1)).save(passenger); }
1. Passenger 객체를 생성하고 적절한 값을 성정하여 해당 승객 객체를 save할때 해당 객체를 반환하도록 PassengerRepository 빈을 미리 모의한다.
2. /passengers URL에 대하여 POST 요청을 보낼 때 적절한 헤더와 함께 요청 객체를 JSON 문자열로 변환했는지, 그리고 반환한 콘텐츠가 passenger 객체의 JSON 결과값과 Created(201) HTTP 상태 코드로 구성되어 있으며, 그 내용이 적절한지 검증한다.
* ObjectMapper : 자바에서 JSON 데이터와 자바 객체간의 변환을 지원한다.
3. save 메서드가 정확히 한번만 실행되었는지 검증한다.
PATCH 테스트
@Test void testPatchPassenger() throws Exception { Passenger passenger = new Passenger("Sophia Graham"); passenger.setCountry(countryMap.get("UK")); passenger.setIsRegistered(false); when(passengerRepository.findById(1L)) .thenReturn(Optional.of(passenger)); when(passengerRepository.save(passenger)) .thenReturn(passenger); String updates = "{\"name\":\"Sophia Jones\", \"country\":\"AU\", \"isRegistered\":\"true\"}"; mvc.perform(patch("/passengers/1") .content(updates) .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()); verify(passengerRepository, times(1)).findById(1L); verify(passengerRepository, times(1)).save(passenger); }
1. Passenger 객체를 생성하고 적절한 값을 설정한 다음 findById(1L)메서드가 해당 객체를 반환하도록 미리 모의한다.
2. 그리고 save 메서드를 실행하면 passenger 객체를 반환하도록 모의한다.
3. /passenger/1 URL에서 PATCH 요청을 보냈을 때 updates라는 JSON 문자열을 HTTP 요청에 추가했다. 그리고 그 결과값으로 반환한 객체의 콘텐트 타입과 HTTP 상태 코드를 검증한다.
4. verify를 통해 findById, save 메서드가 각각 한번만 실행되었는지 검증한다.
DELETE 테스트
@Test void testDeletePassenger() throws Exception { mvc.perform(delete("/passenger/4")) .andExpect(status().isOk()); verify(passengerRepository, times(1)).deleteById(4L); }
1. /passenger/4 URL에 대해 DELETE요청을 보냈을 때 반환된 HTTP 상태 코드가 OK(200)인지 검증한다.
2. verify를 통해 deleteById 메서드가 한번 실행되었는지 검증한다.
전체 코드
package com.manning.junitbook.spring_boot; import com.fasterxml.jackson.databind.ObjectMapper; import com.manning.junitbook.spring_boot.beans.FlightBuilder; import com.manning.junitbook.spring_boot.exceptions.PassengerNotFoundException; import com.manning.junitbook.spring_boot.model.Country; import com.manning.junitbook.spring_boot.model.Flight; import com.manning.junitbook.spring_boot.model.Passenger; import com.manning.junitbook.spring_boot.repository.CountryRepository; import com.manning.junitbook.spring_boot.repository.PassengerRepository; import jakarta.servlet.ServletException; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import java.util.ArrayList; import java.util.Map; import java.util.Optional; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @SpringBootTest @AutoConfigureMockMvc @Import(FlightBuilder.class) public class RestApplicationTest { @Autowired private MockMvc mvc; @Autowired private Flight flight; @Autowired private Map<String, Country> countryMap; @MockitoBean private PassengerRepository passengerRepository; @MockitoBean private CountryRepository countryRepository; @Test void testGetAllCountries() throws Exception { when(countryRepository.findAll()).thenReturn( new ArrayList<>(countryMap.values())); mvc.perform(get("/countries")) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$", hasSize(3))); verify(countryRepository, times(1)).findAll(); } @Test void testGetAllPassengers() throws Exception { when(passengerRepository.findAll()).thenReturn( new ArrayList<>(flight.getPassengers())); mvc.perform(get("/passengers")) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$", hasSize(20))); verify(passengerRepository, times(1)).findAll(); } @Test void testPassengerNotFound() { Throwable throwable = assertThrows(ServletException.class, () -> mvc.perform(get("/passengers/30")) .andExpect(status().isNotFound())); assertEquals(PassengerNotFoundException.class, throwable.getCause().getClass()); } @Test void testPostPassenger() throws Exception { Passenger passenger = new Passenger("Peter Michelsen"); passenger.setCountry(countryMap.get("US")); passenger.setIsRegistered(false); when(passengerRepository.save(passenger)).thenReturn(passenger); mvc.perform(post("/passengers") .content(new ObjectMapper().writeValueAsString(passenger)) .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.name", is("Peter Michelsen"))) .andExpect(jsonPath("$.country.codeName", is("US"))) .andExpect(jsonPath("$.country.name", is("USA"))) .andExpect(jsonPath("$.registered", is(Boolean.FALSE))); verify(passengerRepository, times(1)).save(passenger); } @Test void testPatchPassenger() throws Exception { Passenger passenger = new Passenger("Sophia Graham"); passenger.setCountry(countryMap.get("UK")); passenger.setIsRegistered(false); when(passengerRepository.findById(1L)) .thenReturn(Optional.of(passenger)); when(passengerRepository.save(passenger)) .thenReturn(passenger); String updates = "{\"name\":\"Sophia Jones\", \"country\":\"AU\", \"isRegistered\":\"true\"}"; mvc.perform(patch("/passengers/1") .content(updates) .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()); verify(passengerRepository, times(1)).findById(1L); verify(passengerRepository, times(1)).save(passenger); } @Test void testDeletePassenger() throws Exception { mvc.perform(delete("/passenger/4")) .andExpect(status().isOk()); verify(passengerRepository, times(1)).deleteById(4L); } }
'DEV > JUnit' 카테고리의 다른 글
[JUnit] JUnit 5를 사용한 TDD (0) 2025.01.23 [JUnit] 데이터베이스 애플리케이션 테스트 (1) 2025.01.22 [JUnit] Spring Boot 애플리케이션 테스트 (1) 2024.12.10 [JUnit] JUnit5 extension (확장 모델) (1) 2024.10.22 [JUnit] 모의 객체 프레임워크 EasyMock / JMock / Mockito (0) 2024.09.11