Skip to main content

ullun/
testing.rs

1//! Test an [`App`] in-process, without sockets
2//!
3//! [`TestClient`] sends requests straight through routing, middleware and
4//! handlers, exactly as a running server would, and returns the response. No
5//! port is opened, so tests are fast and cannot collide.
6//!
7//! ```
8//! use ullun::prelude::*;
9//! use ullun::testing::TestClient;
10//!
11//! # tokio_test_block(async {
12//! let app = App::new().post("/echo", |req: Request| async move {
13//!     let name: String = req.json::<serde_json::Value>()?["name"]
14//!         .as_str()
15//!         .unwrap_or("nobody")
16//!         .to_string();
17//!     Ok(Response::json(serde_json::json!({ "hello": name })))
18//! });
19//! let client = TestClient::new(app);
20//!
21//! let res = client.post("/echo").json(&serde_json::json!({ "name": "Ada" })).send().await;
22//! assert_eq!(res.status(), 200);
23//! assert_eq!(res.json::<serde_json::Value>()["hello"], "Ada");
24//! # });
25//! # fn tokio_test_block<F: std::future::Future>(f: F) {
26//! #     tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(f);
27//! # }
28//! ```
29//!
30//! [`TestClient::with_cookies`] keeps a cookie jar between calls, which makes
31//! login flows and sessions testable. Background tasks registered with
32//! `App::background` are not started, and WebSocket upgrades need a real
33//! server (`App::serve` on port 0).
34
35use crate::app::{App, InProcess};
36use crate::request::Request;
37use bytes::Bytes;
38use http_body_util::BodyExt;
39use serde::{de::DeserializeOwned, Serialize};
40use std::collections::BTreeMap;
41use std::net::SocketAddr;
42use std::sync::{Arc, Mutex};
43
44/// Calls an app in-process, see the [module documentation](self)
45#[derive(Clone)]
46pub struct TestClient {
47    server: Arc<InProcess>,
48    jar: Option<Arc<Mutex<BTreeMap<String, String>>>>,
49}
50
51impl TestClient {
52    pub fn new(app: App) -> Self {
53        Self {
54            server: Arc::new(app.into_in_process()),
55            jar: None,
56        }
57    }
58
59    /// Remember cookies set by responses and send them on later requests
60    pub fn with_cookies(mut self) -> Self {
61        self.jar = Some(Arc::default());
62        self
63    }
64
65    /// Value of a cookie in the jar
66    pub fn cookie(&self, name: &str) -> Option<String> {
67        self.jar
68            .as_ref()?
69            .lock()
70            .unwrap_or_else(|e| e.into_inner())
71            .get(name)
72            .cloned()
73    }
74
75    /// Forget all cookies
76    pub fn clear_cookies(&self) {
77        if let Some(jar) = &self.jar {
78            jar.lock().unwrap_or_else(|e| e.into_inner()).clear();
79        }
80    }
81
82    pub fn request(&self, method: &str, path: &str) -> TestRequest {
83        TestRequest {
84            client: self.clone(),
85            method: method.to_string(),
86            path: path.to_string(),
87            headers: Vec::new(),
88            body: Bytes::new(),
89            peer: ([127, 0, 0, 1], 50_000).into(),
90            secure: false,
91        }
92    }
93
94    pub fn get(&self, path: &str) -> TestRequest {
95        self.request("GET", path)
96    }
97    pub fn post(&self, path: &str) -> TestRequest {
98        self.request("POST", path)
99    }
100    pub fn put(&self, path: &str) -> TestRequest {
101        self.request("PUT", path)
102    }
103    pub fn patch(&self, path: &str) -> TestRequest {
104        self.request("PATCH", path)
105    }
106    pub fn delete(&self, path: &str) -> TestRequest {
107        self.request("DELETE", path)
108    }
109    pub fn head(&self, path: &str) -> TestRequest {
110        self.request("HEAD", path)
111    }
112    pub fn options(&self, path: &str) -> TestRequest {
113        self.request("OPTIONS", path)
114    }
115
116    fn remember(&self, headers: &http::HeaderMap) {
117        let Some(jar) = &self.jar else { return };
118        let mut jar = jar.lock().unwrap_or_else(|e| e.into_inner());
119        for value in headers.get_all(http::header::SET_COOKIE) {
120            let Ok(text) = value.to_str() else { continue };
121            let mut parts = text.split(';').map(str::trim);
122            let Some((name, val)) = parts.next().and_then(|nv| nv.split_once('=')) else {
123                continue;
124            };
125            let expired = parts.any(|attr| {
126                attr.eq_ignore_ascii_case("max-age=0")
127                    || attr.to_ascii_lowercase().starts_with("max-age=-")
128            });
129            if expired || val.is_empty() {
130                jar.remove(name);
131            } else {
132                jar.insert(name.to_string(), val.to_string());
133            }
134        }
135    }
136}
137
138/// A request under construction
139pub struct TestRequest {
140    client: TestClient,
141    method: String,
142    path: String,
143    headers: Vec<(String, String)>,
144    body: Bytes,
145    peer: SocketAddr,
146    secure: bool,
147}
148
149impl TestRequest {
150    pub fn header(mut self, name: &str, value: &str) -> Self {
151        self.headers.push((name.to_string(), value.to_string()));
152        self
153    }
154
155    pub fn bearer(self, token: &str) -> Self {
156        self.header("authorization", &format!("Bearer {token}"))
157    }
158
159    /// Send a cookie in addition to those from the jar
160    pub fn cookie(self, name: &str, value: &str) -> Self {
161        self.header("cookie", &format!("{name}={value}"))
162    }
163
164    pub fn json<T: Serialize>(mut self, value: &T) -> Self {
165        self.headers
166            .push(("content-type".into(), "application/json".into()));
167        self.body = Bytes::from(serde_json::to_vec(value).unwrap_or_default());
168        self
169    }
170
171    /// `application/x-www-form-urlencoded` body
172    pub fn form<T: Serialize>(mut self, value: &T) -> Self {
173        self.headers.push((
174            "content-type".into(),
175            "application/x-www-form-urlencoded".into(),
176        ));
177        self.body = Bytes::from(serde_urlencoded::to_string(value).unwrap_or_default());
178        self
179    }
180
181    pub fn text(mut self, body: &str) -> Self {
182        self.headers
183            .push(("content-type".into(), "text/plain; charset=utf-8".into()));
184        self.body = Bytes::from(body.to_string());
185        self
186    }
187
188    pub fn body(mut self, body: impl Into<Bytes>) -> Self {
189        self.body = body.into();
190        self
191    }
192
193    /// Pretend the request comes from this peer address
194    pub fn remote_addr(mut self, addr: impl Into<SocketAddr>) -> Self {
195        self.peer = addr.into();
196        self
197    }
198
199    /// Pretend the request arrived over TLS (`Request::is_secure`)
200    pub fn https(mut self) -> Self {
201        self.secure = true;
202        self
203    }
204
205    pub async fn send(self) -> TestResponse {
206        let uri: http::Uri = self
207            .path
208            .parse()
209            .expect("test request path is not a valid URI");
210        let method = http::Method::from_bytes(self.method.as_bytes()).expect("invalid method");
211        let mut headers = http::HeaderMap::new();
212        headers.insert(
213            http::header::HOST,
214            http::HeaderValue::from_static("localhost"),
215        );
216        for (name, value) in &self.headers {
217            let (Ok(n), Ok(v)) = (
218                http::HeaderName::from_bytes(name.as_bytes()),
219                http::HeaderValue::from_str(value),
220            ) else {
221                panic!("invalid test header {name}: {value}");
222            };
223            headers.append(n, v);
224        }
225        if let Some(jar) = &self.client.jar {
226            let jar = jar.lock().unwrap_or_else(|e| e.into_inner());
227            if !jar.is_empty() {
228                let joined = jar
229                    .iter()
230                    .map(|(n, v)| format!("{n}={v}"))
231                    .collect::<Vec<_>>()
232                    .join("; ");
233                headers.append(
234                    http::header::COOKIE,
235                    http::HeaderValue::from_str(&joined).expect("cookie jar value"),
236                );
237            }
238        }
239        if !self.body.is_empty() {
240            headers.insert(http::header::CONTENT_LENGTH, self.body.len().into());
241        }
242
243        let request = Request::new(method, uri, headers, self.body);
244        let res = self
245            .client
246            .server
247            .call(request, self.peer, self.secure)
248            .await;
249        let (parts, body) = res.into_parts();
250        // a stream that never ends must not hang the test
251        let body = tokio::time::timeout(std::time::Duration::from_secs(10), body.collect())
252            .await
253            .expect("response body did not finish within 10 seconds")
254            .map(|c| c.to_bytes())
255            .unwrap_or_default();
256        self.client.remember(&parts.headers);
257        TestResponse {
258            status: parts.status.as_u16(),
259            headers: parts.headers,
260            body,
261        }
262    }
263}
264
265/// What the app answered
266#[derive(Debug, Clone)]
267pub struct TestResponse {
268    status: u16,
269    headers: http::HeaderMap,
270    body: Bytes,
271}
272
273impl TestResponse {
274    pub fn status(&self) -> u16 {
275        self.status
276    }
277
278    pub fn headers(&self) -> &http::HeaderMap {
279        &self.headers
280    }
281
282    /// Header value, empty when absent or not text
283    pub fn header(&self, name: &str) -> &str {
284        self.headers
285            .get(name)
286            .and_then(|v| v.to_str().ok())
287            .unwrap_or("")
288    }
289
290    pub fn bytes(&self) -> &Bytes {
291        &self.body
292    }
293
294    pub fn text(&self) -> String {
295        String::from_utf8_lossy(&self.body).into_owned()
296    }
297
298    /// Body parsed as JSON; panics with the body in the message when it is not
299    pub fn json<T: DeserializeOwned>(&self) -> T {
300        serde_json::from_slice(&self.body)
301            .unwrap_or_else(|e| panic!("response is not the expected JSON ({e}): {}", self.text()))
302    }
303}