Introduction

test 속성 하나면 함수가 테스트가 되고, cargo test 한 줄이면 단위 테스트, 통합 테스트, 문서 테스트가 한번에 실행됩니다. 별도의 테스트 프레임워크를 설치할 필요가 없다는 점에서 러스트의 테스트 시스템은 컴파일 시점에 보장되는 정확성이라는 원칙의 자연스러운 연장선입니다. 이번 글에서는 러스트가 제공하는 테스트 도구의 전체 그림을 살펴본 뒤, 각 테스트 유형을 코드와 함께 하나씩 짚어 봅니다.

테스트 시스템의 전체 구조

테스트란, 테스트할 코드가 의도대로 기능하는지 검증하는 함수다. 러스트의 테스트는 크게 세 가지 유형으로 나뉜다.

러스트에서 테스트란 test 속성1이 어노테이션된 함수다. cargo test를 실행하면 컴파일러가 test 속성이 어노테이션된 함수들을 수집하고, 테스트 하네스(test harness)2가 이를 실행한다. 각 유형은 서로 다른 위치에 존재하며, 테스트 대상과 목적이 다르다.

유형위치테스트 대상비공개 함수 접근
단위 테스트src/ 내부 모듈개별 함수·모듈의 내부 로직가능
통합 테스트tests/ 디렉토리크레이트의 공개 API불가능
문서 테스트/// 주석 코드 블록문서 예제의 정확성불가능

단위 테스트

단위 테스트unit test의 목적은 각 코드 단위를 나머지 코드와 분리해 제대로 작동하지 않는 코드가 어느 부분인지 빠르게 파악하는 것이다. src 디렉토리 내의 각 파일에 테스트 대상이 될 코드와 함께 작성하며, 각 파일에 tests 모듈을 만들고 cfg(test)3라는 어노테이션을 추가하는 것이 일반적이다.

첫 번째 테스트

이제 첫 번째 테스트를 작성해보자. cargo new --lib로 라이브러리 크레이트를 생성하면 src/lib.rs에 예시 테스트가 자동으로 포함된다.

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

cargo test를 실행하면 컴파일러가 테스트 바이너리를 빌드하고, #[test]가 붙은 함수들을 자동으로 찾아 실행한다.

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

테스트 함수가 패닉하면 실패, 정상 반환하면 성공이다.

assert! 매크로

러스트의 표준 라이브러리는 세 가지 단언assertion 매크로를 제공한다. 단언 매크로는 어떤 조건이 참임을 보장하는 테스트를 작성할 때 유용하다.

assert!(condition)

조건이 false이면 패닉을 일으킨다. 불리언 조건을 검증할 때 사용한다.

#[test]
fn larger_can_hold_smaller() {
    let larger = Rectangle { width: 8, height: 7 };
    let smaller = Rectangle { width: 5, height: 1 };
    assert!(larger.can_hold(&smaller));
}

assert_eq!(left, right)

두 값이 같지 않으면 패닉을 일으키며, 양쪽 값을 출력해 준다. 비교 대상은 PartialEqDebug 트레이트를 구현해야 한다.

#[test]
fn it_adds() {
    assert_eq!(4, add(2, 2));
}

assert_ne!(left, right)

두 값이 같으면 패닉을 일으킨다. 결과가 무엇인지 확실하지 않지만 이것만은 아니어야 한다는 조건을 검증할 때 유용하다.

세 매크로 모두 마지막 인자로 커스텀 메시지를 추가할 수 있다. 이 메시지는 format! 매크로에 전달되므로 포맷 문자열과 자리표시자를 쓸 수 있다.

#[test]
fn greeting_contains_name() {
    let result = greeting("Carol");
    assert!(
        result.contains("Carol"),
        "인사말에 이름이 포함되지 않았습니다. 실제 값: `{result}`"
    );
}

should_panic

함수가 특정 조건에서 패닉을 일으켜야 한다면, should_panic 속성으로 이를 검증할 수 있다. 테스트 내부에서 테스트 대상이 패닉하면 성공, 패닉하지 않으면 실패다.

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("추측값은 1에서 100 사이여야 합니다. 입력값: {value}");
        }
        Guess { value }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "추측값은 1에서 100 사이여야 합니다")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

expected 매개변수는 패닉 메시지에 해당 문자열이 포함되어 있는지를 확인한다. 정확히 일치할 필요는 없지만, expected를 생략하면 어떤 이유로든 패닉이 일어나기만 하면 통과하므로, 의도한 패닉인지 구분할 수 없게 된다. 가능하면 expected를 명시하는 것이 좋다.

Result<T, E>를 반환하는 테스트

테스트 함수는 Result<T, E>를 반환할 수도 있다. Ok(())를 반환하면 성공, Err를 반환하면 실패다. 이 방식은 테스트 본문에서 ? 연산자를 사용할 수 있게 해 주므로, 내부적으로 Result를 반환하는 연산들을 연쇄할 때 편리하다.

#[test]
fn it_works() -> Result<(), String> {
    let result = add(2, 2);
    if result == 4 {
        Ok(())
    } else {
        Err(format!("2 + 2가 4가 아닙니다. 실제 값: {result}"))
    }
}

다만 Result를 반환하는 테스트에는 #[should_panic]을 사용할 수 없다. 연산이 Err를 반환해야 한다는 것을 단언하려면, 반환된 Resultassert!(value.is_err())를 사용한다.

테스트 구성

#[cfg(test)] 모듈

단위 테스트는 관례에 따라 테스트 대상과 같은 파일 안에 #[cfg(test)]가 붙은 tests 모듈로 작성한다.

// src/lib.rs
pub fn add_two(a: i32) -> i32 {
    internal_adder(a, 2)
}

fn internal_adder(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn internal() {
        assert_eq!(4, internal_adder(2, 2));
    }
}

#[cfg(test)] 속성은 컴파일러에게 cargo test 실행 시에만 이 모듈을 컴파일하도록 지시한다. cargo build로 일반 빌드를 할 때는 테스트 코드가 바이너리에 포함되지 않으므로 컴파일 시간과 바이너리 크기를 절약할 수 있다. 통합 테스트는 tests/ 디렉토리에 별도로 존재하기 때문에 #[cfg(test)]가 필요 없지만, 단위 테스트는 제품 코드와 같은 파일에 있으므로 이 속성이 필수다.

비공개 함수 테스트

한편, 러스트에서는 비공개 함수도 테스트할 수 있다. 위 예시에서 internal_adderpub이 없기 때문에 공개 함수가 아니지만, tests 모듈이 같은 파일 안에 있고 use super::*로 비공개 함수를 포함한 부모 모듈의 모든 항목을 가져오기 때문에 접근 가능하다.

비공개 함수의 테스트 여부는 언어가 강제하는 것이 아니라 설계의 영역이다. 다른 언어에서는 비공개 함수를 직접 테스트하기 어렵거나 불가능한 경우가 많지만 러스트는 이를 자연스럽게 허용한다.

통합 테스트

통합 테스트는 라이브러리의 공개 API를 외부 코드의 시점에서 검증한다. 프로젝트 루트에 tests/ 디렉토리를 만들고, 그 안에 테스트 파일을 배치한다.

my_project/
├── Cargo.toml
├── src/
│   └── lib.rs
└── tests/
    └── integration_test.rs

통합 테스트는 라이브러리 바깥에 위치하기 때문에 라이브러리를 외부 코드에서 사용할 때와 똑같은 방식을 사용한다.

// tests/integration_test.rs
use my_project::add_two;

#[test]
fn it_adds_two() {
    assert_eq!(4, add_two(2));
}

카고가 tests/ 디렉토리의 파일을 자동으로 테스트 전용으로 처리하기 때문에 통합 테스트 파일에는 #[cfg(test)]가 필요 없다. 각 파일은 독립된 크레이트로 컴파일되므로, use my_project::...로 공개 API만 가져올 수 있다.

통합 테스트의 하위 모듈

통합 테스트 파일 간에 헬퍼 함수를 공유하고 싶을 때가 있다. 예를 들어 테스트용 설정 함수를 여러 테스트 파일에서 재사용하려면, tests/ 아래에 하위 디렉토리를 만들어야 한다.

tests/
├── common/
│   └── mod.rs
└── integration_test.rs

예를 들어 tests/common.rs 파일을 생성하고, 여러 테스트 파일의 테스트 함수에서 호출할 setup 함수를 작성해보자.

// tests/common/mod.rs
pub fn setup() {
    // 테스트 환경 설정
}

어떤 테스트 함수도 담고 있지 않고 다른 곳에서 setup 함수를 호출하고 있지도 않지만, 테스트 출력 결과에는 common이 포함된다.

// tests/integration_test.rs
mod common;

use my_project::add_two;

#[test]
fn it_adds_two() {
    common::setup();
    assert_eq!(4, add_two(2));
}

tests/common/mod.rs라는 경로를 사용하면 카고는 common을 독립적인 테스트 크레이트로 취급하지 않는다. 만약 tests/common.rs로 만들면 카고가 이를 별도의 테스트 파일로 인식하여 running 0 tests라는 불필요한 출력이 나타난다.

참고로 통합 테스트는 라이브러리 크레이트에만 적용된다. 바이너리 크레이트(src/main.rs만 있는 프로젝트)는 tests/ 디렉토리에서 함수를 가져올 수 없다. 이것이 러스트 프로젝트에서 src/main.rs는 얇게 유지하고 핵심 로직을 src/lib.rs에 두는 패턴이 권장되는 이유 중 하나다.

문서 테스트

Rust의 문서 주석(///) 안에 작성된 코드 블록은 cargo test 실행 시 자동으로 컴파일되고 실행된다. 문서 테스트는 예제 코드가 실제로 동작함을 보장하는 강력한 장치다.

/// 두 수를 더한다.
///
/// # Examples
///
/// ```
/// let result = my_project::add_two(3);
/// assert_eq!(result, 5);
/// ```
pub fn add_two(a: i32) -> i32 {
    a + 2
}

cargo test --doc을 실행하면 문서 주석 안의 코드 블록이 독립적인 테스트로 추출되어 실행된다.

보이지 않는 코드 줄

문서 예제에서 main 함수 래핑이나 use 선언 같은 보일러플레이트를 숨기려면, 해당 줄 앞에 #을 붙인다. 이 줄은 문서에는 표시되지 않지만 컴파일과 실행에는 포함된다.

/// ```
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let value: i32 = "42".parse()?;
/// assert_eq!(value, 42);
/// # Ok(())
/// # }
/// ```

렌더링된 문서에서 사용자는 let value: i32 = "42".parse()?;assert_eq!만 보게 되지만, 실제로는 main 함수로 감싸져서 ? 연산자가 동작할 수 있다. 이 기법 덕분에 문서는 깔끔하게, 테스트는 완전하게 유지할 수 있다.

compile_failignore

컴파일이 실패해야 하는 예제를 보여 주고 싶을 때는 코드 블록에 compile_fail을 지정한다.

/// 아래 코드는 [[소유권]]이 이동된 변수를 사용하려 하므로 컴파일에 실패한다.
///
/// ```compile_fail
/// let s1 = String::from("hello");
/// let s2 = s1;
/// println!("{s1}"); // 오류: 이동된 값 사용
/// ```

ignore를 지정하면 코드 블록이 컴파일도 실행도 되지 않는다. 외부 서비스에 의존하는 예제처럼 자동 테스트가 부적절한 경우에 사용한다.

/// ```ignore
/// // 네트워크 연결이 필요한 예제
/// let response = fetch("https://api.example.com/data")?;
/// ```

README 테스트

README.md에 포함된 코드 예제도 테스트할 수 있다. include_str! 매크로로 README를 문서 주석으로 포함시키면, 그 안의 코드 블록이 문서 테스트로 실행된다.

#[doc = include_str!("../README.md")]
#[cfg(doctest)]
pub struct ReadmeDoctests;

cfg(doctest) 속성 덕분에 이 구조체는 문서 테스트 실행 시에만 존재하고, 일반 빌드에는 영향을 미치지 않는다.

테스트 실행과 제어

cargo test의 인자 구조

cargo test의 인자는 -- 구분자를 기준으로 두 부분으로 나뉜다.

cargo test [카고 옵션] -- [테스트 바이너리 옵션]

-- 앞의 인자는 카고에 전달되고, 뒤의 인자는 생성된 테스트 바이너리에 전달된다.

테스트 필터링

cargo test 뒤에 문자열을 전달하면 이름에 해당 문자열이 포함된 테스트만 실행된다.

# 이름에 "add"가 포함된 테스트만 실행
cargo test add

# 특정 모듈의 테스트만 실행
cargo test tests::it_adds

# 특정 통합 테스트 파일만 실행
cargo test --test integration_test

테스트 유형별 실행

cargo test --lib        # 단위 테스트만
cargo test --tests      # 통합 테스트만
cargo test --doc        # 문서 테스트만

#[ignore]

시간이 오래 걸리는 테스트는 ignore 속성으로 기본 실행에서 제외할 수 있다.

#[test]
#[ignore = "완료까지 10분 이상 소요"]
fn expensive_test() {
    // 시간이 오래 걸리는 테스트
}
cargo test                    # ignore된 테스트 제외
cargo test -- --ignored       # ignore된 테스트만 실행
cargo test -- --include-ignored  # 모든 테스트 실행

병렬 실행과 직렬 실행

기본적으로 러스트의 테스트 하네스는 테스트를 병렬로 실행한다4. 테스트 간에 공유 상태(파일, 환경 변수 등)가 있다면 경합이 발생할 수 있는데, 이때는 스레드 수를 1로 제한하여 직렬 실행으로 전환한다.

cargo test -- --test-threads=1

출력 캡처

통과한 테스트의 println! 출력은 기본적으로 캡처되어 표시되지 않는다. 실패한 테스트의 출력만 보인다. 통과한 테스트의 출력까지 보려면 --nocapture 옵션을 사용한다.

cargo test -- --nocapture

이 옵션은 디버깅 시 println!이나 dbg! 매크로의 출력을 확인할 때 유용하다.

벤치마크

Rust는 bench 속성와 test::Bencher를 통해 마이크로벤치마크5를 지원하지만, 이 기능은 아직 불안정하며 나이틀리 버전에서만 사용할 수 있다.

#![feature(test)]
extern crate test;

use test::Bencher;

#[bench]
fn bench_add_two(b: &mut Bencher) {
    b.iter(|| add_two(2));
}
cargo +nightly bench

안정 채널에서 벤치마크가 필요하다면 criterion 크레이트가 널리 사용되는 대안이다. criterion은 통계적 분석과 회귀 감지까지 제공하므로, 실무에서는 오히려 더 선호되는 선택이다.


출처

Footnotes

  1. 속성은 러스트 코드 조각에 대한 메타데이터다.

  2. 테스트 하네스(test harness)란 #[test]가 붙은 함수들을 자동으로 수집하고 실행하는 프레임워크다. Rust 컴파일러는 각 테스트 함수를 모아 test::test_main_static() 함수에 전달하는 main 함수를 자동 생성한다.

  3. cfg 속성은 설정configuration을 의미하며, 러스트는 이 아이템을 특정 설정 옵션을 적용할 때만 포함한다. cfg(test)에서 옵션 값은 러스트에서 테스트를 컴파일하고 실행하기 위해 제공하는 test다.

  4. 병렬 실행은 테스트 속도를 높여 주지만, 테스트 간 격리(isolation)를 전제로 한다. 같은 파일을 읽고 쓰거나 같은 환경 변수를 수정하는 테스트가 있다면, 실행 순서에 따라 결과가 달라지는 비결정적(non-deterministic) 실패가 발생할 수 있다.

  5. 마이크로벤치마크는 작은 코드 조각의 실행 시간을 반복 측정하는 성능 테스트다. 컴파일러 최적화로 인해 측정 대상 코드가 제거되지 않도록, b.iter()의 클로저가 값을 반환하거나 test::black_box()로 감싸야 한다.