Posts Writing Unit Tests for Actix with Websockets
Post
Cancel

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
mod server;
mod session;

use actix::{Actor, Addr, StreamHandler};
use actix_web::middleware::Logger;
use actix_web::{web, App, Error, HttpRequest, HttpResponse, HttpServer};
use actix_web_actors::ws;

struct MyWs;

impl Actor for MyWs {
    type Context = ws::WebsocketContext<Self>;
}

impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for MyWs {
    fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) {
        match msg {
            Ok(ws::Message::Ping(msg)) => ctx.pong(&msg),
            Ok(ws::Message::Text(text)) => ctx.text(text),
            Ok(ws::Message::Binary(bin)) => ctx.binary(bin),
            _ => (),
        }
    }
}

async fn chat_route(
    req: HttpRequest,
    stream: web::Payload,
    srv: web::Data<Addr<server::ChatServer>>,
) -> Result<HttpResponse, Error> {
    ws::start(
        session::WsChatSession {
            id: 0,
            room: Vec::new(),
            addr: srv.get_ref().clone(),
        },
        &req,
        stream,
    )
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));

    let server = server::ChatServer::new().start();

    log::info!("starting HTTP server at http://localhost:8080");

    HttpServer::new(move || {
        App::new()
            .app_data(web::Data::new(server.clone()))
            .route("/ws/", web::get().to(chat_route))
            .wrap(Logger::default())
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

#[cfg(test)]
mod tests {
    use super::*;

    use actix_web::body::MessageBody;
    use actix_web::dev::{ServiceFactory, ServiceRequest, ServiceResponse};
    use actix_web::{
        http::header::ContentType, http::header::HeaderValue, http::header::CONNECTION,
        http::header::SEC_WEBSOCKET_KEY, http::header::SEC_WEBSOCKET_VERSION,
        http::header::UPGRADE, test,
    };

    fn setup() -> App<
        impl ServiceFactory<
            ServiceRequest,
            Response = ServiceResponse<impl MessageBody>,
            Config = (),
            InitError = (),
            Error = Error,
        >,
    > {
        env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));

        let server = server::ChatServer::new().start();

        App::new()
            .app_data(web::Data::new(server.clone()))
            .route("/ws/", web::get().to(chat_route))
            .wrap(Logger::default())
    }

    #[actix_web::test]
    async fn test_unauthorized_without_authorization_header() {
        let app = test::init_service(setup()).await;

        let req = test::TestRequest::get()
            .uri("/ws/")
            .insert_header((UPGRADE, HeaderValue::from_static("websocket")))
            .insert_header((CONNECTION, HeaderValue::from_static("Upgrade")))
            .insert_header((SEC_WEBSOCKET_VERSION, HeaderValue::from_static("13")))
            .insert_header((
                SEC_WEBSOCKET_KEY,
                HeaderValue::from_static("does-not-matter-for-testing"),
            ))
            .insert_header(ContentType::plaintext())
            .to_request();

        let resp = test::call_service(&app, req).await;

        assert!(resp.status().is_informational());
        assert_eq!(resp.status(), 101);
    }
}

Lessons Learned

Return App from a function

The return type is as follow:

1
2
3
4
5
6
7
8
9
App<
        impl ServiceFactory<
            ServiceRequest,
            Response = ServiceResponse<impl MessageBody>,
            Config = (),
            InitError = (),
            Error = Error,
        >,
    >

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.

This post is licensed under CC BY 4.0 by the author.