Rust Webアプリケーションにおけるテスト戦略
James Reed
Infrastructure Engineer · Leapcell

RustでWebアプリケーションを開発することは、比類なきパフォーマンス、メモリ安全性、並行性をもたらします。しかし、非常に信頼性が高く保守性の高いアプリケーションを構築することは、単に機能するコードを書く以上のものです。包括的なテスト戦略は、しばしば見過ごされたり、不適切に実装されたりする重要な側面です。アプリケーションが絶えず進化し、さまざまな外部コンポーネントと相互作用するWeb開発のダイナミックな世界では、堅牢なテスト戦略は単なるベストプラクティスではなく、必要不可欠です。それらは、リファクタリング、新機能のデプロイ、そして変更がリグレッション(退行)を引き起こさないことを確実にするための自信を提供します。この記事では、Rust Webアプリケーション内のハンドラとサービスの単体テストおよび統合テストのための効果的な方法論を掘り下げ、より回復力のあるシステムを構築するためのツールと知識を提供します。
テストにおけるコアコンセプト
実践に移る前に、本稿全体で頻繁に登場するいくつかの重要なテスト用語について、共通の理解を確立しましょう。
- 単体テスト (Unit Test): 単一の関数、メソッド、または小さなモジュールなど、個々の分離されたコードユニットをテストすることに焦点を当てます。目標は、各ユニットが、依存関係をモックしながら、孤立して正しく機能することを確認することです。
- 統合テスト (Integration Test): アプリケーションのさまざまな部分やモジュールがどのように連携して機能するかをテストすることに焦点を当てます。これは通常、複数のコンポーネントが相互作用し、データベース、API、またはその他のサービスが含まれる可能性があり、それらがシームレスに統合され、期待どおりの結果を生成することを確認します。
- ハンドラ (Handler): Webフレームワーク(例: Actix Web, Axum, Warp)のコンテキストでは、ハンドラは受信HTTPリクエストを処理し、HTTPレスポンスを返す関数またはメソッドです。これは、特定のAPIエンドポイントのエントリーポイントです。
- サービス (Service) またはビジネスロジック (Business Logic): このレイヤーは、アプリケーションのコアビジネスルールと操作をカプセル化します。ハンドラはサービスを呼び出して複雑なタスクを実行したり、データベースと対話したり、外部システムと通信したりします。サービスは通常、Webフレームワーク自体から独立して設計されます。
- モッキング (Mocking): テスト中に、実際の依存関係(例: データベース接続、外部APIクライアント)を制御された代替品に置き換えることです。モックを使用すると、テスト対象のユニットを分離し、実際の外部リソースに依存することなくさまざまなシナリオをシミュレートできます。
- スタビング (Stubbing): モッキングに似ていますが、通常は、相互作用を検証することなく、メソッド呼び出しに対して事前にプログラムされた応答を提供することを指します。
- フィクスチャ (Fixture): テストのベースラインとして使用される固定状態またはデータです。テストが一貫した予測可能な環境で実行されることを保証します。
ハンドラとサービスの単体テスト
単体テストは、分離と速度を目的としています。ハンドラとサービスの場合、これはWebサーバーや外部リソースから独立してそれらのロジックをテストすることを意味します。
サービスの単体テスト
サービスはしばしば最も複雑なビジネスロジックを含んでおり、徹底的な単体テストの主要な候補となります。サービスはフレームワークに依存しないように設計されるべきなので、それらのテストは通常簡単です。
UserRepository
トレイトとやり取りするシンプルなUserService
を考えてみましょう。
// src/user_service.rs pub struct User { pub id: u32, pub name: String, pub email: String, } #[derive(Debug, PartialEq)] pub enum ServiceError { UserNotFound, DatabaseError(String), } // トレイトとしてリポジトリを定義し、モック可能にする pub trait UserRepository: Send + Sync + 'static { fn get_user_by_id(&self, id: u32) -> Result<Option<User>, String>; fn create_user(&self, name: String, email: String) -> Result<User, String>; } pub struct UserService<R: UserRepository> { repository: R, } impl<R: UserRepository> UserService<R> { pub fn new(repository: R) -> Self { UserService { repository } } pub fn fetch_user_details(&self, user_id: u32) -> Result<User, ServiceError> { match self.repository.get_user_by_id(user_id) { Ok(Some(user)) => Ok(user), Ok(None) => Err(ServiceError::UserNotFound), Err(e) => Err(ServiceError::DatabaseError(e)), } } pub fn register_new_user(&self, name: String, email: String) -> Result<User, ServiceError> { if name.is_empty() || email.is_empty() { return Err(ServiceError::DatabaseError("Name or email cannot be empty".to_string())); } match self.repository.create_user(name, email) { Ok(user) => Ok(user), Err(e) => Err(ServiceError::DatabaseError(e)), } } }
次に、UserService
の単体テストを、モックUserRepository
を作成して行いましょう。より高度なモッキングにはmockall
のようなクレートを使用できますが、ここでは説明のために単純なモックを手動で実装します。
// src/user_service.rs (続き) または src/tests/user_service_test.rs #[cfg(test)] mod tests { use super::*; // UserRepositoryの単純な手動モック struct MockUserRepository { // 事前定義された結果または動的な動作のためのクロージャを格納できます get_user_by_id_result: Option<Result<Option<User>, String>>, create_user_result: Option<Result<User, String>>, } impl MockUserRepository { fn new() -> Self { MockUserRepository { get_user_by_id_result: None, create_user_result: None, } } fn expect_get_user_by_id(mut self, result: Result<Option<User>, String>) -> Self { self.get_user_by_id_result = Some(result); self } fn expect_create_user(mut self, result: Result<User, String>) -> Self { self.create_user_result = Some(result); self } } impl UserRepository for MockUserRepository { fn get_user_by_id(&self, id: u32) -> Result<Option<User>, String> { self.get_user_by_id_result .clone() .unwrap_or_else(|| panic!("get_user_by_id not mocked for id {}", id)) } fn create_user(&self, name: String, email: String) -> Result<User, String> { self.create_user_result .clone() .unwrap_or_else(|| panic!("create_user not mocked for name {} email {}", name, email)) } } #[test] fn test_fetch_user_details_success() { let expected_user = User { id: 1, name: "Alice".to_string(), email: "alice@example.com".to_string(), }; let mock_repo = MockUserRepository::new() .expect_get_user_by_id(Ok(Some(expected_user.clone()))); let user_service = UserService::new(mock_repo); let result = user_service.fetch_user_details(1); assert!(result.is_ok()); assert_eq!(result.unwrap(), expected_user); } #[test] fn test_fetch_user_details_not_found() { let mock_repo = MockUserRepository::new() .expect_get_user_by_id(Ok(None)); let user_service = UserService::new(mock_repo); let result = user_service.fetch_user_details(2); assert!(result.is_err()); assert_eq!(result.unwrap_err(), ServiceError::UserNotFound); } #[test] fn test_register_new_user_success() { let expected_user = User { id: 100, name: "Bob".to_string(), email: "bob@example.com".to_string(), }; let mock_repo = MockUserRepository::new() .expect_create_user(Ok(expected_user.clone())); let user_service = UserService::new(mock_repo); let result = user_service.register_new_user("Bob".to_string(), "bob@example.com".to_string()); assert!(result.is_ok()); assert_eq!(result.unwrap(), expected_user); } #[test] fn test_register_new_user_empty_input() { let mock_repo = MockUserRepository::new(); // create_user が呼び出されない場合はモック不要 let user_service = UserService::new(mock_repo); let result = user_service.register_new_user("".to_string(), "bob@example.com".to_string()); assert!(result.is_err()); assert_eq!(result.unwrap_err(), ServiceError::DatabaseError("Name or email cannot be empty".to_string())); } }
この手動モックアプローチは、依存関係を制御してサービスロジックを分離してテストする方法を明確に示しています。より複雑なシナリオでは、mockall
のようなクレートはトレイトからモック実装を自動的に生成でき、ボイラープレートコードを削減します。
ハンドラの単体テスト
ハンドラは、Webフレームワーク固有のコンテキスト(例: リクエストオブジェクト、JSONボディ用のエクストラクタ、パスパラメータ)に依存することが多いため、単体テストは少しトリッキーです。ここでの目標は、実際のWebサーバーを起動することなく、ハンドラの要求解析、エラー処理、および基盤となるサービスの正しい呼び出しをテストすることです。
Actix Webのハンドラを想定してみましょう。
// src/api_handler.rs extern crate actix_web; use actix_web::{web, HttpResponse, Responder}; use serde::{Deserialize, Serialize}; use crate::user_service::{ServiceError, User, UserService, UserRepository}; #[derive(Serialize)] pub struct UserResponse { id: u32, name: String, email: String, } impl From<User> for UserResponse { fn from(user: User) -> Self { UserResponse { id: user.id, name: user.name, email: user.email, } } } pub async fn get_user_handler<R: UserRepository>( path: web::Path<u32>, user_service: web::Data<UserService<R>>, ) -> impl Responder { let user_id = path.into_inner(); match user_service.fetch_user_details(user_id) { Ok(user) => HttpResponse::Ok().json(UserResponse::from(user)), Err(ServiceError::UserNotFound) => HttpResponse::NotFound().body("User not found"), Err(ServiceError::DatabaseError(e)) => HttpResponse::InternalServerError().body(e), } } #[derive(Deserialize)] pub struct CreateUserRequest { pub name: String, pub email: String, } pub async fn create_user_handler<R: UserRepository>( user_data: web::Json<CreateUserRequest>, user_service: web::Data<UserService<R>>, ) -> impl Responder { match user_service.register_new_user(user_data.name.clone(), user_data.email.clone()) { Ok(user) => HttpResponse::Created().json(UserResponse::from(user)), Err(ServiceError::DatabaseError(e)) => HttpResponse::InternalServerError().body(e), // UserNotFound は register_new_user からは期待されない、内部エラーとして処理 Err(ServiceError::UserNotFound) => HttpResponse::InternalServerError().body("Unexpected service error"), } }
これらのハンドラを単体テストするには、Actix Webが通常提供するweb::Path
、web::Json
、web::Data
の同等物を手動で構築する必要があります。
// src/api_handler.rs (続き) または src/tests/api_handler_test.rs #[cfg(test)] mod handler_tests { use super::*; use actix_web::{test, web::Bytes}; use crate::user_service::tests::MockUserRepository; // サービステストからモックを使用 // レスポンスからJSONを抽出するヘルパー async fn get_json_body<T: for<'de> Deserialize<'de>>(response: HttpResponse) -> T { let response_body = test::read_body(response).await; serde_json::from_slice(&response_body).expect("Failed to deserialize response body") } #[actix_web::test] async fn test_get_user_handler_success() { let expected_user = User { id: 1, name: "Alice".to_string(), email: "alice@example.com".to_string(), }; let mock_repo = MockUserRepository::new() .expect_get_user_by_id(Ok(Some(expected_user.clone()))); let user_service = web::Data::new(UserService::new(mock_repo)); let resp = get_user_handler(web::Path::from(1), user_service).await; assert_eq!(resp.status(), 200); let user_resp: UserResponse = get_json_body(resp).await; assert_eq!(user_resp.id, expected_user.id); assert_eq!(user_resp.name, expected_user.name); } #[actix_web::test] async fn test_get_user_handler_not_found() { let mock_repo = MockUserRepository::new() .expect_get_user_by_id(Ok(None)); let user_service = web::Data::new(UserService::new(mock_repo)); let resp = get_user_handler(web::Path::from(99), user_service).await; assert_eq!(resp.status(), 404); let body = test::read_body(resp).await; assert_eq!(body, Bytes::from_static(b"User not found")); } #[actix_web::test] async fn test_create_user_handler_success() { let new_user_req = CreateUserRequest { name: "Bob".to_string(), email: "bob@example.com".to_string(), }; let expected_user = User { id: 2, name: "Bob".to_string(), email: "bob@example.com".to_string(), }; let mock_repo = MockUserRepository::new() .expect_create_user(Ok(expected_user.clone())); let user_service = web::Data::new(UserService::new(mock_repo)); let resp = create_user_handler(web::Json(new_user_req), user_service).await; assert_eq!(resp.status(), 201); // Created let user_resp: UserResponse = get_json_body(resp).await; assert_eq!(user_resp.id, expected_user.id); assert_eq!(user_resp.name, expected_user.name); } }
このアプローチにより、リクエスト解析、サービス連携、レスポンス生成のロジックが正しいことを確認しながら、実際のデータベース呼び出しやネットワークリクエストなしで、モックされたサービスでハンドラをテストできます。#[actix_web::test]
の使用に注意してください。これは、非同期テストのためにActix Webランタイムを提供します。
ハンドラとサービスの統合テスト
統合テストは、ハンドラ、サービス、および場合によってはデータベースを含むアプリケーションのさまざまなコンポーネントが期待どおりに連携して機能することを検証します。Webアプリケーションの場合、これは通常、アプリケーションの軽量インスタンスを起動し、それに実際のHTTPリクエストを行うことを意味します。
Actix Webの場合、actix_web::test
モジュールはこのプロセスを簡素化するユーティリティを提供します。AxumやWarpなどの他のフレームワークについても、同様のテストユーティリティが存在するか、手動でラップできます。
真のエンドツーエンド統合テストには、実際のデータベースが必要です。開発およびテスト目的では、SQLiteのようなインメモリデータベース、またはPostgreSQL/MySQLのテストコンテナが理想的です。ここでは、UserRepository
トレイトを実装するPostgresRepository
を想定します。
// src/pg_repository.rs (例として簡略化) use async_trait::async_trait; use sqlx::{PgPool, Row}; use crate::user_service::{User, UserRepository}; pub struct PostgresRepository { pool: PgPool, } impl PostgresRepository { pub fn new(pool: PgPool) -> Self { PostgresRepository { pool } } } // async_trait を使用して、以前に定義したトレイトを実装 #[async_trait] impl UserRepository for PostgresRepository { fn get_user_by_id(&self, id: u32) -> Result<Option<User>, String> { // 実際の非同期トレイトでは、これは `async fn` になります。 // 単純化のため、ここでは同期的なトレイトシグネチャを維持しましたが、 // 通常はここで SQL 操作を await します。 // Async DB での実際の統合のためには、トレイトを調整する必要があります。 // より良いアプローチは、トレイトを `async fn get_user_by_id(&self, id: u32) -> Result<Option<User>, sqlx::Error>` // のようにすることです。そして、そのエラーを ServiceError の `String` にマッピングします。 // この例では、簡略化のために `sqlx` が同期であるかのように、またはトレイトを適合させます。 // 実際のコードは、`async fn` 内で次のようなものになります。 // let user = sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE id = $1", id as i32) // .fetch_optional(&self.pool) // .await // .map_err(|e| e.to_string())?; // Ok(user) // とりあえず、ダミーデータまたは非同期トレイトコンテキストでのモックを返します // これは単純化です。完全な非同期 UserRepository トレイトの方が良いでしょう。 if id == 1 { Ok(Some(User { id: 1, name: "Integration Alice".to_string(), email: "inta@example.com".to_string() })) } else { Ok(None) } } fn create_user(&self, name: String, email: String) -> Result<User, String> { // 非同期 DB 操作の同様の単純化 // 例: // sqlx::query!("INSERT INTO users (name, email) VALUES ($1, $2)", name, email) // .execute(&self.pool) // .await // .map_err(|e| e.to_string())?; // Ok(User { id: 100, name, email }) // DB から実際の ID を取得 Ok(User { id: 100, name, email }) } } // アプリケーションのエントリーポイント // src/main.rs use actix_web::{App, HttpServer}; use crate::pg_repository::PostgresRepository; // 実際の PG リポジトリを想定 use crate::user_service::UserService; #[actix_web::main] async fn main() -> std::io::Result<()> { // 実際のデータベースプールを設定 let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let pool = PgPool::connect(&database_url) .await .expect("Failed to connect to Postgres."); // 実際のサービスを実際のレポジトリで作成 let user_svc = UserService::new(PostgresRepository::new(pool.clone())); HttpServer::new(move || { App::new() .app_data(web::Data::new(user_svc.clone())) // サービスを app_data として渡す .service(web::resource("/users/{id}").to(api_handler::get_user_handler::<PostgresRepository>)) .service(web::resource("/users").post(api_handler::create_user_handler::<PostgresRepository>)) }) .bind(("127.0.0.1", 8080))?. run() .await }
次に、アプリケーションの統合テストを記述します。Actix Webのテストユーティリティを使用してテストサーバーを作成し、それにリクエストを送信します。
// src/tests/integration_tests.rs #[cfg(test)] mod integration_tests { use actix_web::{test, web, App, HttpResponse, http::StatusCode}; use serde_json::json; use crate::{ api_handler::{self, CreateUserRequest, UserResponse}, PgRepository, // 実際のレポジトリ user_service::UserService, }; use sqlx::PgPool; // テストサーバーをセットアップするヘルパー async fn setup_test_app(pool: PgPool) -> actix_web::App<impl actix_web::dev::ServiceFactory> { let user_repo = PgRepository::new(pool); let user_service = UserService::new(user_repo); App::new() .app_data(web::Data::new(user_service)) .service(web::resource("/users/{id}").to(api_handler::get_user_handler::<PgRepository>)) .service(web::resource("/users").post(api_handler::create_user_handler::<PgRepository>)) } // 実際には、ここでテストデータベースをセットアップします(例: Dockerコンテナ経由、またはインメモリSQLite)。 // この例では、ダミープールを使用するか、テストDBが実行されていることを前提として、簡略化します。 // トランザクショナルなテストデータベースを設定するのが理想的です: // func setup_test_db() -> PgPool { ... テストDBを作成し、マイグレーションを実行し、プールを返します ... } // 各テストの前にこれを呼び出すか、すべてのテストのために一度呼び出し、クリーンアップします。 #[actix_web::test] async fn test_get_user_integration() { // これは実際のデータベースプールのプレースホルダーです。 // 実際のテストでは、ここでテストデータベースに接続します。 let pool = PgPool::connect("postgresql://user:password@localhost/test_db") .await .expect("Failed to connect to test database"); // 既存のデータをクリーンアップするか、フィクスチャデータを挿入します(トランザクショナルがより良い)。 sqlx::query!("INSERT INTO users (id, name, email) VALUES (1, 'Alice', 'alice@example.com') ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, email = EXCLUDED.email") .execute(&pool) .await .expect("Failed to insert test user"); let app = test::init_service(setup_test_app(pool.clone()).await).await; let req = test::TestRequest::get().uri("/users/1").to_request(); let resp = test::call_service(&app, req).await; assert_eq!(resp.status(), StatusCode::OK); let user_response: UserResponse = test::read_body_json(resp).await; assert_eq!(user_response.id, 1); assert_eq!(user_response.name, "Alice"); assert_eq!(user_response.email, "alice@example.com"); // トランザクショナルテストを使用しない場合は、テストデータをクリーンアップします sqlx::query!("DELETE FROM users WHERE id = 1") .execute(&pool) .await .expect("Failed to delete test user"); } #[actix_web::test] async fn test_create_user_integration() { let pool = PgPool::connect("postgresql://user:password@localhost/test_db") .await .expect("Failed to connect to test database"); let app = test::init_service(setup_test_app(pool.clone()).await).await; let new_user = CreateUserRequest { name: "Bob".to_string(), email: "bob@example.com".to_string(), }; let req = test::TestRequest::post() .uri("/users") .set_json(&new_user) .to_request(); let resp = test::call_service(&app, req).await; assert_eq!(resp.status(), StatusCode::CREATED); let user_response: UserResponse = test::read_body_json(resp).await; assert_eq!(user_response.name, "Bob"); assert_eq!(user_response.email, "bob@example.com"); // 実際にデータベースに存在することを確認します let user_in_db: User = sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE name = $1", "Bob") .fetch_one(&pool) .await .expect("Failed to fetch user from DB after creation"); assert_eq!(user_in_db.name, "Bob"); // テストデータをクリーンアップします sqlx::query!("DELETE FROM users WHERE id = $1", user_in_db.id as i32) .execute(&pool) .await .expect("Failed to delete created test user"); } }
本格的なデータベース統合テストのために、テスト実行ごとにクリーンな状態を保証する(またはテストスイートごとに)テストデータベース(または管理されるコンテナインスタンス)の使用を強くお勧めします。testcontainers-rs
のようなツールは、統合テストのデータベースコンテナを管理するのに役立ちます。さらに、各テストでデータベーストランザクションを使用すると、簡単なロールバックが可能になり、テストがデータベース状態を汚染しないことが保証されます。
結論: 包括的なテストによる信頼性の構築
Rust Webアプリケーションでハンドラとサービスを効果的に単体テストおよび統合テストすることは、信頼性が高く、スケーラブルで、保守性の高いシステムを開発するために不可欠です。単体テストは、個々のコンポーネントの段階的な検証を提供し、依存関係のモッキングを通じて速度と正確な障害検出を提供します。一方、統合テストは、これらのコンポーネント間の重要な対話を検証し、永続化レイヤーを含め、アプリケーション全体が正しく機能することを確認します。これらの明確でありながら補完的なテスト戦略を採用することにより、開発者はコードの周りに堅牢なセーフティネットを構築し、Rust Webアプリケーションに対する自信を育むことができます。