├── .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 | --------------------------------------------------------------------------------