stomp를 사용하고 있는 와중에 테스트 코드 작성 방법에 찾아보았고 여러 자료를 찾아본 끝에 아래와 같은 코드를 작성하였습니다. (많이 부족한 코드입니다...)
@UnitTest
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class StompTest {
@MockBean
private JwtUtils jwtUtils;
@MockBean
private HandleMessageUseCase handleMessageUseCase;
@Autowired
private SimpMessagingTemplate simpMessagingTemplate;
@LocalServerPort
private int port;
private final String SEND_MESSAGE_ENDPOINT = "/send/chat";
private final String SUBSCRIBE_ENDPOINT = "/single";
private String URL;
WebSocketStompClient stompClient;
CompletableFuture<MessageDto> arriveMessage;
StompSessionHandlerAdapter handler;
@BeforeEach
void setUp() {
URL = "ws://localhost:%d/ws".formatted(port);
arriveMessage = new CompletableFuture<>();
// websocket, sockjs, stomp 준비, 직렬화 설정
stompClient = new WebSocketStompClient(
new SockJsClient(List.of(new WebSocketTransport(new StandardWebSocketClient()))));
MappingJackson2MessageConverter messageConverter = new MappingJackson2MessageConverter();
ObjectMapper objectMapper = messageConverter.getObjectMapper();
objectMapper.registerModules(new JavaTimeModule(), new ParameterNamesModule());
stompClient.setMessageConverter(messageConverter);
// stomp message handler
handler = new StompSessionHandlerAdapter() {
@Override
public Type getPayloadType(StompHeaders headers) {
return MessageDto.class;
}
@Override
public void handleFrame(StompHeaders headers, Object payload) {
arriveMessage.complete((MessageDto)payload);
}
};
}
}
위 코드의 경우 완벽한 코드는 아니고 참고만 해주시면 좋을 것 같습니다.(통합테스트 환경을 구축한다면 더욱 간단해질 것 같지만 시간상 일단 이렇게 구현해봤습니다.)
@UnitTest
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class StompTest {
@MockBean
private JwtUtils jwtUtils;
@MockBean
private HandleMessageUseCase handleMessageUseCase;
@Autowired
private SimpMessagingTemplate simpMessagingTemplate;
@LocalServerPort
private int port;
private final String SEND_MESSAGE_ENDPOINT = "/send/chat";
private final String SUBSCRIBE_ENDPOINT = "/single";
private String URL;
WebSocketStompClient stompClient;
CompletableFuture<MessageDto> arriveMessage;
StompSessionHandlerAdapter handler;
구현 코드를 다 보기에는 양이 많아 간단하게 어떤 기능인지만 설명하겠습니다.
@UnitTest
는 단순 유닛 테스트,통합 테스트 구분을 위한 어노테이션입니다.@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
랜덤 포트로 띄웠습니다. 보다 격리된 환경에서 진행 할 수 있습니다.@LocalServerPort
현재 띄워진 서버의 포트를 가져올 수 있습니다.private final String SEND_MESSAGE_ENDPOINT = "/send/chat";
private final String SUBSCRIBE_ENDPOINT = "/single";
private String URL;
구독 및 메시지를 보낼 주소입니다.
@BeforeEach
void setUp() {
URL = "ws://localhost:%d/ws".formatted(port);
arriveMessage = new CompletableFuture<>();
// websocket, sockjs, stomp 준비, 직렬화 설정
stompClient = new WebSocketStompClient(
new SockJsClient(List.of(new WebSocketTransport(new StandardWebSocketClient()))));
MappingJackson2MessageConverter messageConverter = new MappingJackson2MessageConverter();
ObjectMapper objectMapper = messageConverter.getObjectMapper();
objectMapper.registerModules(new JavaTimeModule(), new ParameterNamesModule());
stompClient.setMessageConverter(messageConverter);
// stomp message handler
handler = new StompSessionHandlerAdapter() {
@Override
public Type getPayloadType(StompHeaders headers) {
return MessageDto.class;
}
@Override
public void handleFrame(StompHeaders headers, Object payload) {
arriveMessage.complete((MessageDto)payload);
}
};
}
URL = "ws://localhost:%d/ws".formatted(port);
: 테스트할 WebSocket 서버의 URL입니다.
arriveMessage = new CompletableFuture<>();
: 테스트 중에 도착한 메시지를 처리하는 데 사용되는 CompletableFuture 객체를 초기화합니다. 이를 통해 비동기적으로 일어나는 메시지 수신 과정을 컨트롤 할 수 있습니다.
stompClient = new WebSocketStompClient(...);
: WebSocketStompClient 객체를 생성하여 STOMP 프로토콜을 사용하여 WebSocket 통신을 설정합니다.
MappingJackson2MessageConverter messageConverter = new MappingJackson2MessageConverter();
: Jackson 라이브러리를 사용하여 JSON 형식의 메시지를 STOMP 메시지로 변환하고 역변환하는 데 사용될 MessageConverter 객체를 생성합니다.
stompClient.setMessageConverter(messageConverter);
: WebSocketStompClient에 MessageConverter를 설정합니다. 메시지 수신을 위한 준비단계라고 생각하시면 됩니다.
handler = new StompSessionHandlerAdapter() { ... }
: WebSocket 연결에서 발생하는 이벤트를 처리하기 위한 StompSessionHandlerAdapter 객체를 생성합니다. getPayloadType으로 수신 받을 메시지를 지정하며 handleFrame으로 메시지 수신시 처리할 과정을 적으시면 됩니다.
@DisplayName("message send")
@Test
void success_message_send() throws ExecutionException, InterruptedException, TimeoutException {
// given
when(jwtUtils.isValidToken(any())).thenReturn(true);
when(jwtUtils.parseMemberIdFromToken(any())).thenReturn("userIdFromToken");
// doNothing().when(handleMessageUseCase).publishMessage(any());
doAnswer(invocation -> {
simpMessagingTemplate.convertAndSend(SUBSCRIBE_ENDPOINT, new MessageDto(
"roomId", "senderId", "content", MessageType.SEND
));
return null;
}).when(handleMessageUseCase).publishMessage(any(MessageDto.class));
StompHeaders headers = new StompHeaders();
headers.add("Authorization", "Bearer mytoken");
StompSession stompSession = stompClient.connectAsync(URL, new WebSocketHttpHeaders(),
headers,
new StompSessionHandlerAdapter() {
})
.get(1, SECONDS);
stompSession.subscribe(SUBSCRIBE_ENDPOINT, handler);
//when
stompSession.send(SEND_MESSAGE_ENDPOINT+"/roomId", new MessageDto("secondRoom", "123", "hello", MessageType.SEND));
//then
assertThat(stompSession.isConnected()).isTrue();
verify(jwtUtils, times(1)).isValidToken(any());
verify(jwtUtils, times(1)).parseMemberIdFromToken(any());
verify(handleMessageUseCase, times(1)).publishMessage(any());
}
먼저 테스트 코드에대한 이해를 위해 간단하게 mocking한 것에 대해서 설명하겠습니다.
when(jwtUtils.isValidToken(any())).thenReturn(true);
when(jwtUtils.parseMemberIdFromToken(any())).thenReturn("userIdFromToken");
jwt 인증 과정을 처리하는 메소드입니다.
security 과정을 통과시키게 하기 위해 mocking하였습니다.
doAnswer(invocation -> {
simpMessagingTemplate.convertAndSend(SUBSCRIBE_ENDPOINT, new MessageDto(
"roomId", "senderId", "content", MessageType.SEND
));
return null;
}).when(handleMessageUseCase).publishMessage(any(MessageDto.class));
message가 도착한 경우 실제 로직에서는 kafka, redis, mongodb를 거쳐가며 처리하지만 이러한 과정을 생략하기 위해 when(handleMessageUseCase).publishMessage(any(MessageDto.class));
을 통해 mocking 하였습니다.
이때 실제 메시지가 도착한 경우 저는 그냥 간단하게 simpMessagingTemplate.convertAndSend
를 통해 현재 subscribe 중인 곳으로 메시지를 직접 날려주었습니다.
StompHeaders headers = new StompHeaders();
headers.add("Authorization", "Bearer mytoken");
StompSession stompSession = stompClient.connectAsync(URL,
new WebSocketHttpHeaders(),
headers,
new StompSessionHandlerAdapter() {
}).get(1, SECONDS);
stompSession.subscribe(SUBSCRIBE_ENDPOINT, handler);
stompClient.connectAsync
를 통해 stomp 연결을 해줍니다. 이때 StompHeaders를 통해 필요한 헤더 값을 넘겨주었습니다.(여기서의 헤더는 실제 http에서의 헤더와는 다릅니다. stomp에 대한 공식문서를 확인해보시면 좋을 것 같습니다.)CompletableFuture
이므로 get을 통해 값을 반환받을 때까지 대기 해주었습니다.stompSession.subscribe
subscribe 과정입니다. handle의 겨웅 위에서 setup 과정에서 설정한 클래스로 메시지 수신시 처리 동작을 지정해주었습니다.stompSession.send(SEND_MESSAGE_ENDPOINT+"/roomId", new MessageDto("secondRoom", "123", "hello", MessageType.SEND));
실제 메시지 전송 과정입니다. 이를 통해 메시지를 보낼 수 있습니다.
assertThat(stompSession.isConnected()).isTrue();
verify(jwtUtils, times(1)).isValidToken(any());
verify(jwtUtils, times(1)).parseMemberIdFromToken(any());
verify(handleMessageUseCase, times(1)).publishMessage(any());
실제로 stomp가 연결이 되었는지를 확인하였으며 나머지 mocking 한 class들이 동작을 하였는지를 검증하였습니다.
테스트가 성공한 것을 볼 수 있습니다.
개선의 여지가 많은 코드이므로 필요하시다면 추가 구현을 하면 될 것 같습니다.
JUnit을 이용한 웹소켓 테스트 - ksp7331
StompSession BlockingQueue 메시지 전달 안되는 문제 - hyng