├── .gitignore
├── Cargo.toml
├── LICENSE
├── README.md
├── examples
└── hello-world
│ ├── Cargo.toml
│ └── src
│ └── main.rs
└── src
└── lib.rs
/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | /target/
4 | /examples/*/target
5 |
6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
8 | Cargo.lock
9 |
10 | # These are backup files generated by rustfmt and cache files
11 | **/*.rs.bk
12 | .DS_Store
13 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "axum-test-helper"
3 | version = "0.4.0"
4 | edition = "2021"
5 | categories = ["development-tools::testing"]
6 | description = "Extra utilities for axum"
7 | homepage = "https://github.com/cloudwalk/axum-test-helper"
8 | keywords = ["axum", "test", "test-framework"]
9 | license = "MIT"
10 | readme = "README.md"
11 | repository = "https://github.com/cloudwalk/axum-test-helper"
12 |
13 | [dependencies]
14 | axum = "0.7"
15 | reqwest = { version = "0.11.23", features = ["json", "stream", "multipart", "rustls-tls"], default-features = false }
16 | http = "1.0.0"
17 | http-body = "0.4"
18 | bytes = "1.4.0"
19 | tower = "0.4.13"
20 | tower-service = "0.3"
21 | serde = "1.0"
22 | tokio = "1"
23 | hyper = "1"
24 |
25 | [dev-dependencies]
26 | serde = { version = "1", features = ["serde_derive"] }
27 | tokio = { version = "1", features = ["full"] }
28 | serde_json = "1.0"
29 |
30 | [features]
31 | default = ["withtrace"]
32 | cookies = ["reqwest/cookies"]
33 | withtrace = []
34 | withouttrace = []
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 CloudWalk
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # axum-test-helper
2 |
3 | `axum-test-helper` exposes [`axum`] original TestClient, which is private to the [`axum`] crate
4 |
5 | More information about this crate can be found in the [crate documentation][docs].
6 |
7 | ## High level features
8 |
9 | - Provide an easy to use interface
10 | - Start a server in a different port for each call
11 | - Deal with JSON, text and files response/requests
12 |
13 | ## Usage example
14 |
15 | Add this crate as a dev-dependency:
16 |
17 | ```
18 | [dev-dependencies]
19 | axum-test-helper = "0.*" # alternatively specify the version as "0.3.0"
20 | ```
21 |
22 | Use the TestClient on your own Router:
23 |
24 | ```rust
25 | use axum::Router;
26 | use axum::http::StatusCode;
27 | use axum_test_helper::TestClient;
28 |
29 | // you can replace this Router with your own app
30 | let app = Router::new().route("/", get(|| async {}));
31 |
32 | // initiate the TestClient with the previous declared Router
33 | let client = TestClient::new(app);
34 | let res = client.get("/").send().await;
35 | assert_eq!(res.status(), StatusCode::OK);
36 | ```
37 |
38 | You can find examples like this in
39 | the [example directory][examples].
40 |
41 | See the [crate documentation][docs] for way more examples.
42 |
43 | ## Disable trace
44 |
45 | By default axum-test-helper print trace like `Listening on 127.0.0.1:36457`. You can disable trace with `axum-test-helper = { version = "0.*", default-features = false, features = ["withouttrace"] }`.
46 |
47 | ## Contributing
48 |
49 | Before submitting a pull request or after pulling from the main repository, ensure all tests pass:
50 |
51 | ``` shell
52 | # Run axum-test-helper tests
53 | cargo test
54 |
55 | # Test the hello-world example project
56 | (cd examples/hello-world && cargo test)
57 | ```
58 |
59 |
60 | ## License
61 |
62 | This project is licensed under the [MIT license][license].
63 |
64 | [`axum`]: https://github.com/tokio-rs/axum/blob/405e3f8c44ce76c3922fa25db13491ea375c3e8e/axum/src/test_helpers/test_client.rs
65 | [examples]: https://github.com/cloudwalk/axum-test-helper/tree/main/examples
66 | [docs]: https://docs.rs/axum-test-helper
67 | [license]: https://github.com/cloudwalk/axum-test-helper/blob/main/LICENSE
68 |
--------------------------------------------------------------------------------
/examples/hello-world/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "example-hello-world"
3 | version = "0.1.0"
4 | edition = "2021"
5 | publish = false
6 |
7 | [dependencies]
8 | axum = { version = "0.6.*" }
9 | axum-test-helper = { version = "0.*" }
10 | tokio = { version = "1.0", features = ["full"] }
11 |
--------------------------------------------------------------------------------
/examples/hello-world/src/main.rs:
--------------------------------------------------------------------------------
1 | //! Run with
2 | //!
3 | //! ```not_rust
4 | //! cd examples && cargo run -p example-hello-world
5 | //! ```
6 |
7 | use axum::{response::Html, routing::get, Router};
8 | use std::net::SocketAddr;
9 |
10 | #[tokio::main]
11 | async fn main() {
12 | // build our application with a route
13 | let router = router();
14 |
15 | // run it
16 | let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
17 | println!("listening on {}", addr);
18 | axum::Server::bind(&addr)
19 | .serve(router.into_make_service())
20 | .await
21 | .unwrap();
22 | }
23 |
24 | fn router() -> Router {
25 | Router::new().route("/", get(handler))
26 | }
27 |
28 | async fn handler() -> Html<&'static str> {
29 | Html("
Hello, World!
")
30 | }
31 |
32 | #[cfg(test)]
33 | mod tests {
34 | use super::*;
35 | use axum::http::StatusCode;
36 | use axum_test_helper::TestClient;
37 |
38 | #[tokio::test]
39 | async fn test_main_router() {
40 | let router = router();
41 | let client = TestClient::new(router);
42 | let res = client.get("/").send().await;
43 | assert_eq!(res.status(), StatusCode::OK);
44 | assert_eq!(res.text().await, "Hello, World!
");
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/lib.rs:
--------------------------------------------------------------------------------
1 | //! # Axum Test Helper
2 | //! This is a hard copy from TestClient at axum
3 | //!
4 | //! ## Features
5 | //! - `cookies` - Enables support for cookies in the test client.
6 | //! - `withouttrace` - Disables tracing for the test client.
7 | //!
8 | //! ## Example
9 | //! ```rust
10 | //! use axum::Router;
11 | //! use axum::http::StatusCode;
12 | //! use axum::routing::get;
13 | //! use axum_test_helper::TestClient;
14 | //!
15 | //! fn main() {
16 | //! let async_block = async {
17 | //! // you can replace this Router with your own app
18 | //! let app = Router::new().route("/", get(|| async {}));
19 | //!
20 | //! // initiate the TestClient with the previous declared Router
21 | //! let client = TestClient::new(app);
22 | //!
23 | //! let res = client.get("/").send().await;
24 | //! assert_eq!(res.status(), StatusCode::OK);
25 | //! };
26 | //!
27 | //! // Create a runtime for executing the async block. This runtime is local
28 | //! // to the main function and does not require any global setup.
29 | //! let runtime = tokio::runtime::Builder::new_current_thread()
30 | //! .enable_all()
31 | //! .build()
32 | //! .unwrap();
33 | //!
34 | //! // Use the local runtime to block on the async block.
35 | //! runtime.block_on(async_block);
36 | //! }
37 |
38 | use bytes::Bytes;
39 | use http::StatusCode;
40 | use std::net::SocketAddr;
41 | use tokio::net::TcpListener;
42 |
43 | pub struct TestClient {
44 | client: reqwest::Client,
45 | addr: SocketAddr,
46 | }
47 |
48 | impl TestClient {
49 | pub async fn new(svc: axum::Router) -> Self {
50 | let listener = TcpListener::bind("127.0.0.1:0")
51 | .await
52 | .expect("Could not bind ephemeral socket");
53 | let addr = listener.local_addr().unwrap();
54 | #[cfg(feature = "withtrace")]
55 | println!("Listening on {}", addr);
56 |
57 | tokio::spawn(async move {
58 | let server = axum::serve(listener, svc);
59 | server.await.expect("server error");
60 | });
61 |
62 | #[cfg(feature = "cookies")]
63 | let client = reqwest::Client::builder()
64 | .redirect(reqwest::redirect::Policy::none())
65 | .cookie_store(true)
66 | .build()
67 | .unwrap();
68 |
69 | #[cfg(not(feature = "cookies"))]
70 | let client = reqwest::Client::builder()
71 | .redirect(reqwest::redirect::Policy::none())
72 | .build()
73 | .unwrap();
74 |
75 | TestClient { client, addr }
76 | }
77 |
78 | /// returns the base URL (http://ip:port) for this TestClient
79 | ///
80 | /// this is useful when trying to check if Location headers in responses
81 | /// are generated correctly as Location contains an absolute URL
82 | pub fn base_url(&self) -> String {
83 | format!("http://{}", self.addr)
84 | }
85 |
86 | pub fn get(&self, url: &str) -> RequestBuilder {
87 | RequestBuilder {
88 | builder: self.client.get(format!("http://{}{}", self.addr, url)),
89 | }
90 | }
91 |
92 | pub fn head(&self, url: &str) -> RequestBuilder {
93 | RequestBuilder {
94 | builder: self.client.head(format!("http://{}{}", self.addr, url)),
95 | }
96 | }
97 |
98 | pub fn post(&self, url: &str) -> RequestBuilder {
99 | RequestBuilder {
100 | builder: self.client.post(format!("http://{}{}", self.addr, url)),
101 | }
102 | }
103 |
104 | pub fn put(&self, url: &str) -> RequestBuilder {
105 | RequestBuilder {
106 | builder: self.client.put(format!("http://{}{}", self.addr, url)),
107 | }
108 | }
109 |
110 | pub fn patch(&self, url: &str) -> RequestBuilder {
111 | RequestBuilder {
112 | builder: self.client.patch(format!("http://{}{}", self.addr, url)),
113 | }
114 | }
115 |
116 | pub fn delete(&self, url: &str) -> RequestBuilder {
117 | RequestBuilder {
118 | builder: self.client.delete(format!("http://{}{}", self.addr, url)),
119 | }
120 | }
121 | }
122 |
123 | pub struct RequestBuilder {
124 | builder: reqwest::RequestBuilder,
125 | }
126 |
127 | impl RequestBuilder {
128 | pub async fn send(self) -> TestResponse {
129 | TestResponse {
130 | response: self.builder.send().await.unwrap(),
131 | }
132 | }
133 |
134 | pub fn body(mut self, body: impl Into) -> Self {
135 | self.builder = self.builder.body(body);
136 | self
137 | }
138 |
139 | pub fn form(mut self, form: &T) -> Self {
140 | self.builder = self.builder.form(&form);
141 | self
142 | }
143 |
144 | pub fn json(mut self, json: &T) -> Self
145 | where
146 | T: serde::Serialize,
147 | {
148 | self.builder = self.builder.json(json);
149 | self
150 | }
151 |
152 | pub fn header(mut self, key: &str, value: &str) -> Self {
153 | self.builder = self.builder.header(key, value);
154 | self
155 | }
156 |
157 | pub fn multipart(mut self, form: reqwest::multipart::Form) -> Self {
158 | self.builder = self.builder.multipart(form);
159 | self
160 | }
161 | }
162 |
163 | /// A wrapper around [`reqwest::Response`] that provides common methods with internal `unwrap()`s.
164 | ///
165 | /// This is conventient for tests where panics are what you want. For access to
166 | /// non-panicking versions or the complete `Response` API use `into_inner()` or
167 | /// `as_ref()`.
168 | pub struct TestResponse {
169 | response: reqwest::Response,
170 | }
171 |
172 | impl TestResponse {
173 | pub async fn text(self) -> String {
174 | self.response.text().await.unwrap()
175 | }
176 |
177 | #[allow(dead_code)]
178 | pub async fn bytes(self) -> Bytes {
179 | self.response.bytes().await.unwrap()
180 | }
181 |
182 | pub async fn json(self) -> T
183 | where
184 | T: serde::de::DeserializeOwned,
185 | {
186 | self.response.json().await.unwrap()
187 | }
188 |
189 | pub fn status(&self) -> StatusCode {
190 | StatusCode::from_u16(self.response.status().as_u16()).unwrap()
191 | }
192 |
193 | pub fn headers(&self) -> &reqwest::header::HeaderMap {
194 | self.response.headers()
195 | }
196 |
197 | pub async fn chunk(&mut self) -> Option {
198 | self.response.chunk().await.unwrap()
199 | }
200 |
201 | pub async fn chunk_text(&mut self) -> Option {
202 | let chunk = self.chunk().await?;
203 | Some(String::from_utf8(chunk.to_vec()).unwrap())
204 | }
205 |
206 | /// Get the inner [`reqwest::Response`] for less convenient but more complete access.
207 | pub fn into_inner(self) -> reqwest::Response {
208 | self.response
209 | }
210 | }
211 |
212 | impl AsRef for TestResponse {
213 | fn as_ref(&self) -> &reqwest::Response {
214 | &self.response
215 | }
216 | }
217 |
218 | #[cfg(test)]
219 | mod tests {
220 | use axum::response::Html;
221 | use axum::{routing::get, routing::post, Json, Router};
222 | use http::StatusCode;
223 | use serde::{Deserialize, Serialize};
224 |
225 | #[derive(Deserialize)]
226 | struct FooForm {
227 | val: String,
228 | }
229 |
230 | async fn handle_form(axum::Form(form): axum::Form) -> (StatusCode, Html) {
231 | (StatusCode::OK, Html(form.val))
232 | }
233 |
234 | #[tokio::test]
235 | async fn test_get_request() {
236 | let app = Router::new().route("/", get(|| async {}));
237 | let client = super::TestClient::new(app).await;
238 | let res = client.get("/").send().await;
239 | assert_eq!(res.status(), StatusCode::OK);
240 | }
241 |
242 | #[tokio::test]
243 | async fn test_post_form_request() {
244 | let app = Router::new().route("/", post(handle_form));
245 | let client = super::TestClient::new(app).await;
246 | let form = [("val", "bar"), ("baz", "quux")];
247 | let res = client.post("/").form(&form).send().await;
248 | assert_eq!(res.status(), StatusCode::OK);
249 | assert_eq!(res.text().await, "bar");
250 | }
251 |
252 | #[derive(Debug, Serialize, Deserialize, PartialEq)]
253 | struct TestPayload {
254 | name: String,
255 | age: i32,
256 | }
257 |
258 | #[tokio::test]
259 | async fn test_post_request_with_json() {
260 | let app = Router::new().route(
261 | "/",
262 | post(|json_value: Json| async { json_value }),
263 | );
264 | let client = super::TestClient::new(app).await;
265 | let payload = TestPayload {
266 | name: "Alice".to_owned(),
267 | age: 30,
268 | };
269 | let res = client
270 | .post("/")
271 | .header("Content-Type", "application/json")
272 | .json(&payload)
273 | .send()
274 | .await;
275 | assert_eq!(res.status(), StatusCode::OK);
276 | let response_body: TestPayload = serde_json::from_str(&res.text().await).unwrap();
277 | assert_eq!(response_body, payload);
278 | }
279 | }
280 |
--------------------------------------------------------------------------------