문제 상황

기존 테스트의 개수가 수백 개를 넘어가다 보니 테스트 속도를 조금이나마 개선해 보고자 테스트를 병렬로 수행시켜 보았습니다. 하지만, 단일 스레드에서는 잘 동작하던 테스트가 병렬로 수행하니 몇몇 깨지는 테스트들이 존재했습니다.

 

그중 웹소켓의 메시지 형식이 유효하지 않을 경우 예외 처리 메서드가 호출되는지 verify() 메서드를 통해 검증하는 테스트가 있었습니다. @Parameterized를 통해 테스트를 수행하였고, 각 테스트 인자당 1번씩만 호출되기를 기대했지만 스레드의 개수(4)만큼 호출되어 테스트가 실패하고 있었습니다.

 

[채팅 메시지를 받는 핸들러] 

  @MessageMapping("/room/{id}")
  @SendTo("/chat/room/{id}")
  public ChatPublishMessage send(
      @DestinationVariable
      long id,
      @Valid
      ChatMessage chatMessage
  ) {
    return chatService.sendMessage(id, chatMessage);
  }
@Getter
public class ChatMessage {

  @Positive
  private final long userId;

  @NotBlank
  @Length(min = 1, max = 2000)
  private final String content;

}

 

[예외처리 메서드]

@MessageExceptionHandler(RuntimeException.class)
public void handleException(RuntimeException e) {
  log.info(e.getMessage());
}

 

[테스트 코드]

content의 길이가 범위(1~2000)를 벗어 난 경우 예외처리 메서드(handleException())를 호출하는지에 verify 메서드를 통해 검증하는 방식으로 테스트를 구현하였습니다. websocket에 대한 테스트기에 따로 격리 환경을 구성하기 어려워 통합 테스트로 진행하였으며, 에러 핸들러가 있는 객체를 @SpyBean으로 등록하였습니다.

 

SpringBootTest의 경우 매번 applicationContext를 새로 생성하게 되면 리소스가 낭비되기 때문에 context caching을 통해 application콘텍스트를 재사용하게 됩니다. 이때 재사용 여부를 결정하는 데 있어 어떤 bean을 Mock으로 처리했느냐가 영향을 미치게 됩니다. 그래서 @MockBean이나 @SpyBean을 사용할 경우 동일한 test context를 사용하게 되는데, 병렬로 수행할 경우 test context를 사용하는 스레드들이 동일한 객체의 메서드를 호출할 것입니다.

@SpringBootTest(webEnvironment = DEFINED_PORT)
class ChatWebSocketControllerTest {

  @SpyBean
  ChatWebSocketController chatWebSocketController;
  
  ...
  
  @Nested
  @DisplayName("send 메서드는")
  class DescribeSend {
  
    @Nested
    @DisplayName("content길이가 범위를 벗어나면")
    class ContextWithOutOfRangeContentLength {

      @ParameterizedTest
      @ArgumentsSource(ContentSourceOutOfRange.class)
      @DisplayName("에러 핸들러를 호출한다.")
      void ItCallExceptionHandler(String content) throws InterruptedException {
        //given
        long roomId = 1L;

        //when
        String pubUrl = MessageFormat.format("/message/room/{0}", roomId);
        session.send(pubUrl, new ChatSendMessage(1L, content));

        //then
        sleep(1000);
        verify(chatWebSocketController).handleException(any(RuntimeException.class));
      }
    }
  }

 

 

단일 스레드 환경에서는 잘 동작하는 것을 확인할 수 있었는데, 스레드를 4개를 두고 병렬로 수행하였더니 테스트당 1번만 호출되어야 하는 예외처리 메서드가 4번이나 호출되는 것을 확인할 수 있었습니다. 메서드의 호출 횟수를 모든 스레드가 공유하고 있는 것 같다는 생각에 verify() 메서드의 내부 구현을 확인하였습니다.

 

Spring 공식문서에서의 병렬 테스트에 대한 언급

https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html#testcontext-parallel-test-execution

@MockBean, @SpyBean을 사용하는 경우에는 테스트를 병렬로 수행하지 말라라고 언급하고 있습니다.

 

Mockito.verify()의 구현

public static <T> T verify(T mock) {
    return MOCKITO_CORE.verify(mock, times(1));
}

 

verify메서드를 들여다보기 전에 인자로 전달된 times() 메서드를 확인해 보았습니다.

 

  ...
  public static VerificationMode times(int wantedNumberOfInvocations) {
    return VerificationModeFactory.times(wantedNumberOfInvocations);
  }
  ...
  ...
  public static Times times(int wantedNumberOfInvocations) {
    return new Times(wantedNumberOfInvocations);
  }
  ...
public class Times implements VerificationInOrderMode, VerificationMode {

    final int wantedCount;

    public Times(int wantedNumberOfInvocations) {
        if (wantedNumberOfInvocations < 0) {
            throw new MockitoException("Negative value is not allowed here");
        }
        this.wantedCount = wantedNumberOfInvocations;
    }
    
    @Override
    public void verify(VerificationData data) {
        List<Invocation> invocations = data.getAllInvocations();
        MatchableInvocation wanted = data.getTarget();

        if (wantedCount > 0) {
            checkMissingInvocation(data.getAllInvocations(), data.getTarget());
        }
        checkNumberOfInvocations(invocations, wanted, wantedCount);
    }
    ...

times() 메서드는 Times 객체를 팩토리를 통해 생성하여 반환합니다. 이때 times의 인자로 전달된 숫자는 wantedCount 필드 값이 됩니다. 이때 반환된 times() 객체의 verify() 메서드를 호출하여 메서드의 실제 호출 횟수와 기대하는 호출 횟수를 비교하게 되는데요, verify 메서드는 모킹 된 객체의 함수 호출 정보를 인자를 통해 전달받습니다.

 

@Override
public void verify(VerificationData data) {
    List<Invocation> invocations = data.getAllInvocations();
    MatchableInvocation wanted = data.getTarget();

    if (wantedCount > 0) {
        checkMissingInvocation(data.getAllInvocations(), data.getTarget());
    }
    checkNumberOfInvocations(invocations, wanted, wantedCount);
}

 

이때 전달하는 VerificationData는 InvocationContainer로부터 메서드의 호출 정보를 가져옵니다. Mockito는 CGLib을 통해 Mock 대상으로 지정된 객체들의 프락시를 만들고 해당 객체들이 호출되면 InvocationContainer에 객체의 호출 정보를 저장합니다. 

 

public class InvocationContainerImpl implements InvocationContainer, Serializable {

    ...

    public void setInvocationForPotentialStubbing(MatchableInvocation invocation) {
        registeredInvocations.add(invocation.getInvocation());
        this.invocationForStubbing = invocation;
    }

결국 메서드의 호출 정보는 객체 안에 저장되어 heap 영역에서 관리되기 때문에 모든 스레드가 호출 정보를 공유하게 됩니다.

 

건강한 테스트

Is Mockito thread-safe?
For healthy scenarios Mockito plays nicely with threads. For instance, you can run tests in parallel to speed up the build. Also, you can let multiple threads call methods on a shared mock to test in concurrent conditions. Check out a timeout() feature for testing concurrency.
However Mockito is only thread-safe in healthy tests, that is tests without multiple threads stubbing/verifying a shared mock. Stubbing or verification of a shared mock from different threads is NOT the proper way of testing because it will always lead to intermittent behavior. In general, mutable state + assertions in multi-threaded environment lead to random results. If you do stub/verify a shared mock across threads you will face occasional exceptions like: WrongTypeOfReturnValue, etc.

 

Mockito에서는 stubbing이나 verification에 대해서는 thread-safe하지 않다고 이야기합니다. 생각해 보면 스프링 빈은 여러 스레드에 걸쳐 공유되므로, 멀티스레드 환경에서 Websocket과 같은 비동기적 요청을 특정 함수의 호출 여부로 검증하는 것은 현재 요청으로 인한 호출이 아닐 가능성이 있기 때문에 바람직하지 않은 방식으로 보입니다. 

 함수의 호출을 검증하는 테스트가 과연 좋은 테스트일지 고민해보면서, 여태껏 테스트 커버리지를 올리기 위한 테스트 작성에 급급하지 않았나 하는 생각이 듭니다. 

+ Recent posts