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}