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 value of response header `name` as a string, or `None` if absent or
188    /// not valid UTF-8. Matching is case-insensitive.
189    pub fn header(&self, name: &str) -> Option<&str> {
190        self.headers.get(name).and_then(|v| v.to_str().ok())
191    }
192    /// The raw response body bytes.
193    pub fn body_bytes(&self) -> &Bytes {
194        &self.body
195    }
196    /// The response body decoded as UTF-8 (lossily, so this never fails).
197    pub fn text(&self) -> String {
198        String::from_utf8_lossy(&self.body).into_owned()
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::call::Call;
206    use crate::Churust;
207
208    fn app() -> App {
209        Churust::server()
210            .routing(|r| {
211                r.get("/", |_c: Call| async { "home" });
212                r.post("/echo", |mut c: Call| async move {
213                    c.receive_text().await.unwrap_or_default()
214                });
215            })
216            .build()
217    }
218
219    #[tokio::test]
220    async fn get_returns_body_and_status() {
221        let client = TestClient::new(app());
222        let res = client.get("/").send().await;
223        assert_eq!(res.status(), StatusCode::OK);
224        assert_eq!(res.text(), "home");
225    }
226
227    #[tokio::test]
228    async fn post_echoes_body() {
229        let client = TestClient::new(app());
230        let res = client.post("/echo").body("ping").send().await;
231        assert_eq!(res.text(), "ping");
232    }
233
234    #[tokio::test]
235    async fn missing_route_is_404() {
236        let client = TestClient::new(app());
237        let res = client.get("/nope").send().await;
238        assert_eq!(res.status(), StatusCode::NOT_FOUND);
239    }
240
241    #[tokio::test]
242    async fn collects_streamed_body() {
243        use crate::body::Body;
244        use crate::{Call, Churust};
245        use bytes::Bytes;
246
247        let app = Churust::server()
248            .routing(|r| {
249                r.get("/stream", |_c: Call| async {
250                    let chunks = futures_util::stream::iter(vec![
251                        Ok::<_, std::io::Error>(Bytes::from("foo")),
252                        Ok(Bytes::from("bar")),
253                    ]);
254                    crate::Response::stream("text/plain", Body::from_stream(chunks))
255                });
256            })
257            .build();
258        let res = TestClient::new(app).get("/stream").send().await;
259        assert_eq!(res.status(), http::StatusCode::OK);
260        assert_eq!(res.text(), "foobar");
261    }
262}