RustでWebアプリを開発する際、統合テストの品質はプロダクトの信頼性を左右する重要な要素です。
特にaxumを採用しているプロジェクトでは、公式が推奨するパターンに従うことで、メンテナンス性の高いテストコードを構築できます。
本記事では、axum公式が推奨する統合テストのベストプラクティスを体系的に解説します。
以下の内容を網羅的に取り上げます。
- テスト用サーバーの構築方法とライフサイクル管理
- データベース接続のモック化と実際の接続を使ったテストの使い分け
- ミドルウェアスタックの検証手法
- エラーハンドリングの網羅的なテスト戦略
実務において、統合テストは「動作確認」に留まらず、アーキテクチャの設計品質を担保する手段としても機能します。
適切に実装されたテートは、リファクタリング時の安全網となり、チーム全体の開発速度を向上させる効果も期待できます。
以下、具体的なコード例とともに、axumの統合テスト実装における実践的なノウハウを深掘りしていきます。
はじめに:なぜaxumの統合テストが重要なのか

Rustを用いたWebアプリケーション開発において、統合テストの重要性は決して過小評価できません。
個々の関数やメソッドが正しく動作することを確認する単体テストとは異なり、統合テストはアプリケーション全体の振る舞いを検証する役割を担います。
特にaxumのような非同期Webフレームワークでは、リクエストのルーティング、ミドルウェアの適用、エラーハンドリングの連携といった、複数のコンポーネントが協調動作する部分が多数存在します。
統合テストが不十分な状態で本番環境にデプロイすると、以下のようなリスクが生じます。
- ルーティングの誤設定により、特定のエンドポイントが意図しないレスポンスを返す
- ミドルウェアの適用順序の問題で、認証やロギングが正しく機能しない
- データベーストランザクションの整合性が崩れ、データ不整合が発生する
- エラーレスポンスの形式が統一されず、フロントエンド側での処理が複雑化する
これらの問題は、単体テストでは検出困難なケースが少なくありません。
なぜなら、単体テストではモックを多用するため、実際のHTTP通信やデータベース接続、ミドルウェアの連携を再現できないことが多いからです。
axumの設計思想とテストの親和性
axumは、Towerエコシステムを基盤として構築されており、ミドルウェアとハンドラーの分離という設計思想を徹底しています。
このアーキテクチャは、テスト容易性の観点からも大きなメリットをもたらします。
ハンドラーが純粋な関数として記述できるため、テスト時に必要な依存を明示的に注入できるのです。
また、axumは公式ドキュメントにおいて統合テストの実装パターンを積極的に推奨しており、これに従うことでチーム全体で一貫したテスト戦略を構築できます。
一貫性のあるテストコードは、新規メンバーのオンボーディングを加速させ、コードレビューの際の認知負荷も低減します。
Rustの型安全性がテストにもたらす価値
Rustの所有権システムと型安全性は、テストの観点からも重要な特性です。
コンパイル時に多くのエラーを排除できるため、統合テストは本質的なビジネスロジックの検証に集中できます。
他の動的型付け言語と比較すると、型関連のバグをテストで網羅する必要が大幅に減るため、テストコードの量を抑制しつつ品質を担保できるのです。
しかし、型安全性があるからといって統合テストを省略できるわけではありません。
HTTPレイヤーの挙動、ヘッダーの処理、ステータスコードの返却など、ランタイムでのみ検証可能な要素は依然として存在します。
これらを適切にテストすることで、本番環境での予期しない障害を未然に防ぐことができます。
本記事で扱う範囲
本記事では、axum公式が推奨する統合テストの実装パターンを中心に、実務で即座に活用できる知見を提供します。
具体的には、テスト用サーバーの構築方法、リクエスト・レスポンスの検証手法、データベースを伴うテスト戦略、ミドルウェアのテスト、そしてCI/CDパイプラインへの統合方法について解説します。
以下の内容を通じて、品質の高い統合テストコードを効率的に作成するスキルを身につけていただければ幸いです。
axum公式が推奨する統合テストの基本構成

axumにおける統合テストの実装は、公式ドキュメントで明示的に推奨されているパターンに従うことで、シンプルかつ堅牢なテストコードを構築できます。
この節では、その基本構成について段階的に解説します。
テスト用ルーターの構築
統合テストの出発点は、本番環境と同じルーターを構築することです。
axumでは、Router型を使ってエンドポイントを定義し、それをテスト用に起動するという流れが基本となります。
以下のコードは、最小構成のテスト用ルーターを示したものです。
use axum::{routing::get, Router};
fn app() -> Router {
Router::new()
.route("/", get(root))
}
async fn root() -> &'static str {
"Hello, World!"
}
このapp関数は、本番コードとテストコードの両方から呼び出すことができ、ルーター定義の一元管理を実現します。
テスト時に毎回同じルーターを構築できるため、本番環境との差異が生じにくいのがメリットです。
テスト用サーバーの起動
ルーターが構築できたら、次はテスト用のHTTPサーバーを起動します。
axum公式では、tokio::net::TcpListenerを使って実際のTCPポートを確保し、そこにサーバーをバインドするアプローチを推奨しています。
use tokio::net::TcpListener;
#[tokio::test]
async fn test_root_endpoint() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app()).await.unwrap();
});
let client = reqwest::Client::new();
let res = client
.get(format!("http://{}/", addr))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
assert_eq!(res.text().await.unwrap(), "Hello, World!");
}
このコードの重要なポイントは、ポート番号に0を指定している部分です。
これにより、OSが利用可能な空きポートを自動的に割り当ててくれるため、ポート競合によるテスト失敗を防げます。
reqwestを使ったHTTPクライアントの利用
axumの統合テストでは、HTTPクライアントとしてreqwestクレートの使用が一般的です。
reqwestは非同期対応のHTTPクライアントであり、axumの非同期ランタイムであるtokioと親和性が高いです。
テストコード内でreqwest::Clientを使うことで、以下のような検証が可能になります。
- HTTPメソッド(GET, POST, PUT, DELETEなど)の送信
- リクエストヘッダーの設定と検証
- クエリパラメータの付与
- JSONボディのシリアライズとデシリアライズ
- レスポンスステータスコードの確認
- レスポンスボディの内容検証
テスト関数の非同期化
axumのハンドラーは原則として非同期関数であるため、テスト関数も非同期化する必要があります。
Rustのテストフレームワークでは、#[tokio::test]アトリビュートを付与することで、非同期テスト関数を記述できます。
#[tokio::test]
async fn test_async_handler() {
// 非同期処理を含むテストコード
}
このアトリビュートがないと、非同期関数をテストとして実行できないため、必ず付与する必要があります。
また、tokioのfeatureフラグにrt-multi-threadとmacrosを有効にしておくことを忘れないでください。
テストコードの共通化パターン
複数のテストケースで同じサーバー起動処理を繰り返すのは非効率です。
そのため、ヘルパー関数を用意して共通化するのが推奨されます。
以下は、典型的なヘルパー関数の例です。
async fn spawn_app() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
axum::serve(listener, app()).await.unwrap();
});
addr
}
この関数を使うことで、各テスト関数は以下のように簡潔に記述できます。
#[tokio::test]
async fn test_with_helper() {
let addr = spawn_app().await;
let client = reqwest::Client::new();
let res = client
.get(format!("http://{}/", addr))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
}
基本構成のまとめ
axumの統合テストの基本構成は、以下の3ステップで整理できます。
- ルーター構築:本番と同じ
Routerを生成する関数を定義する - サーバー起動:
TcpListenerを使ってテスト用サーバーを起動する - リクエスト送信:
reqwestクライアントでHTTPリクエストを送信し、レスポンスを検証する
この基本構成を理解した上で、次節以降ではより実践的なテクニックを深掘りしていきます。
データベース接続の扱いやミドルウェアのテストなど、実務で頻出するシナリオについて解説します。
テスト用HTTPサーバーの構築とライフサイクル管理

統合テストにおいて、テスト用HTTPサーバーの構築は最も基礎的かつ重要な工程です。
しかし、サーバーを単に起動するだけでなく、ライフサイクルを適切に管理することで、テストの信頼性と実行速度を大きく向上させることができます。
ポート割り当ての戦略
前節で触れたように、ポート番号に0を指定することでOSに空きポートを任せる方法は、競合回避の観点から有効です。
ただし、テストが増えてくると、各テストごとにサーバーを起動するアプローチでは実行時間が線形に増加してしまいます。
この問題に対処するため、以下の2つの戦略が考えられます。
- テストケースごとに独立したサーバーを起動する:完全な独立性を保証できるが、オーバーヘッドが大きい
- テストスイート全体で1つのサーバーを共有する:実行速度は速いが、テスト間の副作用に注意が必要
多くの場合、テストケースごとに独立したサーバーを起動する方が安全です。
Rustのテストはデフォルトで並列実行されるため、共有サーバーへの同時アクセスが発生すると、予期しない挙動を引き起こすリスクがあります。
サーバー起動の共通化と状態管理
テストケースごとにサーバーを起動する場合でも、起動処理自体は共通化できます。
以下のように、TestApp構造体を定義してサーバーの状態を管理すると、コードの再利用性が高まります。
use std::net::SocketAddr;
use tokio::net::TcpListener;
struct TestApp {
addr: SocketAddr,
}
impl TestApp {
async fn new(router: Router) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
Self { addr }
}
fn base_url(&self) -> String {
format!("http://{}", self.addr)
}
}
この構造体を使うことで、テスト関数内でのサーバー操作が抽象化され、テストコードの意図が明確になります。
サーバーのシャットダウン処理
テストが増えると、起動したサーバーが適切に終了しないことで、リソースリークが発生する可能性があります。
特にCI環境では、プロセス数の上限に達してテストが失敗するケースもあります。
axumのserveは、シャットダウンシグナルを受け取ることでグレースフルに終了できます。
テストコードでは、tokio::sync::oneshotチャンネルを使ってシャットダウンを制御する方法が有効です。
use tokio::sync::oneshot;
struct TestApp {
addr: SocketAddr,
shutdown_tx: Option<oneshot::Sender<()>>,
}
impl TestApp {
async fn new(router: Router) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (shutdown_tx, shutdown_rx) = oneshot::channel();
tokio::spawn(async move {
let server = axum::serve(listener, router);
let server = server.with_graceful_shutdown(async move {
let _ = shutdown_rx.await;
});
server.await.unwrap();
});
Self {
addr,
shutdown_tx: Some(shutdown_tx),
}
}
fn shutdown(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
}
}
ただし、Rustのテストフレームワークにはテスト終了時に自動的にリソースを解放する仕組みが限定的であるため、Dropトレイトの実装を検討する価値があります。
impl Drop for TestApp {
fn drop(&mut self) {
self.shutdown();
}
}
タイムアウトの設定
テスト用サーバーがレスポンスを返さない場合、テストが永遠に完了しないリスクがあります。
これを防ぐため、リクエスト送信時にタイムアウトを設定することが推奨されます。
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
タイムアウト値は、テスト対象の処理時間に応じて調整してください。
一般的に、統合テストのタイムアウトは5秒から10秒程度が妥当です。
ライフサイクル管理のベストプラクティス
テスト用HTTPサーバーのライフサイクルを管理する上で、以下の原則を意識するとよいでしょう。
- 独立性を優先する:テストケースごとにサーバーを起動し、副作用を隔離する
- リソースの解放を確実にする:シャットダウンシグナルやタイムアウトを適切に設定する
- 共通処理は抽象化する:
TestAppのような構造体でラップし、テストコードを簡潔に保つ - 実行速度とのトレードオフを考慮する:テスト数が膨大になった場合、共有サーバーの検討も視野に入れる
これらの原則を踏まえた上で、次節ではリクエスト・レスポンスの具体的な検証手法について解説します。
リクエスト・レスポンスの検証パターン

統合テストの核心は、HTTPリクエストを送信してレスポンスを検証することにあります。
axumでは、ステータスコード、ヘッダー、ボディの3要素を網羅的に検証することで、エンドポイントの品質を担保できます。
この節では、実務で頻出する検証パターンを体系化して解説します。
ステータスコードの検証
最も基本的な検証は、レスポンスのステータスコードを確認することです。
reqwest::Response型のstatusメソッドを使うことで、HTTPステータスコードを取得できます。
let res = client
.get(format!("http://{}/users/1", addr))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
ステータスコードの検証では、成功系だけでなく異常系も含めて網羅的にテストすることが重要です。
例えば、存在しないリソースへのアクセスでは404、認証が必要なエンドポイントへの未認証アクセスでは401、権限不足では403を返すことを確認すべきです。
// 存在しないユーザーへのリクエスト
let res = client
.get(format!("http://{}/users/99999", addr))
.send()
.await
.unwrap();
assert_eq!(res.status(), 404);
レスポンスヘッダーの検証
Content-Typeや認証トークンなど、レスポンスヘッダーの検証も統合テストの重要な役割です。
reqwest::Response型のheadersメソッドを使うことで、ヘッダー情報にアクセスできます。
let res = client
.get(format!("http://{}/api/data", addr))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
assert_eq!(
res.headers().get("content-type").unwrap(),
"application/json"
);
ヘッダー名は小文字で取得される点に注意してください。
HTTP/2ではヘッダー名が小文字に正規化されるため、reqwestもこの仕様に準拠しています。
JSONレスポンスボディの検証
Web APIではJSON形式のレスポンスが一般的です。
reqwestのjsonメソッドを使うことで、レスポンスボディを構造体にデシリアライズできます。
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct UserResponse {
id: u64,
name: String,
email: String,
}
let res = client
.get(format!("http://{}/users/1", addr))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let user: UserResponse = res.json().await.unwrap();
assert_eq!(user.id, 1);
assert_eq!(user.name, "山田太郎");
このパターンでは、レスポンスのスキーマ整合性も同時に検証できるため、API仕様の変更を早期に検出できます。
リクエストボディの送信
POSTやPUTリクエストでは、リクエストボディを送信する必要があります。
reqwestでは、jsonメソッドを使って構造体をJSONにシリアライズして送信できます。
#[derive(Serialize)]
struct CreateUserRequest {
name: String,
email: String,
}
let req_body = CreateUserRequest {
name: "佐藤花子".to_string(),
email: "sato@example.com".to_string(),
};
let res = client
.post(format!("http://{}/users", addr))
.json(&req_body)
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
リクエストボディの検証とレスポンスボディの検証を組み合わせることで、一連のCRUD操作を統合テストで網羅できます。
クエリパラメータの検証
検索APIなどでは、クエリパラメータを使ったリクエストが一般的です。
reqwestでは、queryメソッドを使うことで、構造体からクエリパラメータを生成できます。
#[derive(Serialize)]
struct SearchQuery {
keyword: String,
limit: u32,
}
let query = SearchQuery {
keyword: "Rust".to_string(),
limit: 10,
};
let res = client
.get(format!("http://{}/search", addr))
.query(&query)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
エラーレスポンスの構造化検証
エラーレスポンスも統一された形式で返却されるべきです。
以下のように、エラーレスポンス用の構造体を定義して検証すると、エラーハンドリングの一貫性を確認できます。
#[derive(Debug, Deserialize)]
struct ErrorResponse {
code: String,
message: String,
}
let res = client
.get(format!("http://{}/users/99999", addr))
.send()
.await
.unwrap();
assert_eq!(res.status(), 404);
let error: ErrorResponse = res.json().await.unwrap();
assert_eq!(error.code, "NOT_FOUND");
assert!(error.message.contains("ユーザー"));
検証パターンのまとめ
統合テストでの検証は、以下の観点から体系的に行うことを推奨します。
| 検証項目 | 検証内容 | 使用メソッド |
|---|---|---|
| ステータスコード | HTTPステータスの正確性 | res.status() |
| レスポンスヘッダー | Content-Typeなどの確認 | res.headers() |
| JSONボディ | 構造体へのデシリアライズ | res.json().await |
| テキストボディ | 文字列としての検証 | res.text().await |
| エラーレスポンス | 異常系の構造化検証 | res.json().await |
これらのパターンを組み合わせることで、エンドポイントの品質を多角的に担保できます。
次節では、データベースを伴う統合テストの実装戦略について解説します。
データベースを伴う統合テストの実装戦略

Webアプリケーションの統合テストにおいて、データベースの扱いは最も複雑な課題の一つです。
実際のデータベース接続を使用するか、モックに置き換えるかという選択は、テストの信頼性と実行速度のトレードオフを伴います。
この節では、実務で採用可能な戦略を比較検討し、推奨パターンを解説します。
実データベースを使うアプローチの利点
実際のデータベース(PostgreSQLやMySQLなど)を使った統合テストは、最も本番環境に近い挙動を再現できます。
SQLの発行、トランザクション管理、インデックスの効果など、データベースレイヤーの特性を含めて検証できるのが大きなメリットです。
特に以下のようなシナリオでは、実データベースの使用が推奨されます。
- 複雑なJOINや集計クエリの動作確認
- トランザクション分離レベルの検証
- マイグレーション適用後のスキーマ整合性確認
- フルテキスト検索やJSON型などのDB固有機能の利用
実データベースを使う場合、テスト専用のデータベースインスタンスを用意し、各テスト実行前にクリーンな状態に戻す仕組みが必要です。
テスト用データベースのセットアップ
Dockerなどのコンテナ技術を使うことで、テスト用データベースを簡単に構築できます。
testcontainersクレートを使うと、Rustのテストコード内からコンテナを起動・停止できます。
use testcontainers_modules::{postgres, testcontainers::runners::AsyncRunner};
async fn setup_test_db() -> (String, ContainerAsync<postgres::Postgres>) {
let container = postgres::Postgres::default()
.start()
.await
.unwrap();
let host = container.get_host().await.unwrap();
let port = container.get_host_port_ipv4(5432).await.unwrap();
let database_url = format!(
"postgres://postgres:postgres@{}:{}/postgres",
host, port
);
(database_url, container)
}
このアプローチでは、各テストケースごとに独立したデータベースコンテナが起動されるため、テスト間の副作用が完全に排除されます。
データベース接続の共有とクリーンアップ
テスト実行速度を重視する場合、テストスイート全体で1つのデータベース接続を共有し、各テストの前後でデータをクリーンアップする方法も有効です。
use sqlx::PgPool;
async fn setup_pool() -> PgPool {
let database_url = std::env::var("TEST_DATABASE_URL")
.expect("TEST_DATABASE_URL must be set");
PgPool::connect(&database_url)
.await
.expect("Failed to connect to database")
}
async fn cleanup_tables(pool: &PgPool) {
sqlx::query("TRUNCATE TABLE users, posts RESTART IDENTITY CASCADE")
.execute(pool)
.await
.unwrap();
}
この方法では、テスト開始時に一度だけデータベース接続を確立し、各テストの終了時にcleanup_tablesを呼び出すことで、次のテストに影響を与えないようにします。
マイグレーションの適用
データベーススキーマは、本番環境と同じマイグレーションを適用することで同期します。
sqlx migrate runコマンドを使うか、プログラムからマイグレーションを実行できます。
use sqlx::migrate;
async fn run_migrations(pool: &PgPool) {
migrate!("./migrations")
.run(pool)
.await
.expect("Failed to run migrations");
}
マイグレーションの適用は、テストスイートの初期化時に一度だけ実行し、以降は同じスキーマを使い回すのが効率的です。
モックを使う代替戦略
データベース接続をモック化するアプローチは、実行速度の観点から優位です。
しかし、SQLの発行内容まで検証する必要がある場合、モックの記述が複雑化する傾向があります。
Rustのエコシステムでは、mockallクレートを使ってトレイトベースのモックを生成できます。
ただし、axumのハンドラーがデータベース接続プールを直接受け取る設計の場合、モック化には追加の抽象化レイヤーが必要になります。
#[automock]
#[async_trait]
pub trait UserRepository: Send + Sync {
async fn find_by_id(&self, id: i64) -> Result<Option<User>, Error>;
async fn create(&self, user: &CreateUser) -> Result<User, Error>;
}
このようにリポジトリパターンを導入することで、ビジネスロジックとデータアクセスの分離を実現し、テスト時のモック化が容易になります。
戦略の選択基準
実データベースとモックの使い分けは、以下の基準で判断するとよいでしょう。
| 基準 | 実データベース | モック |
|---|---|---|
| 実行速度 | 遅い(コンテナ起動や接続確立に時間がかかる) | 高速 |
| 信頼性 | 高い(実際のDB挙動を再現) | 中程度(モックの実装に依存) |
| セットアップ工数 | 多い(コンテナやマイグレーション管理が必要) | 少ない |
| 複雑なクエリの検証 | 適している | 不向き |
| 並列実行 | データ競合に注意が必要 | 安全 |
多くのプロジェクトでは、統合テストでは実データベースを、単体テストではモックを使うという使い分けが採用されています。
推奨パターン
実務では、以下のパターンがバランスの取れた選択として推奨されます。
- テスト専用のデータベースコンテナを
testcontainersで管理する - 各テストスイートの開始時にマイグレーションを適用し、スキーマを同期する
- 各テストケースの前後でテーブルをTRUNCATEし、クリーンな状態を保つ
- リポジトリパターンを導入し、必要に応じてモック化も可能にしておく
このパターンを採用することで、本番環境に近い信頼性を保ちつつ、テストの保守性も確保できます。
次節では、ミドルウェアと認証のテスト手法について解説します。
ミドルウェアと認証のテスト手法

axumのアーキテクチャにおいて、ミドルウェアはリクエスト処理パイプラインの中核を担います。
ロギング、認証、レート制限、CORS処理など、横断的関心事をハンドラーから分離する役割を果たします。
しかし、この分離がゆえに、ミドルウェアの動作を統合テストで検証することは必須です。
この節では、ミドルウェアと認証のテスト手法を体系的に解説します。
ミドルウェアスタックの検証
axumでは、複数のミドルウェアをlayerメソッドで重ね合わせて適用します。
このスタックが正しい順序で動作することを確認するため、各ミドルウェアの効果を個別に検証するテストが有効です。
例えば、以下のようなミドルウェアスタックを考えます。
fn app() -> Router {
Router::new()
.route("/protected", get(protected_handler))
.layer(axum::middleware::from_fn(auth_middleware))
.layer(TraceLayer::new_for_http())
}
この場合、認証ミドルウェアが正しく機能すること、およびトレースレイヤーがリクエストログを出力することを、それぞれ別のテストケースで検証すべきです。
認証ミドルウェアのテスト
認証は、Webアプリケーションのセキュリティに直結する重要な機能です。
統合テストでは、以下の3パターンを網羅的に検証します。
- 有効な認証情報を持つリクエスト:正常にアクセスできること
- 無効な認証情報を持つリクエスト:適切なエラーレスポンスが返ること
- 認証情報を持たないリクエスト:未認証エラーが返ること
以下は、Bearerトークンによる認証をテストする例です。
#[tokio::test]
async fn test_protected_with_valid_token() {
let addr = spawn_app().await;
let client = reqwest::Client::new();
let res = client
.get(format!("http://{}/protected", addr))
.header("Authorization", "Bearer valid_token_123")
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
}
#[tokio::test]
async fn test_protected_without_token() {
let addr = spawn_app().await;
let client = reqwest::Client::new();
let res = client
.get(format!("http://{}/protected", addr))
.send()
.await
.unwrap();
assert_eq!(res.status(), 401);
let error: ErrorResponse = res.json().await.unwrap();
assert_eq!(error.code, "UNAUTHORIZED");
}
このテストでは、認証ヘッダーの有無だけでなく、エラーレスポンスの内容まで検証しています。
テスト用認証情報の注入
認証ミドルウェアのテストを効率化するため、テスト専用の認証トークン生成機構を用意するとよいでしょう。
以下は、テスト用のJWTトークンを生成するヘルパー関数の例です。
fn generate_test_token(user_id: &str) -> String {
let claims = json!({
"sub": user_id,
"exp": chrono::Utc::now().timestamp() + 3600,
"iat": chrono::Utc::now().timestamp(),
});
jsonwebtoken::encode(
&jsonwebtoken::Header::default(),
&claims,
&jsonwebtoken::EncodingKey::from_secret(b"test_secret"),
)
.unwrap()
}
この関数を使うことで、テストコード内で任意のユーザーIDを持つ有効なトークンを生成でき、様々な認証シナリオを再現できます。
カスタムミドルウェアの動作検証
自作のミドルウェアについては、リクエスト・レスポンスの変換が正しく行われることを検証します。
以下は、リクエストIDを付与するカスタムミドルウェアのテスト例です。
#[tokio::test]
async fn test_request_id_middleware() {
let addr = spawn_app().await;
let client = reqwest::Client::new();
let res = client
.get(format!("http://{}/health", addr))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let request_id = res
.headers()
.get("x-request-id")
.expect("x-request-id header should be present");
assert!(!request_id.is_empty());
assert!(request_id.to_str().unwrap().len() >= 16);
}
このテストでは、レスポンスヘッダーにx-request-idが含まれていること、およびその値が一定の長さを持つことを確認しています。
CORSミドルウェアのテスト
CORS(Cross-Origin Resource Sharing)の設定が正しいかどうかは、フロントエンドとの連携に直結します。
統合テストでは、プリフライトリクエスト(OPTIONSメソッド)と実際のリクエストの両方を検証します。
#[tokio::test]
async fn test_cors_preflight() {
let addr = spawn_app().await;
let client = reqwest::Client::new();
let res = client
.request(
reqwest::Method::OPTIONS,
format!("http://{}/api/data", addr),
)
.header("Origin", "http://localhost:3000")
.header("Access-Control-Request-Method", "POST")
.send()
.await
.unwrap();
assert_eq!(res.status(), 204);
assert_eq!(
res.headers().get("access-control-allow-origin").unwrap(),
"http://localhost:3000"
);
}
CORSのテストでは、許可されたオリジンからのリクエストが通ることと、許可されていないオリジンからのリクエストが拒否されることの両方を確認する必要があります。
ミドルウェアのテスト戦略のまとめ
ミドルウェアのテストは、以下の原則に従うと効果的です。
- 各ミドルウェアを独立してテストし、相互作用による予期しない挙動を早期に検出する
- 認証テストは正例と負例の両方を網羅し、セキュリティホールを防ぐ
- ヘッダー検証を活用し、ミドルウェアの副作用を明確に確認する
- テスト用の認証情報生成機構を整備し、テストコードの重複を排除する
これらの手法を組み合わせることで、ミドルウェアレイヤーの品質を確実に担保できます。
次節では、エラーハンドリングと異常系の網羅的テストについて解説します。
エラーハンドリングと異常系の網羅的テスト

Webアプリケーションの品質担保において、異常系のテストは正常系以上に重要です。
ユーザーが予期しない入力を行った場合や、外部サービスが障害を起こした場合でも、アプリケーションが適切にエラーを処理し、安全な状態を維持できるかどうかは、プロダクトの信頼性を左右します。
この節では、axumにおけるエラーハンドリングの実装と、その網羅的なテスト手法について解説します。
axumにおけるエラーハンドリングの基本
axumでは、ハンドラーがResult<T, E>型を返すことで、エラーレスポンスを自動的に生成できます。
IntoResponseトレイトを実装したエラー型を定義することで、一貫したエラーレスポンス形式を全エンドポイントで統一できます。
use axum::{response::IntoResponse, http::StatusCode};
use serde::Serialize;
#[derive(Serialize)]
struct AppError {
code: String,
message: String,
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let status = match self.code.as_str() {
"NOT_FOUND" => StatusCode::NOT_FOUND,
"UNAUTHORIZED" => StatusCode::UNAUTHORIZED,
"VALIDATION_ERROR" => StatusCode::BAD_REQUEST,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
let body = axum::Json(self);
(status, body).into_response()
}
}
この実装では、エラーコードに応じて適切なHTTPステータスコードを割り当て、JSON形式のレスポンスボディを生成しています。
入力検証エラーのテスト
ユーザーからの入力は、常に不正な可能性を含んでいます。
以下のパターンを網羅的にテストすることで、入力検証の堅牢性を担保します。
- 必須項目の欠如
- データ型の不一致(数値フィールドに文字列を送信するなど)
- 文字列長の超過
- 不正なフォーマット(メールアドレスの形式違反など)
#[tokio::test]
async fn test_validation_error() {
let addr = spawn_app().await;
let client = reqwest::Client::new();
let invalid_body = json!({
"name": "",
"email": "not-an-email",
"age": -1
});
let res = client
.post(format!("http://{}/users", addr))
.json(&invalid_body)
.send()
.await
.unwrap();
assert_eq!(res.status(), 400);
let error: AppError = res.json().await.unwrap();
assert_eq!(error.code, "VALIDATION_ERROR");
assert!(error.message.contains("入力内容"));
}
リソース不在エラーのテスト
存在しないリソースへのアクセスは、Web APIで最も頻出する異常系の一つです。
以下のように、存在しないIDを指定した場合の挙動を検証します。
#[tokio::test]
async fn test_not_found_error() {
let addr = spawn_app().await;
let client = reqwest::Client::new();
let res = client
.get(format!("http://{}/users/99999", addr))
.send()
.await
.unwrap();
assert_eq!(res.status(), 404);
let error: AppError = res.json().await.unwrap();
assert_eq!(error.code, "NOT_FOUND");
assert!(error.message.contains("ユーザー"));
}
外部サービス障害のシミュレーション
外部APIやデータベースへの接続が失われた場合、アプリケーションがどのように振る舞うかをテストすることは、障害耐性の観点から極めて重要です。
外部サービスをモック化する場合、wiremockクレートを使うことで、HTTP外部サービスの障害をシミュレートできます。
use wiremock::{MockServer, Mock, ResponseTemplate};
use wiremock::matchers::{method, path};
async fn setup_failing_external_service() -> MockServer {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/external-api"))
.respond_with(
ResponseTemplate::new(503)
.set_body_string("Service Unavailable")
)
.mount(&mock_server)
.await;
mock_server
}
このモックサーバーを使うことで、外部サービスが503エラーを返す場合のアプリケーション挙動を、統合テスト内で安全に検証できます。
タイムアウトとレート制限のテスト
外部サービスへのリクエストがタイムアウトした場合、デッドロックやリソース枯渇を引き起こさないようにする必要があります。
以下は、タイムアウト処理を検証するテスト例です。
#[tokio::test]
async fn test_timeout_handling() {
let addr = spawn_app().await;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.unwrap();
let res = client
.get(format!("http://{}/slow-endpoint", addr))
.send()
.await;
match res {
Ok(response) => {
assert_eq!(response.status(), 504);
}
Err(e) if e.is_timeout() => {
// クライアント側タイムアウトも許容される
}
Err(e) => panic!("Unexpected error: {}", e),
}
}
エラーレスポンス形式の一貫性検証
全エンドポイントで同じ構造のエラーレスポンスが返されることを確認するため、以下のような汎用検証関数を用意すると効率的です。
async fn assert_error_response(
res: reqwest::Response,
expected_status: StatusCode,
expected_code: &str,
) {
assert_eq!(res.status(), expected_status);
let error: AppError = res.json().await.unwrap();
assert_eq!(error.code, expected_code);
assert!(!error.message.is_empty());
assert!(error.message.len() <= 200);
}
この関数を使うことで、エラーレスポンスの検証が標準化され、テストコードの重複を大幅に削減できます。
異常系テストのカバレッジ目標
異常系のテストは、以下の観点で網羅的に実施することを推奨します。
| 異常系カテゴリ | 具体例 | 期待されるレスポンス |
|---|---|---|
| 入力検証 | 必須項目欠如、型不一致 | 400 Bad Request |
| 認証・認可 | 無効なトークン、権限不足 | 401/403 |
| リソース不在 | 存在しないIDへのアクセス | 404 Not Found |
| 外部サービス | API障害、タイムアウト | 502/504 |
| 内部エラー | 予期しない例外 | 500 Internal Server Error |
これらのカテゴリを網羅することで、エラーハンドリングの品質を定量的に担保できます。
次節では、CI/CDパイプラインへの統合と実行速度の最適化について解説します。
CI/CDパイプラインへの統合と実行速度の最適化

統合テストを実装しただけでは、品質担保の目的は半分しか達成できません。
CI/CDパイプラインに統合して自動化し、かつ実行速度を最適化することで、継続的な品質管理を実現する必要があります。
この節では、GitHub Actionsを中心に、Rustプロジェクトでの統合テスト自動化と速度最適化の手法を解説します。
GitHub Actionsでのテスト自動化
GitHub Actionsは、RustプロジェクトのCI/CDに広く利用されています。
以下は、axumプロジェクトの統合テストを実行する基本的なワークフロー定義です。
name: Integration Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache dependencies
uses: Swatinem/rust-cache@v2
- name: Run migrations
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
run: sqlx migrate run
- name: Run tests
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
run: cargo test --test integration
このワークフローでは、PostgreSQLサービスコンテナを起動し、マイグレーションを適用した上で統合テストを実行しています。
テスト実行の並列化
Rustのテストフレームワークは、デフォルトで複数スレッドを使った並列実行を行います。
しかし、データベース接続を共有する統合テストでは、並列実行によるデータ競合が発生するリスクがあります。
この問題に対処するため、以下の2つのアプローチが考えられます。
- テストケースごとに独立したデータベーススキーマを使用する
--test-threads=1オプションで並列実行を無効化する
並列実行を維持したい場合、以下のように各テストで異なるデータベースを使う方法が有効です。
async fn setup_isolated_db() -> String {
let base_url = std::env::var("DATABASE_URL").unwrap();
let uuid = uuid::Uuid::new_v4().to_string().replace("-", "_");
let db_name = format!("test_{}", uuid);
let pool = PgPool::connect(&base_url).await.unwrap();
sqlx::query(&format!("CREATE DATABASE {}", db_name))
.execute(&pool)
.await
.unwrap();
base_url.replace("test_db", &db_name)
}
この方法では、各テストケースが独立したデータベースを使用するため、並列実行による副作用を完全に排除できます。
キャッシュ戦略による高速化
Rustのコンパイルは時間がかかるため、CI環境での依存関係キャッシュは必須です。
Swatinem/rust-cacheアクションを使うことで、targetディレクトリとCargoのキャッシュを自動的に管理できます。
さらに、SQLxの準備チェックをキャッシュすることで、クエリの検証時間も短縮できます。
- name: Cache SQLx
uses: actions/cache@v4
with:
path: ~/.cargo/bin/sqlx
key: ${{ runner.os }}-sqlx-${{ hashFiles('**/Cargo.lock') }}
テストの選択的実行
変更されたコードに関連するテストのみを実行することで、フィードバックループを短縮できます。
cargo testのフィルタ機能を使うことで、特定のテストモジュールや関数名にマッチするテストだけを実行できます。
# 特定のモジュールのテストのみ実行
cargo test --test integration user::
# 特定のテスト名を含むもののみ実行
cargo test --test integration create_user
Pull Request時には、変更ファイルに基づいて関連テストを選択的に実行し、mainブランチへのマージ時には全テストを実行するという2段階の戦略も有効です。
テストカバレッジの可視化
統合テストの品質を定量的に把握するため、コードカバレッジの計測を導入することを推奨します。
cargo-tarpaulinを使うことで、Rustプロジェクトのカバレッジを計測できます。
- name: Generate coverage
run: cargo tarpaulin --test integration --out Xml
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./cobertura.xml
カバレッジレポートを可視化することで、テストの盲点を特定し、品質向上の指標として活用できます。
実行速度最適化のまとめ
統合テストの実行速度を最適化するための主要な手法は以下の通りです。
| 最適化手法 | 効果 | 実装の複雑さ |
|---|---|---|
| 依存関係キャッシュ | コンパイル時間の短縮 | 低 |
| 並列テスト実行 | 総実行時間の短縮 | 中(データベース分離が必要) |
| 選択的テスト実行 | PR時のフィードバック短縮 | 低 |
| サービスコンテナの最適化 | セットアップ時間の短縮 | 中 |
| カバレッジ計測 | 品質の可視化 | 低 |
これらの手法を組み合わせることで、品質を担保しつつ迅速なフィードバックを実現できます。
次節では、実践的な統合テストコードの完全例を示します。
実践的な統合テストコードの完全例

これまでの節で解説した手法を統合し、実務でそのまま活用できる完全な統合テストコードを示します。
以下の例では、ユーザー管理APIを対象に、データベース接続、認証、エラーハンドリング、入力検証を含む包括的なテストスイートを構築しています。
プロジェクト構成
以下のようなディレクトリ構成を前提とします。
src/
main.rs
handlers/
user.rs
models/
user.rs
middleware/
auth.rs
error.rs
tests/
integration/
main.rs
helpers.rs
user_test.rs
テスト用ヘルパーモジュール
tests/integration/helpers.rsには、テスト全体で共有するユーティリティを定義します。
use axum::Router;
use reqwest::Client;
use sqlx::PgPool;
use std::net::SocketAddr;
use tokio::net::TcpListener;
use uuid::Uuid;
pub struct TestApp {
pub addr: SocketAddr,
pub db_pool: PgPool,
pub client: Client,
}
impl TestApp {
pub async fn new() -> Self {
let db_pool = setup_test_db().await;
let router = create_test_router(db_pool.clone());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
let client = Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.unwrap();
Self {
addr,
db_pool,
client,
}
}
pub fn base_url(&self) -> String {
format!("http://{}", self.addr)
}
pub async fn cleanup(&self) {
sqlx::query("TRUNCATE TABLE users RESTART IDENTITY CASCADE")
.execute(&self.db_pool)
.await
.unwrap();
}
pub fn generate_token(&self, user_id: &str) -> String {
let claims = serde_json::json!({
"sub": user_id,
"exp": chrono::Utc::now().timestamp() + 3600,
});
jsonwebtoken::encode(
&jsonwebtoken::Header::default(),
&claims,
&jsonwebtoken::EncodingKey::from_secret(b"test_secret"),
)
.unwrap()
}
}
async fn setup_test_db() -> PgPool {
let base_url = std::env::var("TEST_DATABASE_URL")
.expect("TEST_DATABASE_URL must be set");
let uuid = Uuid::new_v4().to_string().replace("-", "_");
let db_name = format!("test_{}", uuid);
let admin_pool = PgPool::connect(&base_url).await.unwrap();
sqlx::query(&format!("CREATE DATABASE {}", db_name))
.execute(&admin_pool)
.await
.unwrap();
let test_url = base_url.replace(
base_url.split('/').last().unwrap(),
&db_name
);
let pool = PgPool::connect(&test_url).await.unwrap();
sqlx::migrate!("./migrations")
.run(&pool)
.await
.unwrap();
pool
}
fn create_test_router(pool: PgPool) -> Router {
// 本番のルーター構築関数を呼び出し、テスト用DBプールを注入
my_app::create_router(pool)
}
ユーザーAPIの統合テスト
tests/integration/user_test.rsには、ユーザー管理APIの統合テストを定義します。
use crate::helpers::TestApp;
use serde_json::json;
#[tokio::test]
async fn test_create_user_success() {
let app = TestApp::new().await;
let body = json!({
"name": "山田太郎",
"email": "yamada@example.com"
});
let res = app
.client
.post(format!("{}/users", app.base_url()))
.json(&body)
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
let user: serde_json::Value = res.json().await.unwrap();
assert_eq!(user["name"], "山田太郎");
assert_eq!(user["email"], "yamada@example.com");
assert!(user["id"].is_u64());
app.cleanup().await;
}
#[tokio::test]
async fn test_create_user_validation_error() {
let app = TestApp::new().await;
let body = json!({
"name": "",
"email": "invalid-email"
});
let res = app
.client
.post(format!("{}/users", app.base_url()))
.json(&body)
.send()
.await
.unwrap();
assert_eq!(res.status(), 400);
let error: serde_json::Value = res.json().await.unwrap();
assert_eq!(error["code"], "VALIDATION_ERROR");
app.cleanup().await;
}
#[tokio::test]
async fn test_get_user_success() {
let app = TestApp::new().await;
let create_res = app
.client
.post(format!("{}/users", app.base_url()))
.json(&json!({
"name": "佐藤花子",
"email": "sato@example.com"
}))
.send()
.await
.unwrap();
let created: serde_json::Value = create_res.json().await.unwrap();
let user_id = created["id"].as_u64().unwrap();
let res = app
.client
.get(format!("{}/users/{}", app.base_url(), user_id))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
let user: serde_json::Value = res.json().await.unwrap();
assert_eq!(user["name"], "佐藤花子");
app.cleanup().await;
}
#[tokio::test]
async fn test_get_user_not_found() {
let app = TestApp::new().await;
let res = app
.client
.get(format!("{}/users/99999", app.base_url()))
.send()
.await
.unwrap();
assert_eq!(res.status(), 404);
let error: serde_json::Value = res.json().await.unwrap();
assert_eq!(error["code"], "NOT_FOUND");
app.cleanup().await;
}
#[tokio::test]
async fn test_protected_endpoint_with_auth() {
let app = TestApp::new().await;
let token = app.generate_token("user_123");
let res = app
.client
.get(format!("{}/protected", app.base_url()))
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
app.cleanup().await;
}
#[tokio::test]
async fn test_protected_endpoint_without_auth() {
let app = TestApp::new().await;
let res = app
.client
.get(format!("{}/protected", app.base_url()))
.send()
.await
.unwrap();
assert_eq!(res.status(), 401);
let error: serde_json::Value = res.json().await.unwrap();
assert_eq!(error["code"], "UNAUTHORIZED");
app.cleanup().await;
}
テストエントリーポイント
tests/integration/main.rsには、モジュールの統合を定義します。
mod helpers;
mod user_test;
この構成により、cargo test --test integrationコマンドで統合テストスイート全体を実行できます。
完全例の構成要素のまとめ
以上の完全例には、以下の要素が含まれています。
| 要素 | 実装内容 |
|---|---|
| テスト用DB | 各テストで独立したデータベースを生成し、マイグレーションを適用 |
| サーバー起動 | TcpListenerを使った動的ポート割り当て |
| 認証 | JWTトークンの生成と検証 |
| 入力検証 | 不正な入力に対する400エラーの検証 |
| リソース不在 | 存在しないIDへのアクセスに対する404エラーの検証 |
| クリーンアップ | テスト終了時のテーブルTRUNCATE |
| タイムアウト | reqwestクライアントへの5秒タイムアウト設定 |
この完全例をベースラインとして、プロジェクトの要件に応じて拡張していくことを推奨します。
次節では、本記事の内容をまとめます。
まとめ:品質担保のための統合テスト運用のポイント

本記事では、Rustのaxumフレームワークを用いたWebアプリケーションにおける統合テストの実装手法を、公式推奨パターンに基づいて体系的に解説しました。
ここまでの内容を振り返り、品質担保のための統合テスト運用における重要なポイントを整理します。
統合テストの価値は、単に「テストが通るかどうか」を確認することに留まりません。
アーキテクチャの設計品質を継続的に検証する仕組みとして機能し、チーム全体の開発速度とコードの信頼性を向上させるのです。
axumの設計思想であるミドルウェアとハンドラーの分離は、このようなテスト駆動の開発スタイルと高い親和性を持っています。
本記事で扱った主要なテーマは以下の通りです。
- axum公式が推奨するテスト用HTTPサーバーの構築方法
- リクエスト・レスポンスの多角的な検証パターン
- データベースを伴うテストの実装戦略とモックの使い分け
- ミドルウェアと認証の動作検証手法
- 異常系を網羅的にカバーするエラーハンドリングのテスト
- CI/CDパイプラインへの統合と実行速度の最適化
- 実務で即座に活用できる完全なテストコード例
これらの手法を適切に組み合わせることで、本番環境に近い条件下での品質検証が可能になります。
特に、データベース接続を伴う統合テストでは、テスト間の独立性を保ちつつ実行速度を最適化するバランス感覚が重要です。
統合テストの運用を開始する際は、まず最小構成から始めて段階的に拡張することを推奨します。
全エンドポイントを一度にカバーしようとするのではなく、重要なビジネスロジックを含むエンドポイントから優先的にテストを追加し、チーム内でテスト文化を醸成していくのが効果的です。
また、テストコードの保守性を高めるため、ヘルパー関数や構造体による抽象化を積極的に行い、重複コードを排除してください。
最後に、統合テストはあくまで品質担保の一手段であり、単体テストやE2Eテストと組み合わせることで初めて包括的な品質保証体系が構築されます。
axumの型安全性と、統合テストによる振る舞いの検証を両輪として、高品質なRust Webアプリケーションの開発を継続していっていただければ幸いです。


コメント