1. 테스트 코드도 유지보수를 해야할까?
1-1. 배경
현재 진행하고 있는 프로젝트에서는 개발된 API에 대해서 Spring MockMvc를 활용한 컨트롤러 테스트를 작성하여 전체 기능을 검증하는 방식으로 테스트를 진행한다.
이러한 상황에서, 테스트 코드를 작성하는 것은 각 유즈 케이스에서 발생할 수 있는 문제를 사전에 확인하고, 개발된 기능이 의도에 맞게 작동하는지 확인하며, 기능에 대한 명세로서 동작할 수 있다.
기존에는 테스트 코드를 작성할 때, 유지보수나 추후 변경에 대한 고려는 크게 하지 않았다. 기능이 바뀌었을 때 테스트 코드가 변경되는 것은 당연한 것이라고 때문이다. 하지만 테스트 코드 작성의 편의성과 테스트 대상 외 변경사항으로 인한 영향을 최소화하기 위해 테스트 코드 리팩토링을 진행하게 되었다.
1-2. 순수 MockMvc API 사용 시 장점
현재 프로젝트에서 작성하는 대부분의 컨트롤러 테스트는 아래와 같이 MockMvc를 기반으로 작성되어 있다.
@Test
@DisplayName("when send request, then save category")
public void saveCategory_rawMockMvc_success() throws Exception {
// given
User client = categoryPostDataGenerator.getUsers().get(0);
CategoryCreateDto createDto = CategoryCreateDto.builder()
.name(UUID.randomUUID().toString())
.build();
String token = jwtUtils.generateAccessToken(client.getUserEmail());
// when
MockHttpServletRequestBuilder request = MockMvcRequestBuilders.post("/api/categories")
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createDto));
String responseString = mockMvc.perform(request)
.andExpect(status().is2xxSuccessful())
.andReturn().getResponse().getContentAsString();
CategoryResponseDto response = objectMapper.readValue(responseString,
new TypeReference<>() {
});
// then
assertCategoryResponseDto(response);
}
이러한 방식의 장점은 명확하다.
- 테스트 코드 자체가 요청에 대한 명세로서 동작하고 있음
- 테스트 코드의 흐름에서 필요한 데이터나 요청 방식 등이 모두 드러나 있음
다만, 이러한 방식이 꼭 좋은 점만 있는 것은 아니다.
1-3. 순수 MockMvc API 사용 시 문제점
기존 테스트 코드에서 검증하고자 하는 것은, 특정 주소로 요청을 보냈을 때, 정상적으로 동작하는지를 확인하는 것이다. 하지만, 기존 테스트 코드에서는 이러한 테스트 대상에서 벗어난 부분들이 보인다.
- category에 대한 테스트가 토큰을 생성하는 방식을 알고 있어야함
- 각 테스트에서 문자열 형태의 응답을 응답 객체로 변환하는 작업이 반복되고 있음
이러한 요소들은 대부분의 테스트에서 필수적인 부분이므로 테스트의 일부분으로 보일 수도 있다. 하지만, 실제 프로젝트에서는 토큰 생성 방식이 변경된 상황에서 대부분의 테스트에서 변경 사항이 발생하는 등의 문제가 생기기도 했다.
현재는 테스트할 기능이 적었기에 문제가 없었지만, 만약 이러한 테스트가 수십, 수백개라면, 변경해야 하는 테스트 코드 또한 기하급수적으로 늘어날 수 있었을 것이다.
2. MockMvc를 위한 유틸 클래스 만들기 : MockMvcUtils
2-1. 요구사항
MockMvc에 대한 유틸 클래스를 만들 때, 다음과 같은 요구사항들을 고려했다.
- MockMvcUtils를 사용하더라도, 테스트는 명세서로서 역할해야 한다.
- 유틸 클래스를 사용할 때, 사용감이 좋아야 한다.
- 토큰 생성과 같이 실제 테스트와의 연관성이 적은 부분을 실제 테스트와 분리해야 한다.
- 응답 문자열 변환 등 중복되는 부분을 추상화한다.
2-2. 문제 해결 방법
따라서 다음과 같은 방식으로 MockMvcUtils를 만들었다.

전체 구조로 볼 때, MockMvcUtils는 doAuthRequest 메서드를 제공하여 토큰과 함께 요청을 전송하고, 응답값을 R 타입으로 변환하도록 구현했다.
이를 위해 MockMvcRequestDto에서는 요청에 필요한 여러 데이터를 builder 형태로 받아 테스트가 문서로서 동작하도록 했다. 또한, T(요청 본문 타입)과 R(응답 객체 타입)을 명시하여 범용성을 높이고 MockMvcUtils 내부에서 타입 변환을 수행하도록 했다.
마지막으로 기존에 토큰 생성 시 필요한 데이터가 변경되어 테스트 코드에서 대규모 변경 요소가 발생했기에 TestClientDto를 생성하여 토큰 생성 시 필요한 데이터를 DTO로 묶는 방식으로 개선했다.
2-3. MockMvcUtils 클래스 코드
따라서, MockMvcUtils는 아래와 같이 구현되었다.
@Component
public class MockMvcUtils {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private ObjectMapper objectMapper;
private static final MediaType DEFAULT_CONTENT_TYPE = MediaType.APPLICATION_JSON;
/**
* 책임 : requestBuilder에 토큰 정보 추가
* 인증 정보를 기반으로 토큰을 생성하고 주입한 RequestBuilder 반환
* */
public MockHttpServletRequestBuilder addAuthentication(
MockHttpServletRequestBuilder requestBuilder, TestClientDto clientDto) {
return addAuthentication(requestBuilder, clientDto.getUserEmail());
}
/**
* 책임 : requestBuilder에 토큰 정보 추가
* 이메일을 기반으로 토큰을 생성하여 주입한 RequestBuilder 반환
* */
public MockHttpServletRequestBuilder addAuthentication(
MockHttpServletRequestBuilder requestBuilder, String userEmail) {
String jwtToken = jwtUtils.generateAccessToken(userEmail);
return requestBuilder.header(AuthProperty.ACCESS_TOKEN_HEADER,
AuthProperty.ACCESS_TOKEN_PREFIX + " " + jwtToken);
}
/**
* 책임 : 요청에 필요한 데이터를 넣고, mockMvc.perform으로 요청 수행
* 일반 HTTP 요청용 request builder에 요청 수행 시 필요한 정보를 추가하고, HTTP 요청 결과 반환
* */
public <T, R> ResultActions performAuthRequest(
MockHttpServletRequestBuilder requestBuilder,
MockMvcRequestDto<T, R> requestDto) throws Exception {
// params, body 설정
MockMvcRequestDto.addRequestInfoOnRequest(requestBuilder, requestDto, objectMapper);
// content-type 설정 ( 추후 별도 커스텀 헤더 필요 시, addRequestInfoOnRequest로 합칠 예정 )
requestBuilder.contentType(DEFAULT_CONTENT_TYPE);
return requestDto.getMockMvc()
.perform(addAuthentication(requestBuilder, requestDto.getClientDto()));
}
/**
* 책임 : 요청에 필요한 데이터를 넣고, mockMvc.perform으로 요청 수행
* Multipart HTTP 요청용 request builder에 요청 수행 시 필요한 정보를 추가하고, HTTP 요청 결과 반환
* */
public <R> ResultActions performMultipartAuthRequest(
MockMultipartHttpServletRequestBuilder requestBuilder,
MockMvcMultipartRequestDto<R> requestDto) throws Exception {
MockMvcMultipartRequestDto.addRequestInfoOnRequest(requestBuilder, requestDto);
return requestDto.getMockMvc()
.perform(addAuthentication(requestBuilder, requestDto.getClientDto()));
}
/**
* 책임 : ResultActions의 응답값을 responseType으로 변환
* ResultActions의 응답값을 responseType으로 변환 후 반환
* */
public <R> R parseResponse(ResultActions result, TypeReference<R> responseType)
throws UnsupportedEncodingException, JsonProcessingException {
String response = result.andReturn().getResponse().getContentAsString();
if (responseType.getType() == Void.class) {
return null;
}
return objectMapper.readValue(response, responseType);
}
/**
* 일반 HTTP 요청에 대해 DTO를 기반으로 요청을 수행하고 상태 코드 검증 후, 결과를 R 타입으로 변환
* */
public <T, R> R doAuthRequest(MockMvcRequestDto<T, R> requestDto) throws Exception {
MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.request(
requestDto.getHttpMethod(), requestDto.getPath());
// 요청에 필요한 데이터 추가 및 요청 수행 + 상태값 검증
ResultActions resultActions = performAuthRequest(requestBuilder, requestDto)
.andExpect(status().is(requestDto.getStatusCode()));
// 결과를 ResponseType으로 변환하여 반환
return parseResponse(resultActions, requestDto.getResponseType());
}
/**
* Multipart 요청에 대해 DTO를 기반으로 요청을 수행하고, 상태 코드 검증 후, 결과를 R 타입으로 변환
* */
public <R> R doAuthMultipartRequest(MockMvcMultipartRequestDto<R> requestDto) throws Exception {
MockMultipartHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.multipart(
requestDto.getPath());
// HTTP 메서드 설정
requestBuilder.with(request -> {
request.setMethod(requestDto.getHttpMethodName());
return request;
});
// 요청에 필요한 데이터 추가 및 요청 수행 + 상태값 검증
ResultActions resultActions = performMultipartAuthRequest(requestBuilder, requestDto)
.andExpect(status().is(requestDto.getStatusCode()));
// 결과를 ResponseType으로 변환하여 반환
return parseResponse(resultActions, requestDto.getResponseType());
}
}
doAuthRequest를 하나의 메서드로 구현하는 것이 아닌, 여러개의 메서드로 책임을 분리해 구현하였다.
- addAuthentication: 토큰 정보가 포함된 requestBuilder를 반환
- performAuthRequest : 요청에 필요한 정보를 추가하고, mockMvc.perform 결과 반환
- parseResponse : 응답을 response type으로 변환하여 반환
- doAuthRequest : performAuthRequest와 parseResponse 오케스트레이션 및 상태값 검증을 통해 결과 반환
단순히 doAuthRequest를 사용해야하는 것이 아닌, 복잡한 헤더 추가나 결과 검증 등을 고려하여 위 메서드 중 하나만 사용하더라도 편리성을 제공하도록 했고, 토큰 생성 자동화와 응답 변환 자동화에 초점을 맞추었다.
2-4. MockMvcUtils 사용 예시
결과적으로 테스트 코드는 아래와 같이 변경된다.
@Test
@DisplayName("when send request, then return categories that user has")
public void findAllByUser_success() throws Exception {
// given
User client = categoryPostDataGenerator.getUsers().get(0);
MultiValueMap<String, String> params = TestParams.withEmpty();
params.add(TestParams.USER_EMAIL, client.getUserEmail());
// when
List<CategoryResponseDto> response = mockMvcUtils.doAuthRequest(
MockMvcRequestDto.<Void, List<CategoryResponseDto>>builder()
.mockMvc(mockMvc)
.path("/api/categories")
.httpMethod(HttpMethod.GET)
.clientDto(TestClientDto.fromEntity(client))
.params(params)
.responseType(new TypeReference<>() {
})
.build()
);
// then
assertThat(response)
.isNotNull()
.hasSize(categoryRepository.findAllByOwner_UserEmail(client.getUserEmail()).size());
response.forEach(this::assertCategoryResponseDto);
}
빌더 패턴을 사용했기에 테스트에 필요한 값들과 매개변수, 요청 경로 등이 테스트에 드러나 있고, 결과적으로 테스트가 명세로서 동작하도록 했다. 또한, 토큰을 생성하고, 응답 문자열을 변환하는 부분을 doAuthRequest가 대신 수행해주므로 해당 부분에서의 변경 요소의 관리 난이도를 크게 낮추었다.
3. 마무리
개인적으로 추상화나 클래스 분리 등은 확실한 상황이 아니라면 사용을 자제해야 한다고 생각한다. 이는 과한 엔지니어링은 오히려 프로젝트의 크기가 커지는 문제로 이어질 수 있기에, 상황에 맞는 적절한 수준을 유지해야 한다는 개인의 개발 철학 때문이다.
이번 테스트 코드 개선 작업은 기존에 발생한 변경 요소를 해결하기 위해 여러 테스트에 공통적으로 나타나는 부분을 분리하여 테스트 코드 작성 난이도를 낮추고, 동일한 변경 사항 발생 시 변경의 영향을 최소화하기 위한 작업이었다.
하지만, 여러개의 status code 검증 등의 복잡한 검증 로직이 있다면 doAuthRequest를 사용할 수 없으므로, 이 부분은 지속적으로 MockMvcUtils를 개선하며 해결해볼 예정이다.
'Spring > 기초 개념' 카테고리의 다른 글
| [Spring Boot] MSA에서 git action과 docker를 활용한 Docker CI/CD 구축 (1) | 2025.04.21 |
|---|---|
| [Spring Boot] Spring MVC + WebClient 환경에서 JPA 사용하기 (0) | 2025.04.09 |
| [Spring Boot] Spring AOP Self Invocation과 @Transactional (0) | 2025.04.07 |
| Spring Data JPA의 영속성 컨텍스트 (0) | 2025.04.02 |
| [Spring Boot] 실시간 비동기 작업 처리기 만들기 - 실습 (0) | 2025.03.26 |