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()
는testcontainers
Docker 클라이언트를 초기화합니다. - 컨테이너 생성:
docker.run(Postgres::default())
는testcontainers
에postgres
Docker 이미지를 풀링(아직 없는 경우)하고 이를 기반으로 새 컨테이너를 시작하도록 지시합니다. 그런 다음 컨테이너가 준비될 때까지(예: 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 애플리케이션 개발이 더욱 강력하고 효율적이 됩니다.