Rust 웹 애플리케이션을 위한 테스트 전략
James Reed
Infrastructure Engineer · Leapcell

Rust 웹 애플리케이션의 견고성 보장
Rust로 웹 애플리케이션을 개발하는 것은 타의 추종을 불허하는 성능, 메모리 안전성 및 동시성을 제공합니다. 그러나 매우 안정적이고 유지보수 가능한 애플리케이션을 구축하는 것은 기능적 코드를 작성하는 것 이상을 의미합니다. 종합적인 테스트는 종종 간과되거나 잘못 구현되는 중요한 측면입니다. 애플리케이션이 끊임없이 발전하고 다양한 외부 구성 요소와 상호 작용하는 웹 개발의 동적인 세계에서, 강력한 테스트 전략은 단순한 모범 사례가 아니라 필수 요소입니다. 이는 리팩토링, 새 기능 배포 및 변경 사항이 회귀를 도입하지 않았음을 확신시켜 줍니다. 이 글에서는 Rust 웹 애플리케이션 내에서 핸들러 및 서비스의 단위 및 통합 테스트에 대한 효과적인 방법론을 살펴보고, 보다 탄력적인 시스템을 구축하는 데 필요한 도구와 지식을 제공합니다.
테스트의 핵심 개념
실용적인 내용으로 들어가기 전에, 우리의 논의 전반에 걸쳐 자주 등장할 몇 가지 중요한 테스트 용어에 대한 공통된 이해를 확립해 봅시다.
- 단위 테스트 (Unit Test): 단일 함수, 메서드 또는 작은 모듈과 같은 개별 코드 단위를 테스트하는 데 중점을 둡니다. 목표는 각 단위가 종종 의존성을 모킹하여 격리하여 올바르게 작동하는지 확인하는 것입니다.
- 통합 테스트 (Integration Test): 애플리케이션의 다른 부분이나 모듈이 함께 작동하는 방식을 테스트하는 데 중점을 둡니다. 이는 일반적으로 여러 구성 요소가 데이터베이스, API 또는 기타 서비스를 포함하여 매끄럽게 통합되고 예상되는 결과를 생성하는지 확인하기 위해 상호 작용하는 것을 포함합니다.
- 핸들러 (Handler): 웹 프레임워크(예: Actix Web, Axum, Warp)의 맥락에서 핸들러는 들어오는 HTTP 요청을 처리하고 HTTP 응답을 반환하는 함수 또는 메서드입니다. 특정 API 엔드포인트에 대한 진입점입니다.
- 서비스 (Service) 또는 비즈니스 로직 (Business Logic): 이 계층은 애플리케이션의 핵심 비즈니스 규칙 및 운영을 캡슐화합니다. 핸들러는 복잡한 작업을 수행하거나, 데이터베이스와 상호 작용하거나, 외부 시스템과 통신하기 위해 서비스를 호출합니다. 서비스는 일반적으로 자체 웹 프레임워크와 독립적으로 설계됩니다.
- 모킹 (Mocking): 테스트 중에 제어 가능한 대체 객체로 실제 의존성(예: 데이터베이스 연결, 외부 API 클라이언트)을 대체하는 것입니다. 모크를 사용하면 테스트 중인 단위를 격리하고 실제 외부 리소스에 의존하지 않고 다양한 시나리오를 시뮬레이션할 수 있습니다.
- 스터빙 (Stubbing): 모킹과 유사하지만, 일반적으로 상호 작용을 확인하지 않고 메서드 호출에 사전 프로그래밍된 응답을 제공하는 것을 의미합니다.
- 픽스처 (Fixture): 테스트의 기준으로 사용되는 고정된 상태 또는 데이터입니다. 테스트가 일관되고 예측 가능한 환경에서 실행되도록 합니다.
핸들러 및 서비스의 단위 테스트
단위 테스트는 격리와 속도를 목표로 합니다. 핸들러 및 서비스의 경우, 이는 웹 서버 또는 외부 리소스와의 독립적인 로직 테스트를 의미합니다.
서비스 단위 테스트
서비스는 종종 가장 복잡한 비즈니스 로직을 포함하므로 철저한 단위 테스트에 가장 적합합니다. 서비스는 프레임워크에 구애받지 않도록 설계되어야 하므로 테스트는 일반적으로 간단합니다.
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)), } } }
이제 User
대한 단위 테스트를 작성해 봅시다. UserRepository
에 대한 모의 구현을 만듭니다. 더 정교한 모킹을 위해 mockall
과 같은 크레이트를 사용할 수 있습니다. 또는 명확성을 위해 간단한 모의 구현을 수동으로 구현할 수 있습니다.
// src/user_service.rs (계속) 또는 src/tests/user_service_test.rs #[cfg(test)] mod tests { use super::*; 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(); 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
과 같은 크레이트는 트레이트에서 모의 구현을 자동으로 생성하여 상용구 코드를 줄여줍니다.
핸들러 단위 테스트
핸들러는 웹 프레임워크별 컨텍스트(예: 요청 객체, JSON 본문에 대한 추출기, 경로 매개변수)에 종종 의존하므로 단위 테스트에서는 좀 더 까다로울 수 있습니다. 여기서 목표는 실제 웹 서버를 시작하지 않고 핸들러의 요청 구문 분석, 오류 처리 및 기본 서비스의 올바른 호출을 테스트하는 것입니다.
// 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), 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; 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); 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 런타임을 제공합니다.
핸들러 및 서비스 통합 테스트
통합 테스트는 핸들러, 서비스 및 데이터베이스를 포함한 애플리케이션의 다른 구성 요소가 함께 어떻게 작동하는지 확인합니다. 웹 애플리케이션의 경우, 이는 일반적으로 애플리케이션의 경량 인스턴스를 시작하고 실제 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] impl UserRepository for PostgresRepository { fn get_user_by_id(&self, id: u32) -> Result<Option<User>, String> { 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> { Ok(User { id: 100, name, email }) } } // src/main.rs use actix_web::{App, HttpServer, web}; use crate::pg_repository::PostgresRepository; 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())) .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 }
이제 우리 애플리케이션에 대한 통합 테스트를 작성해 봅시다. 테스트 서버를 만들고 실제 HTTP 요청을 보내기 위해 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>)) } #[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 DO NOTHING") .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"); 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"); } }
적절한 데이터베이스 통합 테스트를 위해서는 각 테스트 실행 또는 테스트 스위트에 대한 깨끗한 상태를 보장하는 테스트 데이터베이스(또는 업/다운 되는 컨테이너화된 인스턴스)를 사용하는 것이 좋습니다. 또한 각 테스트에 데이터베이스 트랜잭션을 사용하면 롤백이 쉬워져 테스트가 후속 실행에 대한 데이터베이스 상태를 오염시키지 않습니다.
결론: 포괄적인 테스트로 자신감 구축
Rust 웹 애플리케이션에서 핸들러 및 서비스를 효과적으로 단위 및 통합 테스트하는 것은 안정적이고 확장 가능하며 유지보수 가능한 시스템을 개발하는 데 매우 중요합니다. 단위 테스트는 의존성 모킹을 통해 개별 구성 요소의 미세한 검증을 제공하고 신속한 오류 감지를 제공합니다. 반면 통합 테스트는 이러한 구성 요소 간의 중요한 상호 작용(영속성 계층 포함)을 검증하여 애플리케이션이 전체적으로 올바르게 작동하도록 합니다. 이러한 독특하지만 상호 보완적인 테스트 전략을 채택함으로써 개발자는 코드 주위에 강력한 안전망을 구축하여 Rust 웹 애플리케이션에 대한 확신을 키울 수 있습니다.