Skip to main content

rainier_framework/
testing.rs

1//! A test harness — [`TestApp`] and [`TestResponse`].
2//!
3//! ```ignore
4//! let app = TestApp::new(boot(Mode::Testing).await?)?;
5//!
6//! app.get("/health/ready").await
7//!     .assert_ok()
8//!     .assert_json_path("database.status", "ok");
9//!
10//! let user: UserView = app.get("/api/me").await.json();
11//! ```
12//!
13//! A feature test is three lines — make a
14//! request, assert the status, assert something about the body — and anything
15//! longer is the harness leaking.
16//!
17//! # It scopes the facades to *this* test
18//!
19//! The container facades resolve through is process-wide, so booting one
20//! application per test used to race: two tests boot, the second wins, and the
21//! first resolves out of a container that has been replaced. The symptom is
22//! `nothing is bound for rainier_config::repository::Config` on a random
23//! subset of tests on a random subset of runs.
24//!
25//! [`TestApp`] holds a [`FacadeScope`], which
26//! overrides the global one **for the thread the test runs on**. So each test
27//! gets its own application and its own configuration, and they do not
28//! interfere.
29//!
30//! `#[tokio::test]` uses a current-thread runtime, so a test and everything it
31//! awaits stay on that thread. Under `#[tokio::test(flavor = "multi_thread")]`
32//! a future can move, and after the move a facade resolves through whatever
33//! was installed globally — so a multi-threaded test wanting its own container
34//! should keep the work on one task, or install its application globally and
35//! not run in parallel with another that does.
36//!
37//! [`FacadeScope`]: rainier_container::FacadeScope
38
39use std::sync::Arc;
40
41use serde::de::DeserializeOwned;
42use serde_json::Value;
43
44use rainier_container::{scope_facade_application, Application, FacadeScope};
45use rainier_http::{Method, Request, Response, StatusCode};
46use rainier_server::Kernel;
47use rainier_support::Result;
48
49/// A booted application, ready to be asked for responses.
50///
51/// Not `Send`: it holds a thread-local facade scope, and moving it to another
52/// thread would unscope on one and pop on another.
53pub struct TestApp {
54    app: Arc<Application>,
55    kernel: Arc<Kernel>,
56    /// Dropped with the harness, which is what ends the scope.
57    _scope: FacadeScope,
58    /// Headers added to every request — a bearer token, usually.
59    defaults: Vec<(String, String)>,
60}
61
62impl TestApp {
63    /// Wrap an application your own `boot` produced.
64    ///
65    /// Takes the booted application rather than booting one, because what a
66    /// test wants exercised is *your* bootstrap — its providers, its
67    /// configuration, its routes — not a generic one this crate could build.
68    pub fn new(app: Arc<Application>) -> Result<Self> {
69        let kernel = app.resolve::<Kernel>()?;
70        let scope = scope_facade_application(Arc::clone(&app));
71
72        Ok(Self { app, kernel, _scope: scope, defaults: Vec::new() })
73    }
74
75    /// The application, for resolving a service to assert on.
76    pub fn app(&self) -> &Arc<Application> {
77        &self.app
78    }
79
80    /// Resolve a service — `app.resolve::<PostRepository>()?`.
81    pub fn resolve<T: Send + Sync + 'static>(&self) -> Result<Arc<T>> {
82        self.app.resolve::<T>()
83    }
84
85    /// Send this header on every subsequent request.
86    ///
87    /// ```ignore
88    /// let app = app.with_header("authorization", format!("Bearer {token}"));
89    /// ```
90    #[must_use = "this returns a configured harness rather than configuring in place"]
91    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
92        self.defaults.push((name.into(), value.into()));
93        self
94    }
95
96    /// Authenticate every subsequent request with a bearer token.
97    #[must_use = "this returns a configured harness rather than configuring in place"]
98    pub fn with_token(self, token: impl std::fmt::Display) -> Self {
99        self.with_header("authorization", format!("Bearer {token}"))
100    }
101
102    /// `GET uri`.
103    pub async fn get(&self, uri: &str) -> TestResponse {
104        self.send(self.request(Method::GET, uri).build()).await
105    }
106
107    /// `DELETE uri`.
108    pub async fn delete(&self, uri: &str) -> TestResponse {
109        self.send(self.request(Method::DELETE, uri).build()).await
110    }
111
112    /// `POST uri` with a JSON body.
113    pub async fn post(&self, uri: &str, body: &impl serde::Serialize) -> TestResponse {
114        self.send(self.request(Method::POST, uri).json(body).build()).await
115    }
116
117    /// `PUT uri` with a JSON body.
118    pub async fn put(&self, uri: &str, body: &impl serde::Serialize) -> TestResponse {
119        self.send(self.request(Method::PUT, uri).json(body).build()).await
120    }
121
122    /// `PATCH uri` with a JSON body.
123    pub async fn patch(&self, uri: &str, body: &impl serde::Serialize) -> TestResponse {
124        self.send(self.request(Method::PATCH, uri).json(body).build()).await
125    }
126
127    /// `POST uri` with no body — a form action, a publish, a logout.
128    pub async fn post_empty(&self, uri: &str) -> TestResponse {
129        self.send(self.request(Method::POST, uri).build()).await
130    }
131
132    /// A request builder carrying this harness's default headers.
133    ///
134    /// For the request the helpers above do not cover — a form encoding, an
135    /// odd header, a deliberately malformed body.
136    pub fn request(&self, method: Method, uri: &str) -> rainier_http::RequestBuilder {
137        let mut builder = Request::builder().method(method).uri(uri);
138        for (name, value) in &self.defaults {
139            builder = builder.header(name.as_str(), value.as_str());
140        }
141        builder
142    }
143
144    /// Send a request you built yourself, through the whole kernel.
145    ///
146    /// The real pipeline: global middleware, routing, route middleware, the
147    /// handler, and the exception renderer. A test that bypassed it would be
148    /// asserting on something no client can reach.
149    pub async fn send(&self, request: Request) -> TestResponse {
150        TestResponse::of(self.kernel.handle_request(request).await).await
151    }
152}
153
154/// One response, with its body already read.
155///
156/// The body is collected on construction so every assertion can be `&self` and
157/// chain. A streaming body can only be read once, and a harness that made you
158/// think about that would be a harness that made you think about the wrong
159/// thing.
160pub struct TestResponse {
161    status: StatusCode,
162    headers: Vec<(String, String)>,
163    body: String,
164}
165
166impl TestResponse {
167    /// Read `response` into an assertable one.
168    pub async fn of(response: Response) -> Self {
169        let status = response.status();
170        let headers = response
171            .headers()
172            .iter()
173            .filter_map(|(name, value)| {
174                Some((name.as_str().to_string(), value.to_str().ok()?.to_string()))
175            })
176            .collect();
177
178        let body = response.into_string().await.unwrap_or_default();
179
180        Self { status, headers, body }
181    }
182
183    /// The status.
184    pub fn status(&self) -> StatusCode {
185        self.status
186    }
187
188    /// The body, as text.
189    pub fn text(&self) -> &str {
190        &self.body
191    }
192
193    /// A header's value.
194    pub fn header(&self, name: &str) -> Option<&str> {
195        self.headers
196            .iter()
197            .find(|(header, _)| header.eq_ignore_ascii_case(name))
198            .map(|(_, value)| value.as_str())
199    }
200
201    /// The body as JSON.
202    ///
203    /// # Panics
204    ///
205    /// If it will not parse — printing what arrived, because that is nearly
206    /// always an error response the test did not expect.
207    pub fn json(&self) -> Value {
208        serde_json::from_str(&self.body).unwrap_or_else(|e| {
209            panic!(
210                "the response body is not JSON: {e}\n  status: {}\n  body: {}",
211                self.status, self.body
212            )
213        })
214    }
215
216    /// The body deserialised into your own type.
217    ///
218    /// # Panics
219    ///
220    /// If it will not deserialise, printing the body.
221    pub fn json_as<T: DeserializeOwned>(&self) -> T {
222        serde_json::from_str(&self.body).unwrap_or_else(|e| {
223            panic!("the response body is not the expected shape: {e}\n  body: {}", self.body)
224        })
225    }
226
227    // --- assertions --------------------------------------------------------
228    //
229    // Every one takes and returns `&Self`, so they chain and the response is
230    // still there afterwards to read.
231
232    /// Panic unless the status is exactly this.
233    ///
234    /// # Panics
235    ///
236    /// If it is not — printing the body, which is where the reason is.
237    pub fn assert_status(&self, expected: StatusCode) -> &Self {
238        assert_eq!(
239            self.status, expected,
240            "expected {expected}, got {}\n  body: {}",
241            self.status, self.body
242        );
243        self
244    }
245
246    /// Panic unless the status is `2xx`.
247    ///
248    /// # Panics
249    ///
250    /// If it is not.
251    pub fn assert_ok(&self) -> &Self {
252        assert!(
253            self.status.is_success(),
254            "expected a 2xx, got {}\n  body: {}",
255            self.status,
256            self.body
257        );
258        self
259    }
260
261    /// Panic unless the status is `201`.
262    ///
263    /// # Panics
264    ///
265    /// If it is not.
266    pub fn assert_created(&self) -> &Self {
267        self.assert_status(StatusCode::CREATED)
268    }
269
270    /// Panic unless the status is `204`.
271    ///
272    /// # Panics
273    ///
274    /// If it is not.
275    pub fn assert_no_content(&self) -> &Self {
276        self.assert_status(StatusCode::NO_CONTENT)
277    }
278
279    /// Panic unless the status is `404`.
280    ///
281    /// # Panics
282    ///
283    /// If it is not.
284    pub fn assert_not_found(&self) -> &Self {
285        self.assert_status(StatusCode::NOT_FOUND)
286    }
287
288    /// Panic unless the status is `401`.
289    ///
290    /// # Panics
291    ///
292    /// If it is not.
293    pub fn assert_unauthorized(&self) -> &Self {
294        self.assert_status(StatusCode::UNAUTHORIZED)
295    }
296
297    /// Panic unless the status is `403`.
298    ///
299    /// # Panics
300    ///
301    /// If it is not.
302    pub fn assert_forbidden(&self) -> &Self {
303        self.assert_status(StatusCode::FORBIDDEN)
304    }
305
306    /// Panic unless the status is `422` — a failed request contract.
307    ///
308    /// # Panics
309    ///
310    /// If it is not.
311    pub fn assert_invalid(&self) -> &Self {
312        self.assert_status(StatusCode::UNPROCESSABLE_ENTITY)
313    }
314
315    /// Panic unless the JSON at `path` equals `expected`.
316    ///
317    /// `path` is dotted, and an index is a segment: `data.0.title`.
318    ///
319    /// # Panics
320    ///
321    /// If the path is absent or holds something else.
322    pub fn assert_json_path(&self, path: &str, expected: impl Into<Value>) -> &Self {
323        let expected = expected.into();
324        let document = self.json();
325
326        match json_path(&document, path) {
327            Some(actual) => assert_eq!(
328                *actual, expected,
329                "at `{path}`: expected {expected}, got {actual}\n  body: {}",
330                self.body
331            ),
332            None => panic!("`{path}` is not in the response\n  body: {}", self.body),
333        }
334        self
335    }
336
337    /// Panic unless there is nothing at `path`.
338    ///
339    /// For asserting a field was **not** serialised — a password hash, an
340    /// internal id.
341    ///
342    /// # Panics
343    ///
344    /// If something is there.
345    pub fn assert_json_missing(&self, path: &str) -> &Self {
346        assert!(
347            json_path(&self.json(), path).is_none(),
348            "`{path}` should not be in the response\n  body: {}",
349            self.body
350        );
351        self
352    }
353
354    /// Panic unless the body contains `text`.
355    ///
356    /// # Panics
357    ///
358    /// If it does not.
359    pub fn assert_contains(&self, text: &str) -> &Self {
360        assert!(
361            self.body.contains(text),
362            "the body does not contain {text:?}\n  body: {}",
363            self.body
364        );
365        self
366    }
367
368    /// Panic unless the header is present with this value.
369    ///
370    /// # Panics
371    ///
372    /// If it is absent or different.
373    pub fn assert_header(&self, name: &str, expected: &str) -> &Self {
374        match self.header(name) {
375            Some(actual) => assert_eq!(actual, expected, "header `{name}`"),
376            None => panic!("`{name}` is not on the response — it has {:?}", self.header_names()),
377        }
378        self
379    }
380
381    /// Panic unless the header is absent.
382    ///
383    /// # Panics
384    ///
385    /// If it is present.
386    pub fn assert_header_missing(&self, name: &str) -> &Self {
387        assert!(self.header(name).is_none(), "`{name}` should not be on the response");
388        self
389    }
390
391    fn header_names(&self) -> Vec<&str> {
392        self.headers.iter().map(|(name, _)| name.as_str()).collect()
393    }
394}
395
396/// Walk a dotted path into a JSON value. A numeric segment indexes an array.
397fn json_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
398    let mut current = value;
399
400    for segment in path.split('.') {
401        current = match current {
402            Value::Array(items) => items.get(segment.parse::<usize>().ok()?)?,
403            other => other.get(segment)?,
404        };
405    }
406    Some(current)
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use serde_json::json;
413
414    fn document() -> Value {
415        json!({
416            "database": { "status": "ok", "latency_ms": 3 },
417            "data": [{ "title": "First" }, { "title": "Second" }],
418            "nothing": null,
419        })
420    }
421
422    #[test]
423    fn a_dotted_path_walks_objects_and_arrays() {
424        let value = document();
425
426        assert_eq!(json_path(&value, "database.status"), Some(&json!("ok")));
427        assert_eq!(json_path(&value, "data.1.title"), Some(&json!("Second")));
428        assert_eq!(json_path(&value, "database.latency_ms"), Some(&json!(3)));
429    }
430
431    #[test]
432    fn an_absent_path_is_none_rather_than_a_panic() {
433        let value = document();
434
435        assert_eq!(json_path(&value, "database.nope"), None);
436        assert_eq!(json_path(&value, "data.9.title"), None);
437        assert_eq!(json_path(&value, "data.title"), None, "an array indexed by a name");
438    }
439
440    #[test]
441    fn a_null_is_present_and_not_missing() {
442        // `"nothing": null` is a field the API sends. `assert_json_missing`
443        // must not confuse it with a field the API omits.
444        assert_eq!(json_path(&document(), "nothing"), Some(&json!(null)));
445    }
446
447    #[tokio::test]
448    async fn a_response_is_read_once_and_asserted_many_times() {
449        let response = TestResponse::of(
450            Response::ok(r#"{"database":{"status":"ok"}}"#)
451                .with_header("content-type", "application/json"),
452        )
453        .await;
454
455        response
456            .assert_ok()
457            .assert_status(StatusCode::OK)
458            .assert_json_path("database.status", "ok")
459            .assert_json_missing("database.error")
460            .assert_header("content-type", "application/json")
461            .assert_contains("status");
462
463        assert_eq!(response.json()["database"]["status"], "ok");
464    }
465
466    #[tokio::test]
467    #[should_panic(expected = "expected 404 Not Found, got 200 OK")]
468    async fn a_failed_status_assertion_prints_the_body() {
469        TestResponse::of(Response::ok("hello")).await.assert_not_found();
470    }
471
472    #[tokio::test]
473    #[should_panic(expected = "`database.status` is not in the response")]
474    async fn a_missing_path_says_which_path() {
475        TestResponse::of(Response::ok("{}")).await.assert_json_path("database.status", "ok");
476    }
477}