Rust의 단위 테스트, 통합 테스트, API 테스트, testcontainers를 활용한 DB 테스트, 프로퍼티 기반 테스트, 벤치마킹까지 다룹니다.
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]; // 패닉 발생
}
}# 모든 테스트 실행
cargo test
# 특정 테스트만 실행
cargo test test_add
# 특정 모듈의 테스트만 실행
cargo test tests::
# 출력 표시 (println! 등)
cargo test -- --nocapture| 매크로 | 용도 |
|---|---|
assert!(expr) | 표현식이 true인지 확인 |
assert_eq!(a, b) | 두 값이 같은지 확인 |
assert_ne!(a, b) | 두 값이 다른지 확인 |
assert!(result.is_ok()) | Result가 Ok인지 확인 |
assert!(result.is_err()) | Result가 Err인지 확인 |
백엔드에서 가장 중요한 테스트는 비즈니스 로직의 단위 테스트입니다.
#[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] 매크로를 사용합니다.
#[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/ 디렉토리에 별도 파일로 작성합니다. 각 파일이 독립적인 크레이트로 컴파일됩니다.
tests/
api/
mod.rs
users_test.rs
posts_test.rs
common/
mod.rs # 테스트 유틸리티
health_test.rsuse 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();
}
}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를 사용하면 테스트마다 격리된 Docker 컨테이너에서 실제 데이터베이스를 실행할 수 있습니다.
[dev-dependencies]
testcontainers = "0.23"
testcontainers-modules = { version = "0.11", features = ["postgres"] }#[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 시 자동으로 정리됨
}
}testcontainers는 테스트 간 완벽한 격리를 보장합니다. 각 테스트가 독립적인 데이터베이스를 사용하므로 병렬 실행이 가능하고 테스트 간 간섭이 없습니다. 다만 Docker가 실행 중이어야 하며, 컨테이너 시작 시간(보통 1-3초)이 추가됩니다.
**프로퍼티 기반 테스트(Property-Based Testing)**는 구체적인 입력값 대신 **속성(property)**을 정의하고, 테스트 프레임워크가 다양한 입력을 자동 생성하여 속성이 항상 성립하는지 검증합니다.
[dev-dependencies]
proptest = "1"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());
}
}프로퍼티 기반 테스트는 개발자가 미처 생각하지 못한 엣지 케이스를 발견하는 데 매우 효과적입니다. 직렬화/역직렬화 라운드트립 테스트에도 유용합니다.
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 크레이트로 코드의 성능을 정밀하게 측정합니다.
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports", "async_tokio"] }
[[bench]]
name = "api_bench"
harness = falseuse 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);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]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 -- --checkCI에서는 cargo clippy -- -D warnings로 린트 경고를 에러로 처리하고, cargo fmt -- --check로 포맷팅을 검증하는 것이 좋습니다. 이 두 명령은 코드 품질의 기본 게이트 역할을 합니다.
이 장에서는 Rust의 테스트와 품질 보증 도구를 다루었습니다.
#[test]와 #[tokio::test]로 동기/비동기 테스트를 작성합니다tests/ 디렉토리에 별도 파일로 분리합니다다음 장에서는 CLI 도구 개발을 다룹니다. Clap을 사용한 서브커맨드 아키텍처, 구조화 로깅, 설정 관리 등 실전 CLI 패턴을 익힙니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
Clap v4 서브커맨드 아키텍처, config-rs 설정 관리, tracing 구조화 로깅, indicatif 진행 표시, 크로스 플랫폼 빌드와 배포까지 다룹니다.
SQLx의 컴파일 타임 쿼리 검증, 연결 풀, CRUD 구현, 마이그레이션, 트랜잭션까지 Rust 백엔드의 데이터베이스 연동 패턴을 다룹니다.
wasm-pack, wasm-bindgen, wasm32-wasi 타겟, Spin 서버리스, 브라우저 통합, 서버와 Wasm 간 비즈니스 로직 공유, 크기 최적화까지 다룹니다.