본문으로 건너뛰기
Kreath Archive
TechProjectsBooksAbout
TechProjectsBooksAbout

내비게이션

  • Tech
  • Projects
  • Books
  • About
  • Tags

카테고리

  • AI / ML
  • 웹 개발
  • 프로그래밍
  • 개발 도구

연결

  • GitHub
  • Email
  • RSS
© 2026 Kreath Archive. All rights reserved.Built with Next.js + MDX
홈TechProjectsBooksAbout
//
  1. 홈
  2. 테크
  3. 8장: 테스트와 품질 보증
2026년 2월 25일·프로그래밍·

8장: 테스트와 품질 보증

Rust의 단위 테스트, 통합 테스트, API 테스트, testcontainers를 활용한 DB 테스트, 프로퍼티 기반 테스트, 벤치마킹까지 다룹니다.

12분1,136자10개 섹션
rust
공유
rust-backend8 / 11
1234567891011
이전7장: SQLx 데이터베이스 연동다음9장: CLI 도구 개발

학습 목표

  • Rust의 내장 테스트 프레임워크를 활용합니다
  • 단위 테스트와 통합 테스트의 구조를 이해합니다
  • testcontainers로 실제 DB를 사용한 테스트를 작성합니다
  • 프로퍼티 기반 테스트와 벤치마킹을 적용합니다

Rust 테스트 기초

Rust는 언어 수준에서 테스트를 지원합니다. 별도의 테스트 프레임워크를 설치할 필요가 없습니다.

기본 단위 테스트
rust
// src/lib.rs 또는 모든 소스 파일
 
fn add(a: i32, b: i32) -> i32 {
    a + b
}
 
fn divide(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err("0으로 나눌 수 없습니다".to_string())
    } else {
        Ok(a / b)
    }
}
 
#[cfg(test)]
mod tests {
    use super::*;
 
    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
        assert_eq!(add(-1, 1), 0);
    }
 
    #[test]
    fn test_divide_success() {
        let result = divide(10.0, 3.0).unwrap();
        assert!((result - 3.333).abs() < 0.001);
    }
 
    #[test]
    fn test_divide_by_zero() {
        let result = divide(10.0, 0.0);
        assert!(result.is_err());
        assert_eq!(result.unwrap_err(), "0으로 나눌 수 없습니다");
    }
 
    #[test]
    #[should_panic(expected = "index out of bounds")]
    fn test_panic() {
        let v = vec![1, 2, 3];
        let _ = v[10]; // 패닉 발생
    }
}
테스트 실행
bash
# 모든 테스트 실행
cargo test
 
# 특정 테스트만 실행
cargo test test_add
 
# 특정 모듈의 테스트만 실행
cargo test tests::
 
# 출력 표시 (println! 등)
cargo test -- --nocapture

주요 assert 매크로

매크로용도
assert!(expr)표현식이 true인지 확인
assert_eq!(a, b)두 값이 같은지 확인
assert_ne!(a, b)두 값이 다른지 확인
assert!(result.is_ok())Result가 Ok인지 확인
assert!(result.is_err())Result가 Err인지 확인

비즈니스 로직 단위 테스트

백엔드에서 가장 중요한 테스트는 비즈니스 로직의 단위 테스트입니다.

비즈니스 로직 테스트
rust
#[derive(Debug, Clone, PartialEq)]
struct Money {
    amount: i64,     // 센트 단위
    currency: String,
}
 
impl Money {
    fn new(amount: i64, currency: &str) -> Self {
        Self {
            amount,
            currency: currency.to_string(),
        }
    }
 
    fn add(&self, other: &Money) -> Result<Money, String> {
        if self.currency != other.currency {
            return Err(format!(
                "통화가 다릅니다: {} vs {}",
                self.currency, other.currency
            ));
        }
        Ok(Money::new(self.amount + other.amount, &self.currency))
    }
 
    fn apply_discount(&self, percent: u32) -> Result<Money, String> {
        if percent > 100 {
            return Err("할인율은 100%를 초과할 수 없습니다".to_string());
        }
        let discounted = self.amount - (self.amount * percent as i64 / 100);
        Ok(Money::new(discounted, &self.currency))
    }
}
 
#[cfg(test)]
mod tests {
    use super::*;
 
    #[test]
    fn test_money_add_same_currency() {
        let a = Money::new(1000, "KRW");
        let b = Money::new(2000, "KRW");
        let result = a.add(&b).unwrap();
        assert_eq!(result.amount, 3000);
        assert_eq!(result.currency, "KRW");
    }
 
    #[test]
    fn test_money_add_different_currency() {
        let a = Money::new(1000, "KRW");
        let b = Money::new(100, "USD");
        assert!(a.add(&b).is_err());
    }
 
    #[test]
    fn test_discount_normal() {
        let price = Money::new(10000, "KRW");
        let discounted = price.apply_discount(20).unwrap();
        assert_eq!(discounted.amount, 8000);
    }
 
    #[test]
    fn test_discount_zero() {
        let price = Money::new(10000, "KRW");
        let discounted = price.apply_discount(0).unwrap();
        assert_eq!(discounted.amount, 10000);
    }
 
    #[test]
    fn test_discount_over_100() {
        let price = Money::new(10000, "KRW");
        assert!(price.apply_discount(101).is_err());
    }
}

비동기 테스트

Tokio 기반 비동기 코드를 테스트할 때는 #[tokio::test] 매크로를 사용합니다.

비동기 테스트
rust
#[cfg(test)]
mod tests {
    use super::*;
    use tokio::time::{sleep, Duration};
 
    #[tokio::test]
    async fn test_async_operation() {
        let result = fetch_data().await;
        assert!(result.is_ok());
    }
 
    #[tokio::test]
    async fn test_timeout_behavior() {
        let result = tokio::time::timeout(
            Duration::from_millis(100),
            slow_operation(),
        )
        .await;
 
        // 100ms 안에 완료되지 않으면 타임아웃 에러
        assert!(result.is_err());
    }
 
    async fn slow_operation() {
        sleep(Duration::from_secs(10)).await;
    }
 
    async fn fetch_data() -> Result<String, String> {
        Ok("데이터".to_string())
    }
}

통합 테스트

통합 테스트는 tests/ 디렉토리에 별도 파일로 작성합니다. 각 파일이 독립적인 크레이트로 컴파일됩니다.

통합 테스트 디렉토리 구조
text
tests/
  api/
    mod.rs
    users_test.rs
    posts_test.rs
  common/
    mod.rs          # 테스트 유틸리티
  health_test.rs
tests/common/mod.rs — 테스트 유틸리티
rust
use axum::Router;
use sqlx::PgPool;
use std::sync::Arc;
 
pub struct TestApp {
    pub router: Router,
    pub db: PgPool,
}
 
impl TestApp {
    pub async fn new() -> Self {
        let database_url = std::env::var("TEST_DATABASE_URL")
            .unwrap_or_else(|_| "postgresql://localhost/test_db".to_string());
 
        let db = PgPool::connect(&database_url).await.unwrap();
 
        // 마이그레이션 실행
        sqlx::migrate!("./migrations").run(&db).await.unwrap();
 
        let state = Arc::new(AppState { db: db.clone() });
        let router = create_router(state);
 
        TestApp { router, db }
    }
 
    pub async fn cleanup(&self) {
        // 테스트 데이터 정리
        sqlx::query("TRUNCATE users, posts CASCADE")
            .execute(&self.db)
            .await
            .unwrap();
    }
}
tests/api/users_test.rs
rust
use axum::{body::Body, http::{Request, StatusCode}};
use http_body_util::BodyExt;
use tower::ServiceExt;
 
mod common;
use common::TestApp;
 
#[tokio::test]
async fn test_create_and_get_user() {
    let app = TestApp::new().await;
 
    // 사용자 생성
    let create_response = app.router.clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/api/v1/users")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"name":"Alice","email":"alice@test.com","password":"12345678"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
 
    assert_eq!(create_response.status(), StatusCode::CREATED);
 
    let body = create_response.into_body().collect().await.unwrap().to_bytes();
    let user: serde_json::Value = serde_json::from_slice(&body).unwrap();
    let user_id = user["id"].as_i64().unwrap();
 
    // 생성된 사용자 조회
    let get_response = app.router.clone()
        .oneshot(
            Request::builder()
                .uri(format!("/api/v1/users/{}", user_id))
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
 
    assert_eq!(get_response.status(), StatusCode::OK);
 
    app.cleanup().await;
}

testcontainers로 DB 테스트

testcontainers를 사용하면 테스트마다 격리된 Docker 컨테이너에서 실제 데이터베이스를 실행할 수 있습니다.

Cargo.toml (dev-dependencies)
toml
[dev-dependencies]
testcontainers = "0.23"
testcontainers-modules = { version = "0.11", features = ["postgres"] }
testcontainers를 활용한 DB 테스트
rust
#[cfg(test)]
mod tests {
    use testcontainers::runners::AsyncRunner;
    use testcontainers_modules::postgres::Postgres;
 
    #[tokio::test]
    async fn test_with_real_database() {
        // PostgreSQL 컨테이너 시작
        let container = Postgres::default()
            .start()
            .await
            .expect("PostgreSQL 컨테이너 시작 실패");
 
        let port = container.get_host_port_ipv4(5432).await.unwrap();
        let database_url = format!("postgresql://postgres:postgres@localhost:{}/postgres", port);
 
        // 연결 풀 생성
        let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
 
        // 마이그레이션 실행
        sqlx::migrate!("./migrations").run(&pool).await.unwrap();
 
        // 테스트 실행
        let result = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM users")
            .fetch_one(&pool)
            .await
            .unwrap();
 
        assert_eq!(result.0, 0);
 
        // 컨테이너는 drop 시 자동으로 정리됨
    }
}
Tip

testcontainers는 테스트 간 완벽한 격리를 보장합니다. 각 테스트가 독립적인 데이터베이스를 사용하므로 병렬 실행이 가능하고 테스트 간 간섭이 없습니다. 다만 Docker가 실행 중이어야 하며, 컨테이너 시작 시간(보통 1-3초)이 추가됩니다.

프로퍼티 기반 테스트

**프로퍼티 기반 테스트(Property-Based Testing)**는 구체적인 입력값 대신 **속성(property)**을 정의하고, 테스트 프레임워크가 다양한 입력을 자동 생성하여 속성이 항상 성립하는지 검증합니다.

Cargo.toml (dev-dependencies)
toml
[dev-dependencies]
proptest = "1"
proptest를 활용한 프로퍼티 기반 테스트
rust
use proptest::prelude::*;
 
fn validate_username(name: &str) -> Result<(), String> {
    if name.is_empty() {
        return Err("이름은 비어있을 수 없습니다".to_string());
    }
    if name.len() > 100 {
        return Err("이름은 100자를 초과할 수 없습니다".to_string());
    }
    if name.contains(char::is_control) {
        return Err("제어 문자는 허용되지 않습니다".to_string());
    }
    Ok(())
}
 
proptest! {
    // 유효한 이름은 항상 검증을 통과해야 한다
    #[test]
    fn valid_names_always_pass(name in "[a-zA-Z가-힣]{1,100}") {
        assert!(validate_username(&name).is_ok());
    }
 
    // 빈 문자열은 항상 실패해야 한다
    #[test]
    fn empty_name_always_fails(name in "\\PC{0,0}") {
        let _ = name; // 사용하지 않음
        assert!(validate_username("").is_err());
    }
 
    // 100자 초과는 항상 실패해야 한다
    #[test]
    fn long_names_always_fail(name in "[a-z]{101,200}") {
        assert!(validate_username(&name).is_err());
    }
}

프로퍼티 기반 테스트는 개발자가 미처 생각하지 못한 엣지 케이스를 발견하는 데 매우 효과적입니다. 직렬화/역직렬화 라운드트립 테스트에도 유용합니다.

직렬화 라운드트립 테스트
rust
proptest! {
    #[test]
    fn serialization_roundtrip(
        id in 1..i64::MAX,
        name in "[a-zA-Z]{1,50}",
        amount in 0..1_000_000i64,
    ) {
        let original = User { id, name: name.clone(), amount };
        let json = serde_json::to_string(&original).unwrap();
        let deserialized: User = serde_json::from_str(&json).unwrap();
        assert_eq!(original, deserialized);
    }
}

벤치마킹

criterion 크레이트로 코드의 성능을 정밀하게 측정합니다.

Cargo.toml
toml
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports", "async_tokio"] }
 
[[bench]]
name = "api_bench"
harness = false
benches/api_bench.rs
rust
use criterion::{criterion_group, criterion_main, Criterion};
 
fn benchmark_password_hashing(c: &mut Criterion) {
    c.bench_function("argon2_hash", |b| {
        b.iter(|| {
            hash_password_sync("test_password_12345")
        })
    });
}
 
fn benchmark_json_serialization(c: &mut Criterion) {
    let users: Vec<User> = (0..1000)
        .map(|i| User {
            id: i,
            name: format!("User {}", i),
            email: format!("user{}@example.com", i),
        })
        .collect();
 
    c.bench_function("serialize_1000_users", |b| {
        b.iter(|| {
            serde_json::to_string(&users).unwrap()
        })
    });
}
 
criterion_group!(benches, benchmark_password_hashing, benchmark_json_serialization);
criterion_main!(benches);
벤치마크 실행
bash
cargo bench
 
# 결과 예시:
# argon2_hash           time:   [45.2 ms 45.8 ms 46.5 ms]
# serialize_1000_users  time:   [234.5 us 237.1 us 240.2 us]

CI 통합

.github/workflows/ci.yml
yaml
name: CI
 
on: [push, pull_request]
 
env:
  CARGO_TERM_COLOR: always
 
jobs:
  test:
    runs-on: ubuntu-latest
 
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: test_db
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
 
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
 
      - name: Run migrations
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost/test_db
        run: |
          cargo install sqlx-cli --no-default-features --features postgres
          sqlx migrate run
 
      - name: Run tests
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost/test_db
        run: cargo test --all-features
 
      - name: Run clippy
        run: cargo clippy -- -D warnings
 
      - name: Check formatting
        run: cargo fmt -- --check
Info

CI에서는 cargo clippy -- -D warnings로 린트 경고를 에러로 처리하고, cargo fmt -- --check로 포맷팅을 검증하는 것이 좋습니다. 이 두 명령은 코드 품질의 기본 게이트 역할을 합니다.

정리

이 장에서는 Rust의 테스트와 품질 보증 도구를 다루었습니다.

  • Rust는 언어 수준에서 테스트를 지원하며, #[test]와 #[tokio::test]로 동기/비동기 테스트를 작성합니다
  • 통합 테스트는 tests/ 디렉토리에 별도 파일로 분리합니다
  • testcontainers로 실제 데이터베이스를 사용한 격리된 테스트를 실행합니다
  • proptest로 프로퍼티 기반 테스트를 작성하여 엣지 케이스를 발견합니다
  • criterion으로 성능을 정밀하게 측정합니다

다음 장에서는 CLI 도구 개발을 다룹니다. Clap을 사용한 서브커맨드 아키텍처, 구조화 로깅, 설정 관리 등 실전 CLI 패턴을 익힙니다.

이 글이 도움이 되셨나요?

관련 주제 더 보기

#rust

관련 글

프로그래밍

9장: CLI 도구 개발

Clap v4 서브커맨드 아키텍처, config-rs 설정 관리, tracing 구조화 로깅, indicatif 진행 표시, 크로스 플랫폼 빌드와 배포까지 다룹니다.

2026년 2월 27일·12분
프로그래밍

7장: SQLx 데이터베이스 연동

SQLx의 컴파일 타임 쿼리 검증, 연결 풀, CRUD 구현, 마이그레이션, 트랜잭션까지 Rust 백엔드의 데이터베이스 연동 패턴을 다룹니다.

2026년 2월 23일·11분
프로그래밍

10장: WebAssembly 타겟

wasm-pack, wasm-bindgen, wasm32-wasi 타겟, Spin 서버리스, 브라우저 통합, 서버와 Wasm 간 비즈니스 로직 공유, 크기 최적화까지 다룹니다.

2026년 3월 1일·13분
이전 글7장: SQLx 데이터베이스 연동
다음 글9장: CLI 도구 개발

댓글

목차

약 12분 남음
  • 학습 목표
  • Rust 테스트 기초
    • 주요 assert 매크로
  • 비즈니스 로직 단위 테스트
  • 비동기 테스트
  • 통합 테스트
  • testcontainers로 DB 테스트
  • 프로퍼티 기반 테스트
  • 벤치마킹
  • CI 통합
  • 정리