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}