You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring Boot STOMP客户端Mock对象验证失败求助

STOMP集成测试中Mockito验证LobbyService方法未被调用排查

我正在开发实时多人游戏API,计划为STOMP API编写集成测试。虽然集成测试里用Mock不算常规操作,但我想验证特定事件下是否调用了正确的端点与方法。

我用@MockBean模拟了LobbyService,但执行verify(lobbyService).matchPlayer(any())时,Mockito提示该方法未被调用。以下是测试代码和调用该方法的控制器代码:

测试代码

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = {"spring.main.allow-bean-definition-overriding=true"})
public class PublishToServerTest {

    @LocalServerPort
    private int port;

    @MockBean
    private SimpMessagingTemplate messagingTemplate;

    @MockBean
    private LobbyService lobbyService;

    @Test
    public void testPublishPlayerToLobby() throws InterruptedException {
        var timeout = 3; // Seconds
        var webSocketClient = new StandardWebSocketClient();
        var stompClient = new WebSocketStompClient(webSocketClient);
        stompClient.setMessageConverter(new MappingJackson2MessageConverter());
        stompClient.setTaskScheduler(new DefaultManagedTaskScheduler());

        var sessionHandler = new ExtendedStompSessionHandler() {
            @Override
            public void afterConnected(StompSession session, @NonNull StompHeaders connectedHeaders) {
                var clientPayload = new IncomingPlayerClientPayload("0", 0);
                session.send("/game/seek-opponent", clientPayload);

                verify(lobbyService).matchPlayer(any());
                getLatch().countDown();
            }

            @Override
            public void handleException(@NonNull StompSession session, StompCommand command, @NonNull StompHeaders headers, @NonNull byte[] payload, @NonNull Throwable exception) {
                fail(exception.getMessage());
            }
        };
        stompClient.connectAsync(String.format("ws://localhost:%d/ws", port), sessionHandler);

        var messageSent = sessionHandler.getLatch().await(timeout, TimeUnit.SECONDS);
        assertTrue(messageSent);
    }
}

控制器代码

@Controller
@RequiredArgsConstructor
public class LobbyController {
    private final SimpMessagingTemplate messagingTemplate;
    private final LobbyService lobbyService;

    @MessageMapping("/seek-opponent")
    public void handleIncomingUser(@Payload IncomingPlayer incomingPlayer) {
        final String notificationUrl = "/lobby/notify";
        lobbyService.matchPlayer(incomingPlayer).ifPresentOrElse(
                multiplayerGame -> {
                    messagingTemplate.convertAndSendToUser(
                            multiplayerGame.getPlayer1().getId(),
                            notificationUrl,
                            new LobbyNotification(multiplayerGame)
                    );
                    messagingTemplate.convertAndSendToUser(
                            multiplayerGame.getPlayer2().getId(),
                            notificationUrl,
                            new LobbyNotification(multiplayerGame)
                    );
                },
                () -> messagingTemplate.convertAndSendToUser(
                        incomingPlayer.getId(),
                        notificationUrl,
                        new LobbyNotification()
                )
        );
    }
}

问题原因与修复方案

1. 验证时机过早

session.send()是异步操作,你在发送消息后立刻调用verify,此时服务器端的handleIncomingUser方法大概率还没执行到matchPlayer。必须等服务器处理完请求后再做验证。

2. 消息类型不匹配

测试里发送的是IncomingPlayerClientPayload,但控制器接收的是IncomingPlayer。如果消息转换器没配置好,无法完成类型转换,控制器方法根本不会被触发,自然不会调用matchPlayer。

3. 线程上下文问题

afterConnected运行在WebSocket客户端线程,和服务器处理请求的线程是分开的,直接在这个回调里做验证可能存在线程可见性问题,或者服务器还没来得及调用Mock方法。

修复后的测试代码

调整验证时机,确保消息类型匹配,并通过CountDownLatch等待服务器处理完成:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = {"spring.main.allow-bean-definition-overriding=true"})
public class PublishToServerTest {

    @LocalServerPort
    private int port;

    @MockBean
    private SimpMessagingTemplate messagingTemplate;

    @MockBean
    private LobbyService lobbyService;

    private final CountDownLatch serverLatch = new CountDownLatch(1);

    @Test
    public void testPublishPlayerToLobby() throws InterruptedException {
        var timeout = 3;
        var webSocketClient = new StandardWebSocketClient();
        var stompClient = new WebSocketStompClient(webSocketClient);
        stompClient.setMessageConverter(new MappingJackson2MessageConverter());
        stompClient.setTaskScheduler(new DefaultManagedTaskScheduler());

        // 提前给Mock设置返回值,避免控制器逻辑卡壳
        doReturn(Optional.empty()).when(lobbyService).matchPlayer(any(IncomingPlayer.class));

        var sessionHandler = new ExtendedStompSessionHandler() {
            @Override
            public void afterConnected(StompSession session, @NonNull StompHeaders connectedHeaders) {
                // 发送控制器期望的IncomingPlayer类型对象,或者确保转换器能完成转换
                var payload = new IncomingPlayer("0", 0);
                session.send("/game/seek-opponent", payload);
                // 订阅服务器返回的通知,确认处理完成
                session.subscribe("/user/0/lobby/notify", this);
            }

            @Override
            public void handleFrame(StompHeaders headers, Object payload) {
                // 收到服务器响应后再执行验证
                try {
                    verify(lobbyService, times(1)).matchPlayer(any(IncomingPlayer.class));
                } finally {
                    serverLatch.countDown();
                }
            }

            @Override
            public void handleException(@NonNull StompSession session, StompCommand command, @NonNull StompHeaders headers, @NonNull byte[] payload, @NonNull Throwable exception) {
                fail(exception.getMessage());
                serverLatch.countDown();
            }
        };
        stompClient.connectAsync(String.format("ws://localhost:%d/ws", port), sessionHandler);

        // 等待服务器处理完成
        assertTrue(serverLatch.await(timeout, TimeUnit.SECONDS));
        // 主线程再做一次验证,确保线程安全
        verify(lobbyService, times(1)).matchPlayer(any(IncomingPlayer.class));
    }
}

额外注意点

  • 确保消息转换器能正确处理IncomingPlayerClientPayload到IncomingPlayer的转换,或者直接发送控制器需要的类型。
  • 提前用doReturn设置Mock的返回值,避免控制器因为Mock返回空而抛出异常。
  • 验证操作最好放在主线程或者服务器响应回调中,避免异步时序问题。

内容的提问来源于stack exchange,提问作者Kenuff

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.20 14:53:12