Skip to main content

churust_core/
test.rs

1//! In-process test harness. Drives `App::process` directly — no socket bind.
2
3use crate::app::App;
4use bytes::Bytes;
5use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri};
6
7/// An in-process test client bound to an assembled [`App`].
8///
9/// `TestClient` drives [`App::process`](crate::App) directly — no socket is
10/// bound and no runtime port is used — so tests are fast and hermetic. Build a
11/// request with [`get`](TestClient::get) / [`post`](TestClient::post) /
12/// [`put`](TestClient::put) / [`delete`](TestClient::delete) (or the generic
13/// [`request`](TestClient::request)), then call
14/// [`send`](TestRequest::send) to get a [`TestResponse`].
15///
16/// ```
17/// use churust_core::{Churust, Call, TestClient};
18/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
19/// let app = Churust::server()
20///     .routing(|r| { r.get("/", |_c: Call| async { "home" }); })
21///     .build();
22/// let res = TestClient::new(app).get("/").send().await;
23/// assert_eq!(res.status().as_u16(), 200);
24/// assert_eq!(res.text(), "home");
25/// # });
26/// ```
27pub struct TestClient {
28    app: App,
29}
30
31/// A builder for a single in-process test request.
32///
33/// Created by the [`TestClient`] verb methods. Chain [`header`](TestRequest::header)
34/// and [`body`](TestRequest::body) to refine the request, then await
35/// [`send`](TestRequest::send) to run it through the pipeline.
36pub struct TestRequest<'c> {
37    client: &'c TestClient,
38    method: Method,
39    uri: String,
40    headers: HeaderMap,
41    body: Bytes,
42}
43
44/// The response returned by the in-process pipeline, with inspection helpers.
45///
46/// Returned by [`TestRequest::send`]. Inspect it with [`status`](TestResponse::status),
47/// [`header`](TestResponse::header), [`text`](TestResponse::text), and
48/// [`body_bytes`](TestResponse::body_bytes).
49///
50/// The body is fully collected (streamed bodies are drained), so the accessors
51/// are synchronous.
52pub struct TestResponse {
53    status: StatusCode,
54    headers: HeaderMap,
55    body: Bytes,
56}
57
58impl TestClient {
59    /// Create a client that drives the given assembled [`App`].
60    pub fn new(app: App) -> Self {
61        Self { app }
62    }
63
64    /// Begin building a request with an arbitrary `method` and `uri`. The verb
65    /// helpers ([`get`](TestClient::get), [`post`](TestClient::post), etc.) call
66    /// this for the common methods.
67    ///
68    /// ```
69    /// use churust_core::{Churust, Call, TestClient};
70    /// use http::Method;
71    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
72    /// let app = Churust::server()
73    ///     .routing(|r| { r.get("/", |_c: Call| async { "ok" }); })
74    ///     .build();
75    /// let res = TestClient::new(app).request(Method::GET, "/").send().await;
76    /// assert_eq!(res.text(), "ok");
77    /// # });
78    /// ```
79    pub fn request(&self, method: Method, uri: impl Into<String>) -> TestRequest<'_> {
80        TestRequest {
81            client: self,
82            method,
83            uri: uri.into(),
84            headers: HeaderMap::new(),
85            body: Bytes::new(),
86        }
87    }
88
89    /// Begin building a `GET` request to `uri`.
90    pub fn get(&self, uri: impl Into<String>) -> TestRequest<'_> {
91        self.request(Method::GET, uri)
92    }
93    /// Begin building a `POST` request to `uri`.
94    pub fn post(&self, uri: impl Into<String>) -> TestRequest<'_> {
95        self.request(Method::POST, uri)
96    }
97    /// Begin building a `PUT` request to `uri`.
98    pub fn put(&self, uri: impl Into<String>) -> TestRequest<'_> {
99        self.request(Method::PUT, uri)
100    }
101    /// Begin building a `DELETE` request to `uri`.
102    pub fn delete(&self, uri: impl Into<String>) -> TestRequest<'_> {
103        self.request(Method::DELETE, uri)
104    }
105}
106
107impl<'c> TestRequest<'c> {
108    /// Set a request header, returning `self` for chaining.
109    ///
110    /// # Panics
111    ///
112    /// Panics if `value` is not a valid header value (this is a test helper, so
113    /// it favors a clear failure over a `Result`).
114    ///
115    /// ```
116    /// use churust_core::{Churust, Call, TestClient};
117    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
118    /// let app = Churust::server()
119    ///     .routing(|r| {
120    ///         r.get("/", |c: Call| async move {
121    ///             c.header("x-test").unwrap_or("none").to_string()
122    ///         });
123    ///     })
124    ///     .build();
125    /// let res = TestClient::new(app).get("/").header("x-test", "yes").send().await;
126    /// assert_eq!(res.text(), "yes");
127    /// # });
128    /// ```
129    pub fn header(mut self, name: &'static str, value: &str) -> Self {
130        self.headers.insert(
131            HeaderName::from_static(name),
132            HeaderValue::from_str(value).expect("valid header value"),
133        );
134        self
135    }
136
137    /// Set the request body, returning `self` for chaining.
138    ///
139    /// ```
140    /// use churust_core::{Churust, Call, TestClient};
141    /// # tokio::runtime::Runtime::new().unwrap().block_on(async {
142    /// let app = Churust::server()
143    ///     .routing(|r| {
144    ///         r.post("/echo", |mut c: Call| async move {
145    ///             c.receive_text().await.unwrap_or_default()
146    ///         });
147    ///     })
148    ///     .build();
149    /// let res = TestClient::new(app).post("/echo").body("ping").send().await;
150    /// assert_eq!(res.text(), "ping");
151    /// # });
152    /// ```
153    pub fn body(mut self, body: impl Into<Bytes>) -> Self {
154        self.body = body.into();
155        self
156    }
157
158    /// Run the request through the application pipeline and return the
159    /// [`TestResponse`], consuming the builder.
160    ///
161    /// # Panics
162    ///
163    /// Panics if the configured URI fails to parse.
164    pub async fn send(self) -> TestResponse {
165        let uri = self.uri.parse::<Uri>().expect("valid URI");
166        let res = self
167            .client
168            .app
169            .process(self.method, uri, self.headers, self.body)
170            .await;
171        let status = res.status;
172        let headers = res.headers;
173        let body = res.body.into_bytes().await.unwrap_or_default();
174        TestResponse {
175            status,
176            headers,
177            body,
178        }
179    }
180}
181
182impl TestResponse {
183    /// The response status code.
184    pub fn status(&self) -> StatusCode {
185        self.status
186    }
187    /// The full response header map, for headers that may appear more than
188    /// once — `Set-Cookie` above all, which must never be folded into one
189    /// comma-separated value.
190    pub fn headers(&self) -> &http::HeaderMap {
191        &self.headers
192    }
193
194    /// The value of response header `name` as a string, or `None` if absent or
195    /// not valid UTF-8. Matching is case-insensitive.
196    pub fn header(&self, name: &str) -> Option<&str> {
197        self.headers.get(name).and_then(|v| v.to_str().ok())
198    }
199    /// The raw response body bytes.
200    pub fn body_bytes(&self) -> &Bytes {
201        &self.body
202    }
203    /// The response body decoded as UTF-8 (lossily, so this never fails).
204    pub fn text(&self) -> String {
205        String::from_utf8_lossy(&self.body).into_owned()
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::call::Call;
213    use crate::Churust;
214
215    fn app() -> App {
216        Churust::server()
217            .routing(|r| {
218                r.get("/", |_c: Call| async { "home" });
219                r.post("/echo", |mut c: Call| async move {
220                    c.receive_text().await.unwrap_or_default()
221                });
222            })
223            .build()
224    }
225
226    #[tokio::test]
227    async fn get_returns_body_and_status() {
228        let client = TestClient::new(app());
229        let res = client.get("/").send().await;
230        assert_eq!(res.status(), StatusCode::OK);
231        assert_eq!(res.text(), "home");
232    }
233
234    #[tokio::test]
235    async fn post_echoes_body() {
236        let client = TestClient::new(app());
237        let res = client.post("/echo").body("ping").send().await;
238        assert_eq!(res.text(), "ping");
239    }
240
241    #[tokio::test]
242    async fn missing_route_is_404() {
243        let client = TestClient::new(app());
244        let res = client.get("/nope").send().await;
245        assert_eq!(res.status(), StatusCode::NOT_FOUND);
246    }
247
248    #[tokio::test]
249    async fn collects_streamed_body() {
250        use crate::body::Body;
251        use crate::{Call, Churust};
252        use bytes::Bytes;
253
254        let app = Churust::server()
255            .routing(|r| {
256                r.get("/stream", |_c: Call| async {
257                    let chunks = futures_util::stream::iter(vec![
258                        Ok::<_, std::io::Error>(Bytes::from("foo")),
259                        Ok(Bytes::from("bar")),
260                    ]);
261                    crate::Response::stream("text/plain", Body::from_stream(chunks))
262                });
263            })
264            .build();
265        let res = TestClient::new(app).get("/stream").send().await;
266        assert_eq!(res.status(), http::StatusCode::OK);
267        assert_eq!(res.text(), "foobar");
268    }
269}