MockMvc(Spring MVC)를 사용하여 BDD 스타일로 Controller 테스트를 구성
@Nested
를 통해 각 테스트 대상 별로 묶어주는 방식을 사용하였다.
@Nested
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class 회원_삭제_API는 {
@Nested
@DisplayName("인증 토큰이 없다면")
class Context_with_not_exist_token {
@Test
@DisplayName("401 코드로 응답한다")
void it_responses_401() throws Exception {
ResultActions perform = mockMvc.perform(
delete("/users/" + ID_MIN.value())
);
perform.andExpect(status().isUnauthorized());
}
}
@Nested
@DisplayName("유효한 인증 토큰이 주어지고")
class Context_with_valid_token {
@Nested
@DisplayName("찾을 수 있는 id가 주어지면")
class Context_with_exist_id {
@Test
@DisplayName("204 코드로 응답한다")
void it_responses_204() throws Exception {
ResultActions perform = mockMvc.perform(
delete("/users/" + ID_MIN.value())
.header(HttpHeaders.AUTHORIZATION, VALID_TOKEN_1.인증_헤더값())
);
perform.andExpect(status().isNoContent());
}
}
}
}
@PreAuthorize
를 선언하여, 인증 토큰이 없다면 401 응답코드로 반환하도록 설정한 상태이다.@DeleteMapping("{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@PreAuthorize("isAuthenticated()")
void destroy(@PathVariable final Long id) {
// 인증 토큰 없으면 여기 안으로 못 들어옴!!
userService.deleteUser(id);
}
전체 예시코드: https://gist.github.com/giibeom/4eaf8ed1cccbe82aa24ebd6192950d10
아래 예시코드와 같이 @Nested
를 빼고 테스트를 진행해보았더니 정상적으로 테스트 성공하였다.
@Test
@DisplayName("401 코드로 응답한다")
void it_responses_401() throws Exception {
ResultActions perform = mockMvc.perform(
delete("/users/" + ID_MIN.value())
);
perform.andExpect(status().isUnauthorized());
verify(userService, never()).deleteUser(ID_MIN.value());
}
@Test
@DisplayName("204 코드로 응답한다")
void it_responses_204() throws Exception {
ResultActions perform = mockMvc.perform(
delete("/users/" + ID_MIN.value())
.header(HttpHeaders.AUTHORIZATION, VALID_TOKEN_1.인증_헤더값())
);
perform.andExpect(status().isNoContent());
verify(userService).deleteUser(ID_MIN.value());
}
그렇다면 @Nested
로 묶여있는 컨택스트끼리는 호출 기록이 초기화되지 않나?
전체 테스트 실행 후 디버깅을 해보니 순서는 아래와 같이 실행되었다.
즉 유효한 테스트 케이스에서 먼저 userService()
를 정상적으로 호출되고 테스트가 종료된다. 그 후 예외 테스트 케이스가 실행되는데, 직전에 userService()
를 호출한 기록이 초기화되지 않아서 테스트가 깨지는 것으로 보였다.
왜 이런 일이 발생하는 것일까?
Junit 5의 공식 문서에서 볼 수 있듯이 @TestInstance
를 따로 설정하지 않을 경우 테스트의 인스턴스의 생명 주기는 기본적으로 메서드 단위이다.
If @TestInstance is not declared on a test class or implemented test interface, the lifecycle mode will implicitly default to PER_METHOD
즉 테스트 하나가 종료되면 정상적으로 테스트에 대한 결과들이 reset되어야 한다.
혹시 모르니 스프링 공식문서를 확인해보자.
(가볍게만 봐도 괜찮습니다!)
@Nested
Test와 이를 감싸고 있는 Test에 동일한 모의 객체가 있는 경우 컨텍스트를 재사용한다.참고 자료:
@Nested
test class configuration
Each nested test class provides its own set of active profiles, resulting in a distinctApplicationContext
for each nested test class (see Context Caching for details).
참고 자료: Context Caching
Once the TestContext framework loads an ApplicationContext (or WebApplicationContext) for a test, that context is cached and reused for all subsequent tests that declare the same unique context configuration within the same test suite.
위의 공식 문서 내용으로는 잘 이해가 되지 않는다.
다른 자료를 서칭하다가 딱 나와 동일한 상황을 맞닥뜨린 사람이 Spring Github issue에 올려놓은 것을 발견하였다!
해당 이슈의 모든 내용을 정확히 이해하진 못했지만, 파파고를 수십번 돌려가면서 읽어봤을 땐 아래와 같은 내용인 것 같다. (영어를 못해서 전체를 읽는데만 2~3시간이 걸렸다… 😂)
@Nested
가 선언된 클래스는 감싸고 있는 클래스와 다른 별도의 Application Context를 사용한다.@Nested
테스트 클래스는 감싸져있는 클래스의 Application Context와 함께 호출된다.@Nested
테스트 클래스에는 mock이 포함되어 있지 않아서 reset할 것이 없다.@MockBean
은 바깥쪽 클래스에 있기 때문)즉 안쪽과 바깥쪽 테스트 클래스들이 Application Context를 공유하지 않고, 안쪽 클래스가 별도의 컨텍스트를 사용하므로, mock이 포함되어있지 않아서 리셋이 되지 않는 현상이다.
따라서 mock이 테스트가 끝나도 리셋이 되지 않으므로 verify()가 진행됐을 때 이전 테스트의 결과물이 그대로 남아있어서 테스트가 실패하는 것이였다.
해당 문제는 Spring Boot 2.4 버전에서 해결되었다고 한다.
(그래서 위의 Spring 공식문서에 “Nested Test와 이를 감싸고 있는 Test에 동일한 모의 객체가 있는 경우 컨텍스트를 재사용한다.” 라는 내용이 추가됐나 보다.)
하지만 내가 현재 프로젝트에서 사용하고 있는 Spring Boot 버전은 2.3.5 이다.
일단 궁금하니 진짜 버그가 해결되었는지 테스트해보자
아래는 현재 상황을 간단하게 재연한 코드이다.
build.gradle을 Spring Boot 버전별로 하나씩 준비하였다.
실제로 2.3.5 버전에서는 실패하는 테스트가 2.7.6 버전에서는 정상적으로 성공하는 것을 확인하였다!
제일 단순한 생각이지만 실천하기엔 쉽지 않은 방법은 스프링 버전을 올리는 방법일 것이다 😂
Spring Github Issue에서의 스프링 개발자가 제안한 방법은 각 @Nested
테스트 클래스마다 동일한 config를 설정해주는 것이다. 즉 @SpringBootTest
, @AutoConfigureMockMvc
, @Autowired private MockMvc mockMvc
, @MockBean private UserService userService
를 각각의 중첩 클래스에 모두 달아주는 것이다.
실제 아래 코드에서 작성해보았더니 테스트는 정상적으로 통과되는 것을 확인하였다.
근데 사실상 일일히 모든 테스트 코드에 붙여주는 방법을 실천하기는 어려울 것 같았다.
직접 mock을 리셋시켜주자!
현재 사용하고 있는 3.3.0 버전의 Mockito 공식 문서에서 초기화 방법을 찾아보았다.
[Mockito 공식문서]
17. Resetting mocks (Since 1.8.0)
reset()
Smart Mockito users hardly use this feature because they know it could be a sign of poor tests. Normally, you don't need to reset your mocks, just create new mocks for each test method.
해당 문서를 읽어보면 매우 여러줄에 걸쳐서 사용을 지양하라, 이걸 쓰기 전에 내 코드가 냄새나는지 봐라… 등등 자존감 낮추는 말들을 많이 적어놓은 것을 볼 수 있다. 또한 reset()
은 모든 모의 객체의 스터빙이 재설정된다.
따라서 호출 횟수를 재설정하기 위한 방법으로는 clearInvocations()
이 있다고 한다. (참고자료)
물론 해당 메서드에서도 동일하게 “무슨 수를 써서라도 이 방법을 피하라”고 기재되어있다.
clearInvocations
public static <T> void clearInvocations(T... mocks)
Try to avoid this method at all costs. Only clear invocations if you are unable to efficiently test your program.
하지만 현재 상황에서는 clearInvocations()
를 사용하여 Mock으로 설정한 userService
의 호출 횟수만 초기화 시켜주는 방법이 가장 적합해보였다. (현재 Spring Boot 버전에서 일어나는 오류이기에...)
따라서 아래와 같이 3번 방식으로 @BeforeEach
를 선언한 setUp()
에서 각 테스트 실행 마다 직전에 호출 횟수를 초기화 함으로써 문제를 해결하였다.
@WebMvcTest({UserController.class, MockMvcCharacterEncodingCustomizer.class})
@DisplayName("UserController 웹 유닛 테스트")
class UserControllerMockTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@BeforeEach
void setUp() {
// 각 테스트 실행마다 직전에 userService 호출 횟수 초기화
Mockito.clearInvocations(userService);
}
}
[예시 코드]