Testing Java WebSockets with Spring WebSocket Test

A WebSocket keeps one TCP connection open for two-way traffic, so a passing HTTP request test can still miss a broken upgrade, subscription, frame handler, close handshake, or reconnect path. The useful assertion is not simply whether the client sent text, but whether the application reaches the expected state while multiple sessions remain active.

Spring WebSocket Test supports several boundaries, from an in-memory session to a running server and its message broker. Selecting the narrowest boundary that can expose a defect keeps feedback fast without overlooking configuration, security, or asynchronous delivery problems.

Australian delivery teams often span Sydney, Melbourne, Brisbane, and Perth. Their tests must account for network latency, reconnection after regional failures, and daylight-saving changes. Using Australia/Sydney rather than a fixed UTC offset prevents time-sensitive message assertions from failing when clocks move.

The local market includes Australian enterprise, retail, and financial services systems built with Java and Spring. Staging systems may run in the AWS Sydney Region, ap-southeast-2, while diagnostic logs must respect the Australian Privacy Act 1988 when messages contain customer or payment-related data.

Start with the boundary you need

Begin with a pure handler test. MockWebSocketSession supplies session state, outbound messages, and close information without opening a network port. It is fast and deterministic for payload conversion, validation, authorisation branches, and close handling. It cannot prove that security filters, WebSocket routing, message converters, or broker subscriptions are configured correctly.

An embedded server test starts the application on a random port, normally through @SpringBootTest(webEnvironment = RANDOM_PORT). Connecting a test client to that port exercises the handshake and the actual Spring configuration. This is the right level for verifying request mappings, interceptors, session attributes, converters, and exception handling.

Keep real-network checks for deployment-specific behaviour such as TLS termination, firewall rules, load balancing, and the path to a broker in ap-southeast-2. Such tests are valuable but slower and harder to diagnose. They should supplement embedded integration tests rather than replace them.

Test handler behaviour without a server

Construct the handler directly and use realistic messages rather than mocks for its own collaborators. A unit test should establish a session, deliver valid and invalid frames, and inspect everything the handler attempts to send or close.

@Test
void acknowledgesAnOrderEvent() throws Exception {
    var handler = new OrderSocketHandler(new ObjectMapper());
    var session = new MockWebSocketSession(
        URI.create("ws://localhost/ws/orders"));

    handler.afterConnectionEstablished(session);
    handler.handleTextMessage(session,
        new TextMessage("{\"id\":\"A-17\",\"type\":\"accepted\"}"));

    assertThat(session.getSentMessages())
        .extracting(message -> message.getPayload().toString())
        .containsExactly("{\"id\":\"A-17\",\"type\":\"ack\"}");
}

This example distinguishes an accepted client event from the server acknowledgement and verifies the exact outbound frame. Add cases for malformed JSON, unknown event types, missing identifiers, and attempted access to another customer’s order. Assert close status and code where the contract specifies them.

A mock session records direct sends, but it does not emulate a broker distributing messages to subscribers. It is therefore most effective for handler decisions and payload construction. Spring-managed beans, converters, validation, and security still require the embedded test boundary.

Exercise a real handshake and frames

Spring Framework 6.1 and newer provide WebSocketTestClient, a fluent client for exercising an actual WebSocket endpoint. An embedded application exposes its random port, which becomes the client’s base URL. This approach tests the HTTP upgrade, negotiated subprotocol, frame exchange, and close sequence through the running application.

WebSocketTestClient client = WebSocketTestClient.bindToServer()
    .baseUrl("http://localhost:" + port)
    .build();

client.execute("/ws/orders")
    .handshake()
    .expectStatus().isOk()
    .sendText("{\"type\":\"ping\"}")
    .receiveText()
    .expectMessage("{\"type\":\"pong\"}")
    .then()
    .expectClose()
    .then();

The endpoint used by this example deliberately closes after its response. Most long-lived applications should instead test heartbeats, idle timeouts, server-initiated closure, and reconnection. For Spring versions without the fluent client, StandardWebSocketClient can perform the same handshake, although the test must collect responses with a bounded Future or latch.

Authentication failures occur before normal frames are exchanged, so assert the rejected handshake status and confirm that no subscription or message was processed. Apply the same care to browser Origin checks, cookies, CSRF policy, subprotocol negotiation, and maximum frame size. Never place bearer tokens in URLs that may be written to access logs.

Verify broadcasts and asynchronous delivery

A direct handler response and a broker broadcast have different semantics. For STOMP or a plain WebSocket subscription, connect and subscribe before publishing the triggering event. A test that publishes immediately can pass intermittently because the broker has not yet recorded the subscriber.

Avoid replacing every wait with Thread.sleep. Awaitility can wait for a queue, session attribute, or received message to reach a specific condition while keeping a short timeout. Include delays, task failures, client disconnects, and concurrent sends in the test design. If a handler sends from several threads, verify whether it requires a concurrency-safe session decorator to prevent overlapping WebSocket frames.

Queues, brokers, and external consumers introduce their own delivery guarantees and failure modes. The middleware integration patterns provide useful architectural context for retries and isolation, but the test should still assert the result visible through the WebSocket connection. Log identifiers and event types instead of full payloads, especially where the Australian Privacy Principles apply.

Signals and checks for a production-shaped suite

A layered suite normally combines local, broker-backed, and controlled staging checks:

Test boundary Execution target Strongest evidence Main limitation
Mock session JVM only Handler branches and outbound frames No handshake or broker
Embedded server Local random port Spring configuration and security No public network path
Broker-backed client Local or Testcontainers broker Subscription and broadcast behaviour More moving parts
Staging check Controlled remote endpoint TLS, routing, deployment behaviour Slower and data-sensitive

An embedded broker test should prove that only subscribed, authorised clients receive an event. Parallel tests also need isolated destinations or principals so messages cannot cross between test methods. A reliable CI baseline combines mock handler tests with embedded server and broker tests.

Protocol-level assertions

Operational release checks

The next step is to add one integration test that connects to /ws/orders, sends an expired token, and asserts a 401 handshake rejection before any frame is exchanged.