
최근에 단위 테스트에 대한 글을 작성했었다.
저번 글에서는 Service Layer에 대해서 단위 테스트 하며, 왜 Service Layer에 대해 독립적으로 테스팅을 수행 해야 했는지에 대해서 내 생각을 이야기 했다.
이번 글에서는 흔히 3 layers Architecture의 Controller layer, Service layer, Repository layer 모든 계층에 대해 테스트를 진행 하는 통합 테스트에 대해서 알아보고자 한다.
저번 글과 마찬가지로 Spring boot에서는 어떻게 통합 테스트를 진행하는지를 중점으로 설명 해보겠다!
그러면 먼저 통합(Integration) 테스트란 무엇일까?
먼저 사전적인 개념은 아래와 같다.
통합 테스트는 애플리케이션의 모든 구성 요소가 예상대로 함께 작동하는지 확인하는 소프트웨어 테스트 유형입니다.
이를 쉽게 설명하면 빌드할 때 최종적으로 모든 코드들을 메모리 올려서 테스팅을 해보는 것이라고도 설명할 수 있겠다.
단위 테스트는 Controller나 Service 같은 하나의 layer에 대해서만 테스팅하는거라면 통합 테스트는 모든 layer를 테스팅 해보는 것이다.
단위 테스트는 해당 layer가 본인의 역할을 잘 수행했는지를 보는 것이 목표이기에, 굳이 다른 layer들이 필요하지 않아 Mock 객체를 의존성 대신 넣는것이다.
이는 다른 layer들간의 의존 에러는 확인 할 수 없음을 의미한다.
통합 테스트는 모든 layer를 테스팅 해보는 것이기에 의존이 필수이며, 그렇기에 의존하는 layer들에서 생긴 에러들을 확인할 수 있다.
이제 통합 테스트에 대해 이론적으로 살펴보았으니, 실제 내가 작성한 코드들을 살펴보며 Spring에서 통합 테스트를 진행하는 방식에 대해서 살펴보려고 한다.
PackageRestControllerTest.class
@ActiveProfiles("test")
@Sql(value = "classpath:db/teardown.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
public class PackageRestControllerTest {
private MockMvc mvc;
@Autowired
private ObjectMapper om;
@Autowired
private PackageRestController packageRestController;
private static final String urlPrefix = "/api/v1";
@BeforeEach
public void set_up() {
mvc = MockMvcBuilders.standaloneSetup(packageRestController)
.addFilter(new CharacterEncodingFilter("UTF-8", true))
.build();
}
@Test
public void save_test() throws Exception {
// given
List<ImageDTO.RequestDTO> requestImageDTOs = MockUtil.getImageDtoRequestDtos();
PackageDTO.RequestDTO requestPackageDTO = new PackageDTO.RequestDTO(1L, requestImageDTOs);
String requestBody = om.writeValueAsString(requestPackageDTO);
// when
ResultActions resultActions = mvc.perform(
post(urlPrefix + "/package")
.content(requestBody)
.contentType(MediaType.APPLICATION_JSON_VALUE)
);
// eye
String responseBody = resultActions.andReturn().getResponse().getContentAsString();
System.out.println("테스트 : " + responseBody);
// verify
resultActions.andExpect(jsonPath("$.status").value("success"));
}
@Test
public void get_package() throws Exception {
// given
long fakeId = 1L;
// when
ResultActions resultActions = mvc.perform(
get(urlPrefix+ "/package/" + fakeId)
);
// eye
String responseBody = resultActions.andReturn().getResponse().getContentAsString();
System.out.println("테스트 : " + responseBody);
// verify
resultActions.andExpect(jsonPath("$.status").value("success"));
resultActions.andExpect(jsonPath("$.data.id").value(1));
resultActions.andExpect(jsonPath("$.data.trackingNo").value(1));
resultActions.andExpect(jsonPath("$.data.images[0].filename").value("파일 이름1"));
}
}
위가 이번에 작성했던 통합 테스트 코드이다.
각 코드의 부분별로 왜 이렇게 작성되었는지 상세히 살펴보고자 한다.
@ActiveProfiles("test")
@Sql(value = "classpath:db/teardown.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
해당 Test 클래스에는 위와 같은 어노테이션들이 붙어있다.
해당 Part에서는 각 어노테이션들이 통합 테스트를 하는데 어떠한 역할을 하는지 설명 하고자 한다.
@ActiveProfiles("test")
해당 어노테이션은 해당 테스트를 실행시킬 Profile을 선택하는 것이다.
일반적으로 나는 test Profile과 deploy Profile을 각각 분리 하여 개발을 하는 편이기에 테스트시의 profile인 test Profile로 설정했다.
@Sql(value = "classpath:db/teardown.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
해당 어노테이션은 Spring JUnit에서 테스트 전후로 데이터베이스를 초기화 하거나 테스트 전후에 SQL 스크립트를 실행할 때 사용된다.
classpath:는 resource 디렉토리를 의미하기에 resource/db 디렉토리의 teardown.sql 스크립트를 실행하겠다는 의미이다.
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD를 통해 각 테스트 메서드 실행 전 스크립트를 실행한다.
@AutoConfigureMockMvc
해당 어노테이션은 웹 애플리케이션에서 Controller를 테스트 할 때 서블릿 컨테이너를 모킹하기 위해서는 사용된다.
웹 환경에서 컨트롤러를 테스트 하려면 반드시 서블릿 컨테이너가 구동되고, DispatcherServlet 객체가 메모리에 올라가야 한다.
그러나 서블릿 컨테이너를 모킹하면 실제 서블릿 컨테이너가 아닌 테스트용 모형 컨테이너를 사용하기 때문에 간단하게 컨트롤러를 테스트 할 수 있다.
출처 : https://medium.com/@minjeesong95/spring-test-webmvctest-vs-autoconfiguremockmvc-0d54487bf250
@WebMvcTest와 해당 어노테이션의 가장 큰 차이점은 Controller 뿐만 아니라 테스트 대상이 아닌 @Service나 @Repository이 붙어 있는 객체들도 모두 메모리에 올린다.
@WebMvcTest는 위의 특성(@Service나 @Repository이 붙어 있는 객체들은 메모리에 올리지 않는 특성) 덕분에 실제 구동되는 어플리케이션과 똑같이 컨텍스트를 로드하는 @SpringBootTest 어노테이션보다 가볍게 테스트 할 수 있다고 한다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
해당 어노테이션은 통합 테스트간 모든 layer들을 거치게 만들어준다.
또한 webEnvironment를 Mock으로 설정하게 되면 Servlet Container를 실제로 띄우지 않고 Mocking 하여 띄운다.
그러므로 Mocking 된 Servlet Container와 Interaction를 하기 위해선 MockMvc 클라이언트를 사용해야 한다.
private MockMvc mvc;
@BeforeEach
public void set_up() {
mvc = MockMvcBuilders.standaloneSetup(packageRestController)
.addFilter(new CharacterEncodingFilter("UTF-8", true))
.build();
}
해당 코드가 없었을 때는 계속 HTTP Response시에 한글이 깨지는 문제가 발생했다.
이는 Controller에서 Response 할 때와 MockMvc를 이용해 Response 할 때 사용하는 객체가 다르고, 이 때 인코딩 설정이 다르기에 한글이 깨졌었던 것이었다.
MockMvc의 필터에 직접 UTF-8로 인코딩 되도록 설정을 해주었다.
출처 : https://goodteacher.tistory.com/394
@Test
public void save_test() throws Exception {
// given
List<ImageDTO.RequestDTO> requestImageDTOs = MockUtil.getImageDtoRequestDtos();
PackageDTO.RequestDTO requestPackageDTO = new PackageDTO.RequestDTO(1L, requestImageDTOs);
String requestBody = om.writeValueAsString(requestPackageDTO);
// when
ResultActions resultActions = mvc.perform(
post(urlPrefix + "/package")
.content(requestBody)
.contentType(MediaType.APPLICATION_JSON_VALUE)
);
// eye
String responseBody = resultActions.andReturn().getResponse().getContentAsString();
System.out.println("테스트 : " + responseBody);
// then - verify
resultActions.andExpect(jsonPath("$.status").value("success"));
}
위 테스트 코드는 BDD 패턴을 기반으로 하였다.
BDD 패턴이란 행위 주도 개발이며, 주로 시스템 동작의 행위를 기반으로 한다.
이는 아래의 문장으로 간략하게 설명할 수 있다.
코드를 작성하기 전에 코드가 수행할 행위에 대한 명세를 먼저 작성해야 한다고 하면 다들 쉽게 이것이 좋은 습관이라고 수긍하게 되지 않을까?
아직 존재하지 않은 코드에 대해 테스트를 작성하기 보다는,
행위에 대한 명세를 작성하는 것이라고 생각하면 직관적으로 쉽게 이해가 된다. 이것이 BDD의 핵심이다.
BDD에서는 주로 Given-When-Then 패턴을 사용하는데, 각 단계는 아래와 같이 의미한다.
Given
When
Then
위 코드를 BDD 패턴에 맞게 해석해보자.
// given
List<ImageDTO.RequestDTO> requestImageDTOs = MockUtil.getImageDtoRequestDtos();
PackageDTO.RequestDTO requestPackageDTO = new PackageDTO.RequestDTO(1L, requestImageDTOs);
String requestBody = om.writeValueAsString(requestPackageDTO);
테스트 시나리오를 진행하는데 필요한 값이나 객체들을 설정한 코드들이다.
이 때 테스트 할 메서드는 POST 형식이며, DTO를 통해 입력 값을 클라이언트로부터 전달 받으므로 이를 객체로 만든 후 ObjectMapper를 이용해 JSON으로 직렬화 한다.
ResultActions resultActions = mvc.perform(
post(urlPrefix + "/package")
.content(requestBody)
.contentType(MediaType.APPLICATION_JSON_VALUE)
);
위에서 선언한 MockMvc 클라이언트를 통해서 urlPrefix + "/package" url로 post 요청을 보낸다.
이는 시나리오를 진행하는데 필요한 조건인 테스트 할 메서드를 호출한다.
나는 Then 이전으로 eye 단계를 추가하였다.
// eye
String responseBody = resultActions.andReturn().getResponse().getContentAsString();
System.out.println("테스트 : " + responseBody);
이는 카카오 테크 캠퍼스 당시 배웠던 개발 방법이며, eye 단계에서는 Server가 보낸 Response에 대해서 Log를 찍어 직접 확인한다.
코드를 보면 Server가 보낸 Response, 즉 JSON을 그대로 로깅하는 역할을 한다.
// then - verify
resultActions.andExpect(jsonPath("$.status").value("success"));
해당 단계에서는 시나리오를 완료했을 때 보장하는 결과를 명시하며, 예상하는 결과와 실제 Response를 통해 받은 값을 비교함으로써 테스트의 성공 / 실패 여부를 판단한다.
해당 테스트 메서드는 POST 메서드 였기에 위 코드가 전부이지만, GET 메서드의 Then 단계를 살펴보자.
// then - verify
resultActions.andExpect(jsonPath("$.status").value("success"));
resultActions.andExpect(jsonPath("$.data.id").value(1));
resultActions.andExpect(jsonPath("$.data.trackingNo").value(1));
resultActions.andExpect(jsonPath("$.data.images[0].filename").value("파일 이름1"));
위 코드들은 실제로 Server로 부터 Response 받은 데이터들과 내가 예상하는 결과값을 비교하는 코드들이다.
테스트 좀 재미따..
정확히는 내 코드에 대해서 근거 있는 자신감이 생기는 과정이 재미가 있는 것 같다.
