Skip to main content

rws_server/test_client/
mod.rs

1//! In-process testing: [`TestClient`] dispatches requests through an
2//! [`Application`] without opening a socket, and the helpers beside it cover
3//! the rest of a test:
4//!
5//! - [`TestClient::websocket`] drives a WebSocket route's upgrade handler over
6//!   an in-memory duplex ([`TestWebSocket`]);
7//! - [`TestResponse::sse`] reads a `text/event-stream` response, streamed or
8//!   buffered, and parses its events ([`TestSse`], [`SseFrame`]);
9//! - [`assert_openapi_snapshot`] compares the generated OpenAPI document with a
10//!   committed file (`RWS_UPDATE_SNAPSHOTS=1` rewrites it);
11//! - `TestDb` (`model-*` features) gives each test a database that leaves
12//!   nothing behind, and `factory` builds `#[derive(Factory)]` models with
13//!   default values.
14//!
15//! Everything here is for tests: nothing on the request path uses it. It is
16//! compiled into every build (like `TestClient` always was) so that an
17//! application's own tests, in another crate, reach it without a feature of
18//! their own.
19
20#[cfg(test)]
21mod tests;
22#[cfg(test)]
23mod helper_tests;
24pub mod sse;
25pub mod snapshot;
26#[cfg(not(target_arch = "wasm32"))]
27pub mod websocket;
28#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
29pub mod db;
30#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
31pub mod factory;
32
33pub use snapshot::{assert_json_snapshot, assert_openapi_snapshot, OpenApiSource};
34pub use sse::{SseFrame, SseParser, TestSse};
35#[cfg(not(target_arch = "wasm32"))]
36pub use websocket::{TestWebSocket, TestWebSocketRequest};
37#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
38pub use db::{TestDb, TestDbBuilder};
39
40use crate::application::Application;
41use crate::header::Header;
42use crate::http::VERSION;
43use crate::mime_type::MimeType;
44use crate::range::Range;
45use crate::request::{METHOD, Request};
46use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
47use crate::server::{Address, ConnectionInfo};
48
49/// An in-process HTTP test client that dispatches requests directly through an
50/// [`Application`] without opening a TCP socket.
51///
52/// Use this in unit and integration tests to exercise controllers and routing
53/// without starting the server.
54///
55/// # Example
56///
57/// ```rust,no_run
58/// # extern crate rws_server as rust_web_server;
59/// use rust_web_server::app::App;
60/// use rust_web_server::core::New;
61/// use rust_web_server::test_client::TestClient;
62///
63/// let client = TestClient::new(App::new());
64///
65/// let res = client.get("/healthz").send();
66/// assert_eq!(200, res.status());
67///
68/// let res = client.post("/echo")
69///     .header("Content-Type", "text/plain")
70///     .body_text("hello")
71///     .send();
72/// assert_eq!(200, res.status());
73/// ```
74pub struct TestClient<A: Application> {
75    app: A,
76    connection: ConnectionInfo,
77}
78
79impl<A: Application> TestClient<A> {
80    /// Create a test client wrapping `app`. Requests are dispatched on a
81    /// synthetic `127.0.0.1:12345 → 127.0.0.1:7878` connection.
82    pub fn new(app: A) -> Self {
83        TestClient {
84            app,
85            connection: ConnectionInfo {
86                client: Address { ip: "127.0.0.1".to_string(), port: 12345 },
87                server: Address { ip: "127.0.0.1".to_string(), port: 7878 },
88                request_size: 16000,
89                sni_hostname: None,
90                tls: false,
91            },
92        }
93    }
94
95    /// Build a `GET` request to `path`.
96    pub fn get(&self, path: &str) -> TestRequest<'_, A> {
97        TestRequest::new(METHOD.get.to_string(), path, self)
98    }
99
100    /// Build a `POST` request to `path`.
101    pub fn post(&self, path: &str) -> TestRequest<'_, A> {
102        TestRequest::new(METHOD.post.to_string(), path, self)
103    }
104
105    /// Build a `PUT` request to `path`.
106    pub fn put(&self, path: &str) -> TestRequest<'_, A> {
107        TestRequest::new(METHOD.put.to_string(), path, self)
108    }
109
110    /// Build a `PATCH` request to `path`.
111    pub fn patch(&self, path: &str) -> TestRequest<'_, A> {
112        TestRequest::new(METHOD.patch.to_string(), path, self)
113    }
114
115    /// Build a `DELETE` request to `path`.
116    pub fn delete(&self, path: &str) -> TestRequest<'_, A> {
117        TestRequest::new(METHOD.delete.to_string(), path, self)
118    }
119
120    /// Build an `OPTIONS` request to `path`.
121    pub fn options(&self, path: &str) -> TestRequest<'_, A> {
122        TestRequest::new(METHOD.options.to_string(), path, self)
123    }
124
125    /// Build a WebSocket upgrade request to `path`; call
126    /// [`connect`](TestWebSocketRequest::connect) to run the route's upgrade
127    /// handler over an in-memory connection (no socket).
128    ///
129    /// ```rust,no_run
130    /// # extern crate rws_server as rust_web_server;
131    /// use rust_web_server::app::App;
132    /// use rust_web_server::test_client::TestClient;
133    /// use rust_web_server::websocket::{Message, WebSocket};
134    ///
135    /// let app = App::with_state(()).get("/echo", |request, _params, _conn, _state| {
136    ///     WebSocket::upgrade(request, |mut ws| {
137    ///         while let Ok(Some(Message::Text(text))) = ws.recv() {
138    ///             let _ = ws.send_text(&text);
139    ///         }
140    ///     })
141    /// });
142    /// let client = TestClient::new(app);
143    /// let mut ws = client.websocket("/echo").connect().expect("a 101");
144    /// ws.send_text("hi");
145    /// assert_eq!("hi", ws.recv_text());
146    /// assert_eq!(Some(1000), ws.close(1000, "bye"));
147    /// ```
148    #[cfg(not(target_arch = "wasm32"))]
149    pub fn websocket(&self, path: &str) -> TestWebSocketRequest<'_, A> {
150        TestWebSocketRequest::new(TestRequest::new(METHOD.get.to_string(), path, self))
151    }
152
153    /// The application under test.
154    pub fn app(&self) -> &A {
155        &self.app
156    }
157}
158
159/// A pending test request. Chain builder methods then call [`TestRequest::send`].
160pub struct TestRequest<'a, A: Application> {
161    method: String,
162    path: String,
163    headers: Vec<Header>,
164    body: Vec<u8>,
165    client: &'a TestClient<A>,
166}
167
168impl<'a, A: Application> TestRequest<'a, A> {
169    fn new(method: String, path: &str, client: &'a TestClient<A>) -> Self {
170        TestRequest {
171            method,
172            path: path.to_string(),
173            headers: vec![],
174            body: Default::default(),
175            client,
176        }
177    }
178
179    /// Add a request header.
180    pub fn header(mut self, name: &str, value: &str) -> Self {
181        self.headers.push(Header { name: name.to_string().into(), value: value.to_string().into() });
182        self
183    }
184
185    /// Set the request body to raw bytes.
186    pub fn body_bytes(mut self, body: Vec<u8>) -> Self {
187        self.body = body;
188        self
189    }
190
191    /// Set the request body to a UTF-8 string.
192    pub fn body_text(mut self, text: &str) -> Self {
193        self.body = text.as_bytes().to_vec();
194        self
195    }
196
197    /// Set the request body to a JSON string, with
198    /// `Content-Type: application/json`.
199    pub fn body_json(mut self, json: &str) -> Self {
200        self.headers.push(Header { name: Header::_CONTENT_TYPE.into(), value: "application/json".into() });
201        self.body = json.as_bytes().to_vec();
202        self
203    }
204
205    /// Dispatch the request and return the response.
206    pub fn send(self) -> TestResponse {
207        TestResponse::from_response(self.dispatch())
208    }
209
210    /// Whether a header named `name` (case-insensitive) was added.
211    #[cfg(not(target_arch = "wasm32"))]
212    fn has_header(&self, name: &str) -> bool {
213        self.headers.iter().any(|h| h.name.eq_ignore_ascii_case(name))
214    }
215
216    fn into_request(self) -> (Request, &'a TestClient<A>) {
217        let request = Request {
218            method: self.method,
219            request_uri: self.path,
220            http_version: VERSION.http_1_1.to_string(),
221            headers: self.headers,
222            body: self.body.into(),
223        };
224        (request, self.client)
225    }
226
227    /// Run the request through the application; an `Err` becomes a `500`.
228    fn dispatch(self) -> Response {
229        let (request, client) = self.into_request();
230        client.app.execute(&request, &client.connection).unwrap_or_else(error_response)
231    }
232}
233
234#[cfg(feature = "http2")]
235impl<'a, S: Send + Sync + 'static> TestRequest<'a, crate::async_state::AsyncAppWithState<S>> {
236    /// Dispatch the request to an `App::with_async_state` app and await
237    /// the handler, as the async servers do (on this runtime when it's
238    /// multi-threaded, else on rws's `rws-async` runtime). Use it from
239    /// `#[tokio::test]`: [`send`](Self::send) blocks the test's thread while
240    /// the handler runs, so on a single-threaded test runtime anything made
241    /// on it — a PostgreSQL or MySQL `DbPool`'s connections — can't make
242    /// progress meanwhile.
243    pub async fn send_async(self) -> TestResponse {
244        let (request, client) = self.into_request();
245        let result = client.app.respond(&request, &client.connection).await;
246        TestResponse::from_response(result.unwrap_or_else(error_response))
247    }
248}
249
250/// The `500` a handler's `Err(message)` becomes.
251fn error_response(message: String) -> Response {
252    let dummy = Request {
253        method: "GET".to_string(),
254        request_uri: "/".to_string(),
255        http_version: VERSION.http_1_1.to_string(),
256        headers: vec![],
257        body: Default::default(),
258    };
259    let header_list = Header::get_header_list(&dummy);
260    let cr = Range::get_content_range(message.into_bytes(), MimeType::TEXT_PLAIN.to_string());
261    Response::get_response(STATUS_CODE_REASON_PHRASE.n500_internal_server_error, Some(header_list), Some(vec![cr]))
262}
263
264/// The result of a dispatched test request.
265pub struct TestResponse {
266    status: i16,
267    reason: String,
268    headers: Vec<Header>,
269    body: Vec<u8>,
270    stream: StreamSlot,
271}
272
273/// A streamed body's reader, reachable only through `&mut` — so
274/// `TestResponse` stays `Sync` and unwind-safe, as it was before it kept one.
275struct StreamSlot(Option<Box<dyn std::io::Read + Send>>);
276
277// SAFETY: a shared `&StreamSlot` gives no access to the reader (only whether
278// there is one); the reader itself is `Send`, and is used only through
279// `&mut StreamSlot`, i.e. by one thread at a time.
280unsafe impl Sync for StreamSlot {}
281impl std::panic::UnwindSafe for StreamSlot {}
282impl std::panic::RefUnwindSafe for StreamSlot {}
283
284impl std::fmt::Debug for TestResponse {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        f.debug_struct("TestResponse")
287            .field("status", &self.status)
288            .field("reason", &self.reason)
289            .field("headers", &self.headers.iter().map(|h| format!("{}: {}", h.name, h.value)).collect::<Vec<_>>())
290            .field("body", &String::from_utf8_lossy(&self.body))
291            .field("streamed", &self.is_streamed())
292            .finish()
293    }
294}
295
296impl TestResponse {
297    fn from_response(mut r: Response) -> Self {
298        let body: Vec<u8> = r.content_range_list.iter()
299            .flat_map(|cr| cr.body.iter().copied())
300            .collect();
301
302        // Mirror Response::generate_response, which only adds these headers
303        // at HTTP/1.1 write time — TestClient bypasses that path entirely.
304        if r.content_range_list.len() == 1 {
305            let content_range = r.content_range_list.get(0).unwrap();
306            r.headers.push(Header {
307                name: Header::_CONTENT_TYPE.into(),
308                value: content_range.content_type.to_string().into(),
309            });
310            if let Some(value) = crate::response::content_range_header_value(r.status_code, content_range) {
311                r.headers.push(Header {
312                    name: Header::_CONTENT_RANGE.into(),
313                    value: value.into(),
314                });
315            }
316            r.headers.push(Header {
317                name: Header::_CONTENT_LENGTH.into(),
318                value: content_range.body.len().to_string().into(),
319            });
320        }
321
322        TestResponse {
323            status: r.status_code,
324            reason: r.reason_phrase,
325            headers: r.headers,
326            body,
327            stream: StreamSlot(r.stream_pipe.take()),
328        }
329    }
330
331    /// HTTP status code, e.g. `200`.
332    pub fn status(&self) -> i16 {
333        self.status
334    }
335
336    /// HTTP reason phrase, e.g. `"OK"`.
337    pub fn reason(&self) -> &str {
338        &self.reason
339    }
340
341    /// Return the value of the first header matching `name` (case-insensitive).
342    pub fn header(&self, name: &str) -> Option<&str> {
343        let lower = name.to_lowercase();
344        self.headers
345            .iter()
346            .find(|h| h.name.to_lowercase() == lower)
347            .map(|h| h.value.as_ref())
348    }
349
350    /// All response headers.
351    pub fn headers(&self) -> &[Header] {
352        &self.headers
353    }
354
355    /// Raw response body bytes.
356    pub fn body_bytes(&self) -> &[u8] {
357        &self.body
358    }
359
360    /// Response body decoded as UTF-8. Panics if the body is not valid UTF-8.
361    pub fn body_text(&self) -> &str {
362        std::str::from_utf8(&self.body).expect("response body is not valid UTF-8")
363    }
364
365    /// `true` if the status code is 2xx.
366    pub fn is_success(&self) -> bool {
367        (200..300).contains(&self.status)
368    }
369
370    /// `true` if the body is streamed (`Response::stream_pipe`, as from
371    /// [`Sse::stream`](crate::sse::Sse::stream)) rather than buffered. A
372    /// streamed body isn't in [`body_bytes`](Self::body_bytes): read it with
373    /// [`sse`](Self::sse) or [`take_stream`](Self::take_stream).
374    pub fn is_streamed(&self) -> bool {
375        self.stream.0.is_some()
376    }
377
378    /// The streamed body's reader, if there is one (taken: a second call
379    /// returns `None`).
380    pub fn take_stream(&mut self) -> Option<Box<dyn std::io::Read + Send>> {
381        self.stream.0.take()
382    }
383
384    /// Read the body as Server-Sent Events: a streamed body as it arrives
385    /// (on a thread of its own), a buffered one at once.
386    ///
387    /// ```rust,no_run
388    /// # extern crate rws_server as rust_web_server;
389    /// use std::time::Duration;
390    /// use rust_web_server::app::App;
391    /// use rust_web_server::sse::{Sse, SseEvent};
392    /// use rust_web_server::test_client::TestClient;
393    ///
394    /// let app = App::with_state(()).get("/events", |_req, _params, _conn, _state| {
395    ///     let (events, response) = Sse::stream();
396    ///     std::thread::spawn(move || {
397    ///         let _ = events.send(SseEvent::data("1").event_type("tick").id("1"));
398    ///     });
399    ///     response
400    /// });
401    /// let client = TestClient::new(app);
402    /// let events = client.get("/events").send().sse_events(1, Duration::from_secs(5));
403    /// assert_eq!("tick", events[0].event);
404    /// assert_eq!("1", events[0].data);
405    /// ```
406    pub fn sse(mut self) -> TestSse {
407        match self.take_stream() {
408            Some(reader) => TestSse::from_reader(reader),
409            None => TestSse::from_bytes(&self.body),
410        }
411    }
412
413    /// Up to `n` events from [`sse`](Self::sse), waiting at most `timeout` in
414    /// all (fewer when the stream ends or the time runs out).
415    pub fn sse_events(self, n: usize, timeout: std::time::Duration) -> Vec<SseFrame> {
416        self.sse().events(n, timeout)
417    }
418}