Ephemeralt 데이터베이스 인스턴스로 Rust 통합 테스트 간소화하기
Emily Parker
Product Engineer · Leapcell

소개
소프트웨어 개발 세계에서 애플리케이션의 견고성과 정확성을 보장하는 것은 무엇보다 중요합니다. 통합 테스트는 특히 데이터베이스와 같은 외부 서비스와 상호 작용할 때 다양한 시스템 구성 요소가 예상대로 함께 작동하는지 확인하는 데 중요한 역할을 합니다. 그러나 여러 통합 테스트에서 데이터베이스 상태를 관리하는 것은 상당한 문제가 될 수 있습니다. 테스트는 종종 잔여 데이터를 남겨 간섭을 일으키고 테스트 결과가 결정론적이지 않게 만듭니다. 각 테스트 스위트에 대해 데이터베이스를 수동으로 설정하고 해제하는 것은 지루하고 오류가 발생하기 쉬워 개발 주기를 상당히 늦춥니다. 이때 Rust용 testcontainers가 등장하여 격리된 데이터베이스 인스턴스를 동적으로 생성하고 삭제하는 우아한 솔루션을 제공하여 통합 테스트 접근 방식을 혁신합니다. 이 문서는 Rust에서 깨끗하고 안정적이며 효율적인 데이터베이스 통합 테스트를 달성하기 위해 testcontainers의 강력한 기능을 활용하는 방법을 탐구합니다.
핵심 개념 및 구현
실제 사례를 살펴보기 전에 논의의 핵심이 되는 몇 가지 주요 용어를 명확히 합시다.
- 통합 테스트: 애플리케이션의 다양한 모듈 또는 서비스가 예상대로 함께 작동하는지 확인하는 소프트웨어 테스트 유형입니다. 이 맥락에서는 종종 애플리케이션과 데이터베이스의 상호 작용을 테스트하는 것을 의미합니다.
- 임시 데이터베이스 인스턴스: 특정 테스트 또는 테스트 세트 실행만을 위해 생성되고 나중에 자동으로 파괴되는 데이터베이스 인스턴스입니다. 이렇게 하면 각 테스트 실행에 대해 깨끗한 상태가 보장됩니다.
testcontainers: Testcontainers for Java 및 Go에서 영감을 받은 Rust 크레이트입니다. Rust 코드에서 Docker 컨테이너를 프로그래밍 방식으로 생성하고 관리할 수 있으므로 테스트 목적으로 데이터베이스, 메시지 큐 등과 같이 격리된 서비스 종속성을 빠르게 시작하는 데 이상적입니다.- Docker: 컨테이너라는 패키지로 소프트웨어를 제공하기 위해 OS 수준 가상화를 사용하는 플랫폼입니다.
testcontainers는 이러한 격리된 서비스 인스턴스를 관리하기 위해 Docker에 의존합니다.
데이터베이스 통합 테스트에 testcontainers를 사용하는 기본 원칙은 데이터베이스를 임시적이고 격리된 리소스로 취급하는 것입니다. 각 테스트 또는 테스트 스위트는 이상적으로 자체 전용 데이터베이스 인스턴스에 대해 실행되어야 합니다. 이렇게 하면 테스트 간의 데이터 오염을 방지하고 복잡한 설정 및 해제 스크립트 또는 데이터 롤백 메커니즘의 필요성을 제거할 수 있습니다.
PostgreSQL 데이터베이스를 사용한 실제 사례를 통해 이를 설명해 보겠습니다. 데이터베이스와 상호 작용하는 간단한 Rust 애플리케이션을 설정한 다음 testcontainers를 사용하여 데이터베이스 수명을 관리하는 통합 테스트를 작성하겠습니다.
먼저 testcontainers가 의존하므로 시스템에 Docker가 설치되어 실행 중인지 확인하세요.
Cargo.toml에 필요한 종속성을 추가합니다.
[dev-dependencies] testcontainers = "0.19.0" # 최신 버전 사용 tokio = { version = "1", features = ["macros", "rt-multi-thread"] } sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "uuid", "chrono"] } uuid = { version = "1.0", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } [dependencies] # 애플리케이션 종속성
이제 src/models.rs에 간단한 데이터베이스 상호 작용 모듈을 만듭니다.
use sqlx::{PgPool, Error}; use uuid::Uuid; use chrono::{DateTime, Utc}; #[derive(Debug, sqlx::FromRow, PartialEq)])] pub struct User { pub id: Uuid, pub name: String, pub email: String, pub created_at: DateTime<Utc>, } pub async fn create_user(pool: &PgPool, name: &str, email: &str) -> Result<User, Error> { let new_user = sqlx::query_as!( User, r#"" INSERT INTO users (id, name, email, created_at) VALUES ($1, $2, $3, $4) RETURNING id, name, email, created_at "#, Uuid::new_v4(), name, email, Utc::now() ) .fetch_one(pool) .await?; Ok(new_user) } pub async fn find_user_by_email(pool: &PgPool, email: &str) -> Result<Option<User>, Error> { let user = sqlx::query_as!( User, r#"" SELECT id, name, email, created_at FROM users WHERE email = $1 "#, email ) .fetch_optional(pool) .await?; Ok(user) }
다음으로 통합 테스트를 작성합니다. tests/integration_test.rs 파일을 만듭니다.
use testcontainers::{clients, images::postgres::Postgres}; use sqlx::{PgPool, Executor}; use tokio; use crate::models::{create_user, find_user_by_email}; // models.rs가 메인 lib/bin에 있다고 가정, 경로 조정 // 테스트 스키마 설정을 위한 헬퍼 함수 async fn setup_db(pool: &PgPool) -> Result<(), sqlx::Error> { pool.execute( r#"" CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY, name VARCHAR NOT NULL, email VARCHAR NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL ); "#, ) .await?; Ok(()) } #[tokio::test] async fn test_user_crud_operations() { // 1. Testcontainers 클라이언트 초기화 let docker = clients::Cli::default(); // 2. PostgreSQL 컨테이너 시작 // 필요한 경우 이미지 버전 또는 기타 매개변수 (예: db_test.Postgres::default().with_tag("13")) 를 사용자 지정할 수 있습니다. let node = docker.run(Postgres::default()); // 3. 데이터베이스 연결 문자열 가져오기 let connection_string = &node.dbc.get_connection_string(); // 4. 데이터베이스에 연결 let pool = PgPool::connect(connection_string) .await .expect("PostgreSQL 연결 실패"); // 5. 이 테스트 인스턴스에 대한 스키마 설정 setup_db(&pool).await.expect("데이터베이스 스키마 설정 실패"); // 6. 테스트 작업 수행 let user_name = "Alice Smith"; let user_email = "alice.smith@example.com"; // 새 사용자 생성 let created_user = create_user(&pool, user_name, user_email) .await .expect("사용자 생성 실패"); assert_eq!(created_user.name, user_name); assert_eq!(created_user.email, user_email); // 이메일로 사용자 찾기 let found_user = find_user_by_email(&pool, user_email) .await .expect("사용자 찾기 실패") .expect("사용자를 찾아야 합니다."); assert_eq!(found_user.id, created_user.id); assert_eq!(found_user.name, created_user.name); assert_eq!(found_user.email, created_user.email); // 중복 이메일로 사용자 생성 시도 let duplicate_result = create_user(&pool, "Bob Johnson", user_email).await; assert!(duplicate_result.is_err()); // 고유 이메일 제약 조건으로 인한 오류 예상 // 7. `node`가 범위를 벗어나면 컨테이너는 자동으로 중지되고 제거됩니다. // 이는 `testcontainers`의 `Drop` 구현에 의해 처리됩니다. }
models 모듈을 통합 테스트에서 사용할 수 있도록 하려면 일반적으로 src/lib.rs가 있어야 하며 테스트는 tests/ 디렉토리에 있어야 합니다.
// src/lib.rs pub mod models; // 다른 모듈
cargo test --color always --tests를 실행하면 다음이 수행됩니다.
- Docker 클라이언트 초기화:
clients::Cli::default()는testcontainersDocker 클라이언트를 초기화합니다. - 컨테이너 생성:
docker.run(Postgres::default())는testcontainers에postgresDocker 이미지를 풀링(아직 없는 경우)하고 이를 기반으로 새 컨테이너를 시작하도록 지시합니다. 그런 다음 컨테이너가 준비될 때까지(예: PostgreSQL이 포트에서 수신 대기 중일 때) 기다립니다. - 연결 문자열:
node.dbc.get_connection_string()은 Docker가 매핑한 임의 포트를 포함하여 실행 중인 PostgreSQL 인스턴스의 동적으로 생성된 연결 문자열을 제공합니다. - 데이터베이스 연결 및 스키마 설정:
sqlx::PgPool::connect는이 임시 데이터베이스에 대한 연결을 설정하고setup_db는 필요한users테이블을 생성합니다. - 테스트 실행: 애플리케이션 논리는이 격리된 데이터베이스와 상호 작용합니다.
- 컨테이너 해제: 중요하게도
node변수(Container인스턴스 보유)가#[tokio::test]함수의 끝에서 범위를 벗어나면testcontainers는 Docker에 컨테이너를 중지하고 제거하라는 신호를 자동으로 보냅니다. 이 정리 작업은 테스트가 성공하든 실패하든 관계없이 발생하여 후속 테스트를 위해 깨끗한 환경을 보장합니다.
이 접근 방식은 몇 가지 상당한 이점을 제공합니다.
- 격리: 각 테스트는 자체 깨끗한 데이터베이스에 대해 실행되어 테스트 간의 간섭을 방지합니다.
- 안정성: 테스트는 이전 실행에서 남겨진 상태에 의존하지 않으므로 더 결정론적으로 됩니다.
- 효율성: 컨테이너를 시작하는 데 시간이 걸리지만 통합 테스트의 경우 오버헤드가 대부분 허용 가능하며 수동 설정/해제보다 훨씬 빠릅니다. Docker의 레이어링 및 캐싱도 도움이 됩니다.
- 단순성: 설정 및 해제 논리는
testcontainers라이브러리에 캡슐화되어 테스트에서 보일러플레이트 코드를 줄입니다. - 재현성: 테스트는 Docker를 사용할 수 있는 모든 곳에서 실행될 수 있어 서로 다른 개발 환경 및 CI/CD 파이프라인에서 일관된 동작을 보장합니다.
동일한 원칙은 MySQL, Redis, Kafka, Elasticsearch 또는 Docker 이미지로 사용할 수 있는 모든 서비스와 같은 다른 서비스에도 적용될 수 있습니다. testcontainers는 광범위한 미리 빌드된 이미지를 제공하거나 GenericImage를 사용하여 사용자 지정 Dockerfile을 사용할 수 있습니다.
결론
Rust에서 testcontainers를 사용하여 통합 테스트를 위해 데이터베이스 인스턴스를 동적으로 생성하고 삭제하는 것은 테스트 스위트의 품질과 유지 관리성을 크게 향상시키는 강력한 기술입니다. 각 테스트가 격리된 임시 데이터베이스에서 작동하도록 보장함으로써 개발자는 더 안정적이고 결정론적이며 디버깅하기 쉬운 통합 테스트를 작성할 수 있습니다. testcontainers를 채택하면 테스트 워크플로가 간소화되어 Rust 애플리케이션 개발이 더욱 강력하고 효율적이 됩니다.