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