thacoon's Blog

Writing Unit Tests for Actix with Websockets

· thacoon

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:

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.

#rust #actix #websockets

Reply to this post by email ↪