Rust 테스팅 | 단위 테스트, 통합 테스트, 벤치마크
이 글의 핵심
cargo test로 단위·통합 테스트를 바로 돌릴 수 있어, CI에 붙이기도 좋습니다. #[test]와 #[should_panic] 등으로 실패·패닉을 명시적으로 검증할 수 있습니다.
시리즈 안내
#10 | 📋 전체 목차 | 이전: #09 웹 개발 · 다음: #11 CLI 도구
들어가며
cargo test로 단위·통합 테스트를 바로 돌릴 수 있어, CI에 붙이기도 좋습니다. #[test]와 #[should_panic] 등으로 실패·패닉을 명시적으로 검증할 수 있습니다.
단위 테스트는 모든 언어에서 중요합니다. Python에서 pytest·CI, Node.js의 Jest, C++의 Google Test, Go의 go test는 각각의 생태계에서 표준에 가깝습니다. CI/CD·컨테이너 배포와 연결하려면 Node.js GitHub Actions CI/CD와 C++ Docker·배포 이미지를 함께 보세요.
Rust가 다른 언어와 다른 점은 테스트 프레임워크가 언어와 빌드 도구에 내장되어 있다는 것입니다. JavaScript는 Jest·Vitest·Mocha 중 하나를 고르고 설정해야 하고, C++은 Google Test나 Catch2를 빌드 시스템에 연결해야 하지만, Rust는 cargo new로 만든 프로젝트에서 바로 cargo test가 동작합니다. 테스트 코드를 소스 파일 안에 함께 두는 것도 관례입니다. 이 글은 단위 테스트, assert 매크로, 패닉과 Result 테스트, tests/ 디렉터리의 통합 테스트, 파일 시스템을 쓰는 테스트, 그리고 벤치마크까지 순서대로 다루면서 각 기능이 왜 그렇게 설계되었는지와 자주 막히는 지점을 함께 설명합니다.
#[test] 단위 테스트 작성과 실행
기본 테스트
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn subtract(a: i32, b: i32) -> i32 {
a - b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
assert_eq!(add(-2, 3), 1);
assert_eq!(add(0, 0), 0);
}
#[test]
fn test_subtract() {
assert_eq!(subtract(10, 3), 7);
assert_eq!(subtract(5, 10), -5);
}
}
#[cfg(test)]는 “테스트 빌드일 때만 이 모듈을 컴파일하라”는 조건부 컴파일 속성입니다. cargo build로 만든 실제 바이너리에는 tests 모듈이 전혀 포함되지 않으므로, 테스트 코드와 테스트 전용 의존성이 배포 크기나 성능에 영향을 주지 않습니다. use super::*;는 부모 모듈(이 파일의 나머지 부분)의 모든 항목을 가져오는 줄인데, 테스트 모듈은 같은 파일 안의 자식 모듈이라 pub이 아닌 private 함수도 테스트할 수 있습니다. 내부 구현을 직접 검증할 수 있다는 점은 장점이지만, private 함수 테스트가 많아지면 리팩터링할 때마다 테스트도 함께 고쳐야 하는 부담이 생기므로 공개 API 중심으로 테스트하는 것이 균형이 좋습니다.
test_add 하나에 assert_eq!를 세 번 넣었는데, 첫 번째 단언이 실패하면 그 테스트는 거기서 즉시 멈추고 나머지 두 줄은 실행되지 않습니다. 한 번의 실행으로 어떤 입력들이 실패하는지 모두 보고 싶다면 입력마다 테스트를 나누거나, 입력과 기대값 배열을 순회하면서 실패 메시지에 입력값을 포함시키는 방법을 씁니다.
테스트 실행
# 모든 테스트 실행
cargo test
# 특정 테스트만 실행
cargo test test_add
# 출력 보기
cargo test -- --nocapture
# 단일 스레드로 실행
cargo test -- --test-threads=1
cargo test 뒤의 인자는 두 종류로 나뉩니다. -- 앞은 Cargo에 전달되는 옵션이고(--release, --package, --lib), -- 뒤는 컴파일된 테스트 바이너리에 전달되는 옵션입니다(--nocapture, --test-threads, --ignored). 이 구분 때문에 cargo test --nocapture라고 쓰면 unexpected argument '--nocapture' 에러가 납니다.
cargo test test_add의 인자는 정확한 이름이 아니라 부분 문자열 필터입니다. 그래서 test_add와 test_add_negative가 둘 다 실행되고, 단위 테스트와 통합 테스트에 같은 이름이 있으면 양쪽 모두 실행됩니다. 정확히 하나만 돌리려면 cargo test test_add -- --exact를 씁니다. 기본적으로 테스트가 통과하면 println! 출력이 숨겨지는데, 이것은 병렬로 도는 여러 테스트의 출력이 뒤섞이지 않도록 하기 위한 것입니다. 실패한 테스트의 출력만 결과에 표시되고, --nocapture(최근 버전에서는 --show-output도 가능)를 주면 통과한 테스트의 출력까지 볼 수 있습니다.
assert 매크로, should_panic, Result 반환 테스트
기본 assert
#[cfg(test)]
mod tests {
#[test]
fn test_assertions() {
// assert!: 조건이 true인지 확인
assert!(true);
assert!(2 + 2 == 4);
// assert_eq!: 두 값이 같은지
assert_eq!(2 + 2, 4);
assert_eq!("hello".to_uppercase(), "HELLO");
// assert_ne!: 두 값이 다른지
assert_ne!(2 + 2, 5);
}
#[test]
fn test_with_message() {
let x = 10;
assert!(x > 5, "x는 5보다 커야 함, 실제: {}", x);
assert_eq!(x, 10, "x는 10이어야 함");
}
}
assert!(a == b) 대신 assert_eq!(a, b)를 쓰는 이유는 실패 메시지입니다. assert!가 실패하면 “조건이 거짓”이라는 사실만 알려 주지만, assert_eq!는 left: 5, right: 4처럼 양쪽 값을 모두 출력합니다. 그래서 assert_eq!로 비교하는 값은 PartialEq(비교용)와 Debug(출력용)를 모두 구현해야 하고, 직접 만든 구조체라면 #[derive(Debug, PartialEq)]가 필요합니다. 빠뜨리면 'MyStruct' doesn't implement 'Debug' 컴파일 에러가 납니다.
부동소수점은 assert_eq!로 비교하면 안 됩니다. assert_eq!(0.1 + 0.2, 0.3)은 left: 0.30000000000000004, right: 0.3으로 실패합니다. assert!((a - b).abs() < 1e-9)처럼 허용 오차로 비교하거나 approx 크레이트의 assert_relative_eq!를 씁니다. 메시지 인자는 format!과 같은 문법이라 실패했을 때 어떤 입력이었는지 담아 두면 CI 로그만 보고도 원인을 짐작할 수 있습니다. assert!는 테스트뿐 아니라 일반 코드에서도 동작하며 릴리스 빌드에서도 검사하므로, 디버그 빌드에서만 검사하고 싶다면 debug_assert!를 씁니다.
should_panic
fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("0으로 나눌 수 없음");
}
a / b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn test_divide_by_zero() {
divide(10, 0);
}
#[test]
#[should_panic(expected = "0으로 나눌 수 없음")]
fn test_divide_panic_message() {
divide(10, 0);
}
}
expected 없는 #[should_panic]은 어떤 이유로든 패닉하면 통과합니다. 테스트 준비 코드에서 인덱스 범위를 벗어나거나 unwrap()이 실패해도 통과하므로, 검증하려던 패닉이 아닌 엉뚱한 버그를 성공으로 보고할 수 있습니다. 두 번째 테스트처럼 expected로 메시지를 지정하면 이 위험이 줄어듭니다. expected는 부분 문자열 일치라서 메시지 전체를 적지 않아도 되지만, 너무 짧게 적으면(expected = "0") 다시 느슨해집니다.
should_panic이 필요한 경우는 생각보다 적습니다. 이 divide처럼 호출자가 실수할 수 있는 입력에 패닉하는 것은 Rust에서 권장되는 설계가 아니고, 아래 계산기 예제처럼 Result를 반환하면 테스트도 assert!(result.is_err())로 단순해집니다. should_panic은 “이 조건은 프로그램 버그이므로 반드시 패닉해야 한다”는 불변식(예: 인덱스 범위 검사, assert!로 지킨 전제 조건)을 검증할 때 씁니다. 참고로 should_panic 테스트는 Result를 반환하는 형태와 함께 쓸 수 없습니다.
Result를 반환하는 테스트
#[cfg(test)]
mod tests {
#[test]
fn test_with_result() -> Result<(), String> {
if 2 + 2 == 4 {
Ok(())
} else {
Err(String::from("계산 오류"))
}
}
}
테스트가 Result를 반환하면 Err를 돌려줄 때 실패로 처리됩니다. 이 형태의 실제 장점은 예제처럼 직접 Err를 만드는 것이 아니라 ? 연산자를 쓸 수 있다는 점입니다. 파일을 열고, 파싱하고, 결과를 확인하는 테스트에서 매 단계마다 .unwrap()을 붙이는 대신 let data = fs::read_to_string(path)?;처럼 쓸 수 있어 테스트 코드가 제품 코드와 비슷한 모양이 됩니다. 아래 “실전 심화 보강”의 tempfile 예제가 이 방식입니다.
단점은 실패 위치 정보가 줄어든다는 것입니다. unwrap()이 실패하면 패닉 메시지에 파일과 줄 번호가 나오지만, ?로 전파된 에러는 “테스트가 Err를 반환했다”는 사실과 에러 값만 보여 줍니다. 어느 단계에서 실패했는지 중요하다면 anyhow 같은 크레이트로 .context("설정 파일 읽기")를 붙이거나, 핵심 단계만 expect("설정 파일이 있어야 함")으로 쓰는 절충도 흔합니다.
tests/ 디렉토리의 통합 테스트
tests/ 디렉토리
// tests/integration_test.rs
use my_crate;
#[test]
fn test_add() {
let result = my_crate::add(2, 3);
assert_eq!(result, 5);
}
#[test]
fn test_subtract() {
let result = my_crate::subtract(10, 3);
assert_eq!(result, 7);
}
tests/ 디렉터리의 각 파일은 별도의 크레이트로 컴파일되어, 외부 사용자와 똑같은 위치에서 라이브러리를 사용합니다. 그래서 통합 테스트에서는 pub으로 공개된 항목만 쓸 수 있고, 앞의 단위 테스트 예제처럼 fn add가 private이면 function 'add' is private 에러가 납니다. 공개 API가 실제로 쓸 만한 모양인지 검증하는 것이 통합 테스트의 목적이므로, 이 제약은 의도된 것입니다.
처음 통합 테스트를 만들 때 가장 흔히 막히는 부분은 바이너리 크레이트입니다. src/main.rs만 있는 프로젝트에서는 tests/에서 use my_crate;를 할 대상이 없어 unresolved import 'my_crate' 에러가 납니다. 통합 테스트로 검증하려면 로직을 src/lib.rs로 옮기고 main.rs는 라이브러리 함수를 호출하는 얇은 진입점으로 두는 구조가 필요합니다. 크레이트 이름은 Cargo.toml의 name에서 하이픈을 밑줄로 바꾼 것입니다(my-crate → my_crate). 2018 에디션부터는 use my_crate; 줄 없이 my_crate::add로 바로 써도 됩니다.
통합 테스트 파일은 파일마다 따로 링크되므로, 파일이 많아지면 테스트 빌드 시간이 크게 늘어납니다. 큰 프로젝트에서는 tests/integration/main.rs 하나에 여러 모듈을 두어 바이너리를 하나로 합치는 방식을 쓰기도 합니다.
공통 모듈
// tests/common/mod.rs
pub fn setup() {
// 테스트 초기화 코드
}
// tests/integration_test.rs
mod common;
#[test]
fn test_with_setup() {
common::setup();
// 테스트 코드
}
공통 코드를 tests/common.rs가 아니라 tests/common/mod.rs에 두는 데는 이유가 있습니다. tests/ 바로 아래의 .rs 파일은 모두 독립된 테스트 크레이트로 취급되므로, tests/common.rs로 만들면 Cargo가 이것도 테스트 파일로 컴파일하고 결과에 running 0 tests가 한 줄 더 찍힙니다. 하위 디렉터리의 mod.rs는 테스트 크레이트로 취급되지 않고, 다른 테스트 파일에서 mod common;으로 불러올 때만 포함됩니다.
common 모듈을 쓰는 테스트 파일이 여럿이면 파일마다 한 번씩 컴파일되고, 어떤 파일은 setup만 쓰고 다른 헬퍼는 쓰지 않아 function 'helper' is never used 경고가 날 수 있습니다. #[allow(dead_code)]를 공통 모듈에 붙이는 것이 흔한 해결책입니다. 또 Rust 테스트 러너에는 JUnit의 @BeforeEach 같은 전후 처리 훅이 없으므로, 이 예제처럼 각 테스트에서 setup()을 직접 호출하거나, 한 번만 초기화할 것은 std::sync::OnceLock으로 감싸는 것이 관례입니다. 정리 작업은 Drop을 구현한 가드 객체를 반환하면 테스트가 패닉해도 실행됩니다.
예제: 계산기 테스트
struct Calculator;
impl Calculator {
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err(String::from("0으로 나눌 수 없음"))
} else {
Ok(a / b)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(Calculator::add(2, 3), 5);
assert_eq!(Calculator::add(-2, 3), 1);
}
#[test]
fn test_divide_success() {
assert_eq!(Calculator::divide(10, 2), Ok(5));
}
#[test]
fn test_divide_by_zero() {
assert!(Calculator::divide(10, 0).is_err());
}
}
앞의 divide는 0으로 나누면 패닉했지만, 계산기의 divide는 Result를 반환합니다. 테스트도 그에 맞춰 #[should_panic] 대신 is_err()로 바뀌었습니다. 이 차이가 곧 API 설계 차이입니다. 사용자 입력처럼 정상적으로 발생할 수 있는 실패는 Result로 표현하고 테스트로 각 분기를 확인하는 것이 Rust다운 방식입니다.
assert_eq!(Calculator::divide(10, 2), Ok(5))처럼 Result 전체를 비교할 수 있는 것은 Result<i32, String>이 PartialEq와 Debug를 구현하기 때문입니다. is_err()만 확인하는 테스트는 어떤 에러인지는 검증하지 않으므로, 에러 종류가 여러 개라면 assert_eq!(Calculator::divide(10, 0), Err("0으로 나눌 수 없음".to_string()))이나 assert!(matches!(result, Err(CalcError::DivByZero)))처럼 구체적으로 확인하는 편이 좋습니다. 경계값도 빠져 있습니다. Calculator::divide(i32::MIN, -1)은 결과가 i32 범위를 넘어 패닉하는데(attempt to divide with overflow), 이런 경계 입력을 테스트로 드러내는 것이 테스트를 쓰는 진짜 이유입니다. checked_div를 쓰면 이 경우도 None으로 처리할 수 있습니다.
임시 디렉터리를 쓰는 통합 테스트
파일 시스템을 건드리는 함수를 검증하는 최소 예제입니다. 테스트용 임시 디렉터리는 tempfile 크레이트로 만들고, 나머지는 표준 라이브러리만 씁니다.
Cargo.toml (dev-dependencies):
[dev-dependencies]
tempfile = "3"
use std::fs;
use std::path::Path;
fn merge_logs(dir: &Path) -> std::io::Result<String> {
let mut out = String::new();
let mut entries: Vec<_> = fs::read_dir(dir)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().map(|x| x == "log").unwrap_or(false))
.collect();
entries.sort();
for p in entries {
out.push_str(&fs::read_to_string(&p)?);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn orders_files_lexicographically() -> std::io::Result<()> {
let dir = tempdir()?;
let a = dir.path().join("b.log");
let b = dir.path().join("a.log");
fs::write(&a, "second\n")?;
fs::write(&b, "first\n")?;
let merged = merge_logs(dir.path())?;
assert_eq!(merged, "first\nsecond\n");
Ok(())
}
}
tempdir()은 운영체제 임시 폴더 아래에 고유한 이름의 디렉터리를 만들고, 반환된 TempDir 값이 스코프를 벗어나면 디렉터리를 통째로 지웁니다. 테스트마다 서로 다른 디렉터리를 쓰므로 병렬 실행에서도 충돌하지 않고, 테스트가 패닉해도 Drop이 실행되어 정리됩니다. 고정 경로(/tmp/test_logs)를 쓰는 테스트가 병렬 실행에서 가끔 실패하는 문제를 이 방식으로 피할 수 있습니다. 주의할 점은 let _ = tempdir()?;처럼 _에 바인딩하면 즉시 삭제된다는 것입니다. 반드시 let dir = ...처럼 이름 있는 변수에 담아야 테스트가 끝날 때까지 유지됩니다.
이 테스트가 orders_files_lexicographically라는 이름으로 검증하는 것도 의미가 있습니다. fs::read_dir은 순서를 보장하지 않으며, 실제 순서는 파일 시스템과 운영체제에 따라 다릅니다. 로컬 macOS에서는 우연히 알파벳순으로 나와 sort() 없이도 통과하던 테스트가 Linux CI에서 실패하는 일이 흔합니다. 파일을 쓰는 순서를 일부러 b.log → a.log로 뒤집어 둔 것도 정렬이 정말 동작하는지 드러내기 위한 장치입니다.
순서 의존·panic 메시지·import 규칙에서 생기는 실수
- 전역 상태(환경 변수, 현재 디렉터리)에 의존한 테스트가 순서에 따라 실패하는 경우.
#[should_panic]만 걸고 메시지를 검증하지 않아 잘못된 panic에도 통과하는 경우.- 통합 테스트에서
use crate::가 아니라 크레이트 이름으로만 import해야 하는 규칙을 혼동하는 경우.
첫 번째 실수가 가장 찾기 어렵습니다. 제가 Rust 테스트에서 가장 자주 본 유형은 std::env::set_var로 환경 변수를 바꾸는 테스트와 그 환경 변수를 읽는 다른 테스트가 동시에 도는 경우인데, 혼자 돌리면 항상 통과하고 전체를 돌리면 열 번에 한 번쯤 실패합니다. 환경 변수와 현재 디렉터리(set_current_dir)는 프로세스 전체에서 공유되기 때문에 스레드로 나뉜 테스트끼리 서로의 값을 봅니다. 참고로 set_var는 Rust 2024 에디션에서 unsafe 함수로 바뀌었는데, 멀티스레드 환경에서 안전하지 않다는 이 문제 때문입니다. 설정을 환경 변수에서 직접 읽지 말고 구조체로 받아 테스트에서 주입하도록 설계를 바꾸는 것이 근본적인 해결책이고, 당장은 serial_test 크레이트의 #[serial]로 해당 테스트들만 순차 실행할 수 있습니다.
cargo test는 기본적으로 병렬입니다. 공유 자원이 있으면Mutex또는--test-threads=1을 고려하세요.- 벤치마크는
criterion등으로 분리해 릴리스 프로파일에서 측정합니다.
criterion으로 벤치마크 측정하기
제목에 벤치마크가 있지만, 안정(stable) Rust의 표준 #[bench] 속성은 nightly 전용이라 대부분의 프로젝트는 criterion 크레이트를 씁니다.
[dev-dependencies]
criterion = "0.5"
[[bench]]
name = "calc"
harness = false
// benches/calc.rs
use criterion::{criterion_group, criterion_main, Criterion};
use std::hint::black_box;
fn bench_add(c: &mut Criterion) {
c.bench_function("add", |b| b.iter(|| my_crate::add(black_box(2), black_box(3))));
}
criterion_group!(benches, bench_add);
criterion_main!(benches);
cargo bench로 실행하면 criterion이 워밍업 후 여러 번 반복 측정해 평균과 신뢰 구간을 보여 주고, 이전 실행과 비교해 성능이 유의미하게 변했는지도 알려 줍니다. harness = false는 Rust 기본 테스트 러너 대신 criterion의 main을 쓰겠다는 설정이라 빠뜨리면 컴파일 에러가 납니다. black_box는 컴파일러가 입력이 상수라는 것을 알고 계산 자체를 컴파일 시점에 끝내 버리는 것을 막는 장치입니다. 이것 없이 add(2, 3)을 측정하면 최적화로 계산이 사라져 0에 가까운 비현실적인 숫자가 나옵니다. 벤치마크 결과는 기기 부하에 따라 흔들리므로, 공유 CI 러너에서 나온 수치로 회귀를 판단할 때는 오차 범위를 넉넉히 보는 편이 좋습니다.
문서 주석 속 코드도 테스트된다
/// 문서 주석 안의 코드 블록도 cargo test가 실제로 컴파일하고 실행합니다. 문서의 예제가 코드 변경으로 낡으면 테스트가 실패하므로, 문서와 구현이 어긋나는 것을 자동으로 막아 줍니다. 문서 테스트는 통합 테스트처럼 외부 크레이트 관점에서 컴파일되므로 use my_crate::add;처럼 경로를 적어야 하고, 라이브러리 크레이트에서만 동작합니다.
proptest·nextest·CI 설정
- 프로퍼티 기반 테스트(
proptest)로 입력 공간을 넓혀 엣지 케이스를 잡습니다. - CI에서는 cargo test —locked와 cargo clippy — -D warnings를 함께 돌립니다.
--locked는Cargo.lock과 맞지 않는 의존성 버전이 선택되면 실패하게 해서, 로컬에서 통과한 테스트가 CI에서 다른 의존성 버전으로 도는 상황을 막아 줍니다. - 테스트 수가 많아지면
cargo-nextest를 검토할 만합니다. 테스트마다 별도 프로세스로 실행해 전역 상태 간섭이 줄고, 실패한 테스트만 다시 돌리거나 느린 테스트를 찾기 쉽습니다. - 오래 걸리는 테스트는
#[ignore]를 붙여 기본 실행에서 빼고, CI의 별도 단계에서cargo test -- --ignored로 돌리는 방식이 흔합니다.
단위·통합·문서 테스트의 용도
| 종류 | 용도 |
|---|---|
| 단위 테스트 | 순수 함수, 빠른 피드백 |
| 통합 테스트 | 바이너리 경계, 파일·네트워크 |
| 문서 테스트 | 예제 코드 동기화 |
참고 자료
테스트 작성 요약
- #[test]: 테스트 함수 표시
- assert!: 조건 검증
- assert_eq!/assert_ne!: 값 비교
- #[should_panic]: panic 테스트
- 통합 테스트: tests/ 디렉토리
다음 단계
같이 보면 좋은 글
- Rust 시작하기 | 메모리 안전한 시스템 프로그래밍 언어
- Rust 동시성 | Thread, Channel, Arc, Mutex
- Google Test로 C++ 단위 테스트 시작하기
- Swift 에러 처리 | do-catch, throw, Result
자주 묻는 질문 (FAQ)
Q. 혼자 돌리면 통과하는 테스트가 cargo test 전체 실행에서는 가끔 실패하는 이유는 무엇인가요?
A. cargo test는 기본적으로 테스트를 여러 스레드에서 병렬로 실행하기 때문에, 환경 변수나 현재 디렉터리, 같은 파일 경로 같은 전역 상태를 공유하는 테스트끼리 서로 간섭할 수 있습니다. 테스트마다 임시 디렉터리를 따로 쓰도록 바꾸는 것이 근본적인 해결이고, 당장은 공유 자원에 Mutex를 두거나 —test-threads=1로 순차 실행해 원인을 확인할 수 있습니다.