WebSocket and SSE in tests
Drive a WebSocket route's handler and read a Server-Sent Events stream from TestClient, with no socket.
TestClient drives two kinds of long-lived responses in-process (since 18.29.0): a WebSocket route's upgrade handler, over an in-memory duplex, and a text/event-stream body, read as it arrives. Both wait with timeouts, so a handler that never answers fails the test instead of hanging it.
WebSocket
client.websocket(path).connect() sends the opening handshake through the app. When the route answers with WebSocket::upgrade's 101, the handler runs on a thread of its own — the same handler the server would run — and you get a TestWebSocket:
use rust_web_server::app::App;
use rust_web_server::test_client::TestClient;
use rust_web_server::websocket::{Message, WebSocket};
#[test]
fn echo() {
let app = App::with_state(()).get("/echo", |request, _params, _conn, _state| {
WebSocket::upgrade(request, |mut ws| {
while let Ok(Some(Message::Text(text))) = ws.recv() {
let _ = ws.send_text(&text);
}
})
});
let client = TestClient::new(app);
let mut ws = client.websocket("/echo").connect().expect("a 101");
ws.send_text("hi");
assert_eq!("hi", ws.recv_text());
assert_eq!(Some(1000), ws.close(1000, "done")); // the handler's close code
}Anything but an upgrade comes back as the Err response, so refusals are testable too:
let refused = client.websocket("/admin/live").connect().err().expect("refused");
assert_eq!(401, refused.status());| Method | What it does |
|---|---|
.header(name, value) |
add a handshake header (Authorization, Cookie, Sec-WebSocket-Protocol, …) |
.timeout(d) |
how long recv/close wait for the handler (default 5 s) |
send_text / send_binary |
send a message, masked as a browser does |
ping(payload) / send(frame) |
a ping, or any Frame |
send_raw(bytes) |
bytes as they are, to test protocol errors (an unmasked frame gets close code 1002) |
recv() |
Ok(Some(Message)); Ok(None) once the handler sent a close (answered for you); Err on a timeout or a handler that returned without closing. Pings are answered and skipped, fragments joined |
recv_text() / recv_binary() |
the next message, panicking if it's anything else |
recv_frame() |
the next raw frame (pings and pongs included) |
close_code() |
the handler's close code, once it closed |
close(code, reason) |
the closing handshake; waits for the handler to return, returns its close code |
handler_finished(timeout) |
whether the handler returned |
handshake() |
the 101 response and its headers |
Dropping a TestWebSocket ends the connection: the handler's recv returns an error. A handler that reaches for tokio::runtime::Handle::current() finds the test's runtime when the test is #[tokio::test].
Server-Sent Events
TestResponse::sse() reads a text/event-stream body — streamed by Sse::stream (stream_pipe) as it arrives, or buffered by Sse::into_response at once — and parses it into SseFrame { event, data, id, retry }:
use std::time::Duration;
use rust_web_server::app::App;
use rust_web_server::sse::{Sse, SseEvent};
use rust_web_server::test_client::TestClient;
#[test]
fn ticks() {
let app = App::with_state(()).get("/ticks", |_req, _params, _conn, _state| {
let (events, response) = Sse::stream();
std::thread::spawn(move || {
for n in 1..=3 {
let _ = events.send(SseEvent::data(n.to_string()).event_type("tick").id(n.to_string()));
}
});
response
});
let client = TestClient::new(app);
let events = client.get("/ticks").send().sse_events(3, Duration::from_secs(5));
assert_eq!(3, events.len());
assert_eq!("tick", events[0].event);
assert_eq!("1", events[0].data);
assert_eq!(Some("1"), events[0].id.as_deref());
}sse_events(n, timeout) returns up to n events within timeout in all — fewer when the stream ends or time runs out, so assert on the count. For finer control keep the TestSse from .sse():
| Method | What it does |
|---|---|
next_event(timeout) |
the next event, or None (stream ended, or timed out) |
events(n, timeout) |
up to n events |
collect_until_end(timeout) |
every event until the stream ends (waits the whole timeout if it never does) |
is_ended() / error() |
whether the stream ended, and why if it was an error |
comments() |
comment lines seen (keep-alives) |
Parsing follows the HTML Standard's rules: lines end in CRLF, LF or CR; data lines join with \n; the event type defaults to message; a block without data isn't dispatched; retry must be digits. SseParser is the same parser on its own (parser.feed(bytes) returns the events those bytes completed). It refuses an event or line over 8 MiB (sse::MAX_EVENT_BYTES) instead of buffering without bound.
A streamed body is read on a thread of its own; it ends with the stream, or at the first read after the TestSse is dropped (for Sse::stream, the next event or keep-alive comment). TestResponse::is_streamed() tells whether a response has a streamed body, and take_stream() hands you its reader for anything that isn't SSE.