Writing Unit Tests for Actix with Websockets
The Problem
I am implementing a chat service using websockets based on Rust with Actix. Actix provides numerous examples, including a websocket-chat example, which I used as a reference for my implementation. However, I encountered difficulties while attempting to add my first unit test, following the testing documentation. Unfortunately, I couldn’t get it to work as I consistently received a response status of 400.
The Solution
After thorough debugging, I discovered that I was missing some headers, specifically:
Upgrade: websocket
Connection: Upgrade
Sec-Websocket-Key: <my-websocket-key>
Sec-WEbSocket-Version: 13
More information about these headers can be found on Websocket’s Wikipedia page.
Unlike in production scenarios, the value for Sec-Websocket-Key does not matter as long as the header exists.
In addition, I initially asserted 200 OK
using assert!(resp.status().is_success());
, following the documentation. However, a successful response when connecting to the WebSocket is 101 Switching Protocols
, which I now verify using assert_eq!(resp.status(), 101);
.
The Code
1mod server;
2mod session;
3
4use actix::{Actor, Addr, StreamHandler};
5use actix_web::middleware::Logger;
6use actix_web::{web, App, Error, HttpRequest, HttpResponse, HttpServer};
7use actix_web_actors::ws;
8
9struct MyWs;
10
11impl Actor for MyWs {
12 type Context = ws::WebsocketContext<Self>;
13}
14
15impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for MyWs {
16 fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
17 match msg {
18 Ok(ws::Message::Ping(msg)) => ctx.pong(&msg),
19 Ok(ws::Message::Text(text)) => ctx.text(text),
20 Ok(ws::Message::Binary(bin)) => ctx.binary(bin),
21 _ => (),
22 }
23 }
24}
25
26async fn chat_route(
27 req: HttpRequest,
28 stream: web::Payload,
29 srv: web::Data<Addr<server::ChatServer>>,
30) -> Result<HttpResponse, Error> {
31 ws::start(
32 session::WsChatSession {
33 id: 0,
34 room: Vec::new(),
35 addr: srv.get_ref().clone(),
36 },
37 &req,
38 stream,
39 )
40}
41
42#[actix_web::main]
43async fn main() -> std::io::Result<()> {
44 env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
45
46 let server = server::ChatServer::new().start();
47
48 log::info!("starting HTTP server at http://localhost:8080");
49
50 HttpServer::new(move || {
51 App::new()
52 .app_data(web::Data::new(server.clone()))
53 .route("/ws/", web::get().to(chat_route))
54 .wrap(Logger::default())
55 })
56 .bind(("127.0.0.1", 8080))?
57 .run()
58 .await
59}
60
61#[cfg(test)]
62mod tests {
63 use super::*;
64
65 use actix_web::body::MessageBody;
66 use actix_web::dev::{ServiceFactory, ServiceRequest, ServiceResponse};
67 use actix_web::{
68 http::header::ContentType, http::header::HeaderValue, http::header::CONNECTION,
69 http::header::SEC_WEBSOCKET_KEY, http::header::SEC_WEBSOCKET_VERSION,
70 http::header::UPGRADE, test,
71 };
72
73 fn setup() -> App<
74 impl ServiceFactory<
75 ServiceRequest,
76 Response = ServiceResponse<impl MessageBody>,
77 Config = (),
78 InitError = (),
79 Error = Error,
80 >,
81 > {
82 env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
83
84 let server = server::ChatServer::new().start();
85
86 App::new()
87 .app_data(web::Data::new(server.clone()))
88 .route("/ws/", web::get().to(chat_route))
89 .wrap(Logger::default())
90 }
91
92 #[actix_web::test]
93 async fn test_unauthorized_without_authorization_header() {
94 let app = test::init_service(setup()).await;
95
96 let req = test::TestRequest::get()
97 .uri("/ws/")
98 .insert_header((UPGRADE, HeaderValue::from_static("websocket")))
99 .insert_header((CONNECTION, HeaderValue::from_static("Upgrade")))
100 .insert_header((SEC_WEBSOCKET_VERSION, HeaderValue::from_static("13")))
101 .insert_header((
102 SEC_WEBSOCKET_KEY,
103 HeaderValue::from_static("does-not-matter-for-testing"),
104 ))
105 .insert_header(ContentType::plaintext())
106 .to_request();
107
108 let resp = test::call_service(&app, req).await;
109
110 assert!(resp.status().is_informational());
111 assert_eq!(resp.status(), 101);
112 }
113}
Lessons Learned
Return App from a function
The return type is as follow:
1App<
2 impl ServiceFactory<
3 ServiceRequest,
4 Response = ServiceResponse<impl MessageBody>,
5 Config = (),
6 InitError = (),
7 Error = Error,
8 >,
9 >
Taken from here: How can I return App from a function / why is AppEntry private?, which links to the test code here.
Unhide test output
By default, the Rust test suite hides output from test execution, which prevented me from debugging this. To display all output, you need to run it as follows: cargo test -- --nocapture
As explained in the display options section in The Cargo Book.