これは、なにをしたくて書いたもの?
前に、RustのWebフレームワークであるaxumを試してみました。
RustのWebフレームワーク、axumを試してみる - CLOVER🍀
この時はとりあえず動かしてみただけでしたが、こちらに対するテストを書いてみたいと思います。
axumアプリケーションのテストを書く
axumアプリケーションのテストを書くにはどうしたらよいのか?というところですが、ドキュメントを見るとテストに関しては
exampleを見ること、となっています。
See the testing example in the repo to learn more about testing axum apps.
こちらですね。
※やっぱりaxum 0.8.1のタグがないので、0.8.0のタグで代用します…
https://github.com/tokio-rs/axum/tree/axum-v0.8.0/examples/testing
コードを見るとaxum::Router
の定義を関数に切り出して
https://github.com/tokio-rs/axum/blob/axum-v0.8.0/examples/testing/src/main.rs#L37-L52
このRouter
に対してリクエストを投げるという感じになるみたいです。
https://github.com/tokio-rs/axum/blob/axum-v0.8.0/examples/testing/src/main.rs#L67-L82
ここで使われているのは、tokioのテスト用の機能です。
Unit Testing | Tokio - An asynchronous Rust runtime
こちらを使うと、テストをasync
な関数として書けるようです。また時間操作やAsyncRead
およびAsyncWrite
のモック化も
サポートしているようです。
また、テストではこれらのクレートも使います。
http-body-utilはレスポンスボディの内容をまとめて収集するために、towerはHTTPサーバーを起動しない場合のリクエストの送信に、
reqwestはHTTPサーバーを起動した場合のリクエストの送信にそれぞれ使います。
mimeはHTTPヘッダー名を定数で参照するのに使っています。
今回はこれらを使って、前回作成したコードのテストを書いてみましょう。
環境
今回の環境はこちら。
$ rustup --version rustup 1.27.1 (54dd3d00f 2024-04-24) info: This is the version for the rustup toolchain manager, not the rustc compiler. info: The currently active `rustc` version is `rustc 1.84.1 (e71f9a9a9 2025-01-27)`
アプリケーションを用意する
まずはアプリケーションを用意します。Cargoパッケージの作成。
$ cargo new --vcs none hello-axum-test $ cd hello-axum-test
アプリケーションとしては以下のクレートが必要です。
$ cargo add axum $ cargo add tokio --features macros,rt-multi-thread $ cargo add serde_json
テスト用には、以下のクレートを追加。
$ cargo add --dev tower --features=util $ cargo add --dev http-body-util mime $ cargo add --dev reqwest --no-default-features --features=json,rustls-tls
towerにはutilフィーチャーが、reqwestはビルド時にOpenSSLからの依存関係を排除するためにデフォルトフィーチャーをオフにして
Rustlsを使うようにrustls-tlsフィーチャーを有効にしています。
Cargo.toml
[package] name = "hello-axum-test" version = "0.1.0" edition = "2021" [dependencies] axum = "0.8.1" serde_json = "1.0.138" tokio = { version = "1.43.0", features = ["macros", "rt-multi-thread"] } [dev-dependencies] http-body-util = "0.1.2" mime = "0.3.17" reqwest = { version = "0.12.12", features = ["json", "rustls-tls"], default-features = false } tower = { version = "0.5.2", features = ["util"] }
ソースコードについては、前回の内容からaxum::Router
を定義する部分だけを関数に独立させた状態からスタートしましょう。
src/main.rs
use std::collections::HashMap; use axum::{ extract::{Path, Query}, routing::{get, post}, serve, Json, Router, }; use serde_json::{json, Value}; use tokio::net::TcpListener; #[tokio::main] async fn main() { let app = app(); let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap(); serve(listener, app).await.unwrap(); } fn app() -> Router { Router::new() .route("/", get(root)) .route("/foo/{id}", get(get_id)) .route("/query", get(get_query)) .route("/json", post(post_json)) } async fn root() -> &'static str { "Hello World" } async fn get_id(Path(id): Path<u32>) -> String { format!("id = {}", id) } async fn get_query(Query(query): Query<HashMap<String, String>>) -> String { format!("query word = {}", query.get("word").unwrap()) } async fn post_json(Json(payload): Json<Value>) -> Json<Value> { Json(json!({ "message": payload["message"], "result": payload["a"].as_number().unwrap().as_i64().unwrap() + payload["b"].as_number().unwrap().as_i64().unwrap() })) } #[cfg(test)] mod tests { // ここにテストを書く!! }
これでテストを書く前の準備は完了です。
テストを書く
では、テストを書いていきましょう。
テストコードの中は、以下からスタートします。
#[cfg(test)] mod tests { use std::net::SocketAddrV4; use axum::{ body::Body, extract::connect_info::MockConnectInfo, http::{header::CONTENT_TYPE, Request, StatusCode}, serve, }; use http_body_util::BodyExt; // body.collect use mime::APPLICATION_JSON; use reqwest::Client; use serde_json::{from_slice, json, to_vec, Value}; use tokio::{net::TcpListener, spawn}; use tower::{Service, ServiceExt}; // oneshot use crate::app; // ここに、テストを書く!! }
use
の部分は少し説明が必要なのですが、それは使う時にまとめて書きます。
HTTPサーバーを実行しないテストを書く
まずはHTTPサーバーを実行しないテストを書きます。axumの例だと、こちらの内容ですね。
https://github.com/tokio-rs/axum/blob/axum-v0.8.0/examples/testing/src/main.rs#L67-L82
パス/
に対するテスト。
#[tokio::test] async fn test_root() { let app = app(); let request = Request::get("/").body(Body::empty()).unwrap(); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body_binary = response .into_body() .collect() .await .unwrap() .to_bytes() .to_vec(); let body = String::from_utf8(body_binary).unwrap(); assert_eq!(body, "Hello World"); }
テスト用の関数に対して#[tokio::test]
マクロを使用することで、非同期関数としてテストを実行できるようになります。よって
async
キーワードを関数に付与できるようになります。
#[tokio::test] async fn test_root() {
テストの中身に移りましょう。
最初にRouter
を作成します。
let app = app();
リクエストの組み立て。
let request = Request::get("/").body(Body::empty()).unwrap();
Request
の定義はこちらですね。axumにあるのはエイリアスです。
Request in axum::extract - Rust
Request in http::request - Rust
リクエストの送信。Router::oneshot
を使うには、towerのutilフィーチャーを有効にする必要があります。
let response = app.oneshot(request).await.unwrap();
この部分ですね。
use tower::{Service, ServiceExt}; // oneshot
これでService
トレイトを実装しているRouter
を拡張しています。
レスポンスボディはcollect
してからBytes
を経由した後にベクターに変換しています。
let body_binary = response .into_body() .collect() .await .unwrap() .to_bytes() .to_vec();
Response in http::response - Rust
collect
は、http-body-utilのBodyExt
をuse
していないと利用できません。
use http_body_util::BodyExt; // body.collect
BodyExt in http_body_util - Rust
あとはこれをString
にしてアサーションしています。
let body = String::from_utf8(body_binary).unwrap(); assert_eq!(body, "Hello World");
その他のテストも概ね同じですね。
#[tokio::test] async fn test_get_id() { let app = app(); let id = 10; let request = Request::get(format!("/foo/{}", id)) .body(Body::empty()) .unwrap(); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body_binary = response .into_body() .collect() .await .unwrap() .to_bytes() .to_vec(); let body = String::from_utf8(body_binary).unwrap(); assert_eq!(body, format!("id = {}", id)); } #[tokio::test] async fn test_get_query() { let app = app(); let word = "hello"; let request = Request::get(format!("/query?word={}", word)) .body(Body::empty()) .unwrap(); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body_binary = response .into_body() .collect() .await .unwrap() .to_bytes() .to_vec(); let body = String::from_utf8(body_binary).unwrap(); assert_eq!(body, format!("query word = {}", word)); }
JSONを扱うところはserde_jsonを使ったり、リクエストにHTTPボディが入ったり、レスポンスをJSONとしてアサーション
したりと少し違いがありますね。
#[tokio::test] async fn test_post_json() { let app = app(); let request_body = to_vec(&json!({ "message": "Hello World", "a": 5, "b": 3 })) .unwrap(); let request = Request::post("/json") .header(CONTENT_TYPE, APPLICATION_JSON.as_ref()) .body(Body::from(request_body)) .unwrap(); let response = app.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body_binary = response .into_body() .collect() .await .unwrap() .to_bytes() .to_vec(); let body = from_slice::<Value>(&body_binary).unwrap(); assert_eq!( body, json!({ "message": "Hello World", "result": 8, } ) ); }
ちなみに、1回のテスト中で複数回のリクエストを送信する場合は使用する関数が変わります。
例ではこの部分ですね。
https://github.com/tokio-rs/axum/blob/axum-v0.8.0/examples/testing/src/main.rs#L157-L180
こちらを使ったテストコードはこちら。
#[tokio::test] async fn test_multiple() { let mut app = app().into_service(); let root_request = Request::get("/").body(Body::empty()).unwrap(); let root_response = ServiceExt::<Request<Body>>::ready(&mut app) .await .unwrap() .call(root_request) .await .unwrap(); assert_eq!(root_response.status(), StatusCode::OK); let root_body_binary = root_response .into_body() .collect() .await .unwrap() .to_bytes() .to_vec(); let root_body = String::from_utf8(root_body_binary).unwrap(); assert_eq!(root_body, "Hello World"); let word = "hello"; let query_request = Request::get(format!("/query?word={}", word)) .body(Body::empty()) .unwrap(); let query_response = ServiceExt::<Request<Body>>::ready(&mut app) .await .unwrap() .call(query_request) .await .unwrap(); assert_eq!(query_response.status(), StatusCode::OK); let query_body_binary = query_response .into_body() .collect() .await .unwrap() .to_bytes() .to_vec(); let query_body = String::from_utf8(query_body_binary).unwrap(); assert_eq!(query_body, format!("query word = {}", word)); }
Router
はinto_service
を呼び出してRouterIntoService
に変換します。またmut
キーワードを付与します。
let mut app = app().into_service();
あとの違いは、RouterIntoService
はServiceExt::ready
に渡すことと、リクエストを送信するのはoneshot
ではなくcall
に
なります。
let root_response = ServiceExt::<Request<Body>>::ready(&mut app) .await .unwrap() .call(root_request) .await .unwrap(); assert_eq!(root_response.status(), StatusCode::OK);
接続情報をモックにすることもできます。例はこちらですね。
https://github.com/tokio-rs/axum/blob/axum-v0.8.0/examples/testing/src/main.rs#L182-L199
作成したテストコードはこちら。
#[tokio::test] async fn test_mock_root() { let mut app = app() .layer(MockConnectInfo("0.0.0.0:0".parse::<SocketAddrV4>())) .into_service(); let request = Request::get("/").body(Body::empty()).unwrap(); let response = app.ready().await.unwrap().call(request).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body_binary = response .into_body() .collect() .await .unwrap() .to_bytes() .to_vec(); let body = String::from_utf8(body_binary).unwrap(); assert_eq!(body, "Hello World"); }
複数回リクエストを送信する例と少し似ていますが、接続情報をモックにしています。
let mut app = app() .layer(MockConnectInfo("0.0.0.0:0".parse::<SocketAddrV4>())) .into_service();
こちらを使うと、接続情報を扱うハンドラーをテストする際に便利なようです。
https://github.com/tokio-rs/axum/blob/axum-v0.8.0/examples/testing/src/main.rs#L46-L49
HTTPサーバーを実行するテストを書く
続いては、HTTPサーバーを実行するテストを書いてみます。こちらは実際にHTTPサーバーを起動し、HTTPクライアントを
使ってリクエストを送信するテストですね。
axumの例としてはこちらになります。
https://github.com/tokio-rs/axum/blob/axum-v0.8.0/examples/testing/src/main.rs#L128-L155
axumの例ではHTTPクライアントにhyper-utilを使っていたのですが、
今回はreqwestを使うことにします。
GitHub - seanmonstar/reqwest: An easy and powerful Rust HTTP Client
reqwestはRustにおける著名な高レベルなHTTPクライアントのようです。
hyperは低レイヤーなHTTPクライアント、サーバーの機能を提供してくれるクレートです。
reqwestはデフォルトではビルド時にOpenSSLを求めるのでデフォルトのフィーチャーをオフにして、Rustlsを使うように構成。
またjsonフィーチャーを有効にすることでJSONを扱えるようになります。
reqwest = { version = "0.12.12", features = ["json", "rustls-tls"], default-features = false }
最初のテストコードはこちら。
#[tokio::test] async fn test_real_root() { let listener = TcpListener::bind("0.0.0.0:0").await.unwrap(); // random port let addr = listener.local_addr().unwrap(); spawn(async move { serve(listener, app()).await.unwrap(); }); let client = Client::new(); let response = client.get(format!("http://{}", addr)).send().await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.text().await.unwrap(); assert_eq!(body, "Hello World"); }
TcpListener
を起動して、axumのRouter
を使ってHTTPサーバーを開始します。ポートはランダムにしました。
let listener = TcpListener::bind("0.0.0.0:0").await.unwrap(); // random port let addr = listener.local_addr().unwrap(); spawn(async move { serve(listener, app()).await.unwrap(); });
この時、spawn
で別スレッドにしています。
あとはreqwestを使ってリクエストを送信。
let client = Client::new(); let response = client.get(format!("http://{}", addr)).send().await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.text().await.unwrap(); assert_eq!(body, "Hello World");
その他のテスト。
#[tokio::test] async fn test_real_get_id() { let listener = TcpListener::bind("0.0.0.0:0").await.unwrap(); let addr = listener.local_addr().unwrap(); spawn(async move { serve(listener, app()).await.unwrap(); }); let id = 10; let client = Client::new(); let response = client .get(format!("http://{}/foo/{}", addr, id)) .send() .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.text().await.unwrap(); assert_eq!(body, format!("id = {}", id)); } #[tokio::test] async fn test_real_get_query() { let listener = TcpListener::bind("0.0.0.0:0").await.unwrap(); let addr = listener.local_addr().unwrap(); spawn(async move { serve(listener, app()).await.unwrap(); }); let word = "hello"; let client = Client::new(); let response = client .get(format!("http://{}/query", addr)) .query(&[("word", word)]) .send() .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response.text().await.unwrap(); assert_eq!(body, format!("query word = {}", word)); }
JSONを扱うテストですが、reqwestにjsonフィーチャーを有効にしておいたことでreqwestのResponse.json
を呼び出せるように
なります。
#[tokio::test] async fn test_real_post_json() { let listener = TcpListener::bind("0.0.0.0:0").await.unwrap(); let addr = listener.local_addr().unwrap(); spawn(async move { serve(listener, app()).await.unwrap(); }); let request_body = to_vec(&json!({"message":"Hello World","a":5,"b":3})).unwrap(); let client = Client::new(); let response = client .post(format!("http://{}/json", addr)) .header(CONTENT_TYPE, APPLICATION_JSON.as_ref()) .body(request_body) .send() .await .unwrap(); let body = response.json::<Value>().await.unwrap(); assert_eq!( body, json!({ "message": "Hello World", "result": 8 }) ); }
この部分ですね。
let body = response.json::<Value>().await.unwrap();
ひとまず、こんなところでしょうか。
おわりに
axumで書いたアプリケーションのテストを書いてみました。
テストコードを書くのにいろいろと準備が必要で、なかなか大変でしたがaxumにexampleがあって助かりました…。
というか、これがなかったら書けなかったと思います。
またデータ型の変換が必要になることが多いのですが、このあたりのルールがよくわからないので参考にするものがないと
簡単に詰む気がします。
どうやって慣れていくのがいいんでしょうね…?