CLOVER🍀

That was when it all began.

axumで書いたアプリケーションのテストを書いてみる

これは、なにをしたくて書いたもの?

前に、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 - Rust

こちらですね。
※やっぱり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 - Rust

mime - Rust

tower - Rust

reqwest - Rust

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() {

test in tokio - Rust

テストの中身に移りましょう。

最初に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

ServiceExt in tower - Rust

Oneshot in tower::util - Rust

これで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のBodyExtuseしていないと利用できません。

    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));
    }

Routerinto_serviceを呼び出してRouterIntoServiceに変換します。またmutキーワードを付与します。

        let mut app = app().into_service();

Router in axum - Rust

あとの違いは、RouterIntoServiceServiceExt::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を使っていたのですが、

hyper_util - Rust

今回はreqwestを使うことにします。

GitHub - seanmonstar/reqwest: An easy and powerful Rust HTTP Client

reqwest - Rust

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があって助かりました…。
というか、これがなかったら書けなかったと思います。

またデータ型の変換が必要になることが多いのですが、このあたりのルールがよくわからないので参考にするものがないと
簡単に詰む気がします。

どうやって慣れていくのがいいんでしょうね…?