Mockito의 verify() 오류 | (feat. @Mockbean fields are not reset for @Nested tests)

gibeom·2022년 11월 25일
1

삽질 저장소

목록 보기
4/5

문제 상황

[현재 상태]

  • 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());
                  }
              }
          }
      }
  • 회원 삭제 API를 요청 시 인증 토큰(Access Token)이 없다면, Spring Security 필터에서 예외를 던지므로 실제 컨트롤러 메서드 내부까지 들어오면 안되는 상황
    • 회원 삭제 API는 로그인 인증(Authentication)이 필요한 API이다.
    • Spring Security 필터를 설정하고 회원 삭제 Controller 메서드에 @PreAuthorize를 선언하여, 인증 토큰이 없다면 401 응답코드로 반환하도록 설정한 상태이다.
    • 즉, 인증 토큰이 없다면 Controller 메서드 내부로 들어오지 못한다.
      @DeleteMapping("{id}")
      @ResponseStatus(HttpStatus.NO_CONTENT)
      @PreAuthorize("isAuthenticated()")
      void destroy(@PathVariable final Long id) {
          // 인증 토큰 없으면 여기 안으로 못 들어옴!!
          userService.deleteUser(id);
      }

[문제]

전체 예시코드: https://gist.github.com/giibeom/4eaf8ed1cccbe82aa24ebd6192950d10

  • 테스트를 단일로 실행할 때는 정상적으로 테스트 성공
  • 전체 테스트를 돌릴 때는 아래와 같이 테스트가 실패함
    1. Request Header에 인증 토큰 없이 delete API 요청
    2. Security Filter에서 401 응답 코드로 반환하고, Controller 메서드 내부로는 들어가지 않아야 함
    3. 하지만 테스트 결과는 401 응답 코드로 반환되지만, 실제 Controller 메서드의 로직은 실행 됐다며 테스트 실패


문제 원인

아래 예시코드와 같이 @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로 묶여있는 컨택스트끼리는 호출 기록이 초기화되지 않나?
전체 테스트 실행 후 디버깅을 해보니 순서는 아래와 같이 실행되었다.

  1. 인증 토큰이 있는 유효한 테스트 케이스
  2. 인증 토큰이 없는 예외 테스트 케이스

즉 유효한 테스트 케이스에서 먼저 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되어야 한다.


혹시 모르니 스프링 공식문서를 확인해보자.

[스프링 최신 공식문서(2022.11.25 기준)]

(가볍게만 봐도 괜찮습니다!)

  • 중첩된 각 테스트 클래스는 고유한 활성 프로필 세트를 제공하므로 중첩된 각 테스트 클래스에 대해 고유한 Application Context가 생성된다.
  • Application Context가 로딩되어 캐시된 후, 동일한 테스트 스위트 내에서의 동일한 고유 컨텍스트 구성인 경우 모든 후속 테스트에서 재사용 된다.
  • @Nested Test와 이를 감싸고 있는 Test에 동일한 모의 객체가 있는 경우 컨텍스트를 재사용한다.

참고 자료: @Nested test class configuration

Each nested test class provides its own set of active profiles, resulting in a distinct ApplicationContextfor 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 버전에서는 정상적으로 성공하는 것을 확인하였다!


그렇다면 나는 현재 상황에서 문제를 어떻게 해결해야될까?

[방법 1]

제일 단순한 생각이지만 실천하기엔 쉽지 않은 방법은 스프링 버전을 올리는 방법일 것이다 😂

[방법 2]

Spring Github Issue에서의 스프링 개발자가 제안한 방법은 각 @Nested 테스트 클래스마다 동일한 config를 설정해주는 것이다. 즉 @SpringBootTest, @AutoConfigureMockMvc, @Autowired private MockMvc mockMvc, @MockBean private UserService userService 를 각각의 중첩 클래스에 모두 달아주는 것이다.

실제 아래 코드에서 작성해보았더니 테스트는 정상적으로 통과되는 것을 확인하였다.

근데 사실상 일일히 모든 테스트 코드에 붙여주는 방법을 실천하기는 어려울 것 같았다.

[방법 3]

직접 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);
    }
}

다음 프로젝트부터는 스프링 버전좀 올려야겠다 😂😂


Reference

[예시 코드]

profile
꾸준함의 가치를 향해 📈

0개의 댓글