Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

#[test] 속성

많은 Rust 프로그래머는 #[test]라는 내장 속성을 사용합니다. 다음과 같이 함수에 표시하고 몇 가지 단언(assert)을 넣기만 하면 됩니다:

#[test]
fn my_test() {
    assert!(2+2 == 4);
}

이 프로그램을 rustc --test 또는 cargo test로 컴파일하면 이 함수와 다른 모든 테스트 함수를 실행할 수 있는 실행 파일이 만들어집니다. 이런 테스트 방식을 쓰면 테스트가 코드와 자연스럽게 함께 있을 수 있습니다. 심지어 비공개 모듈 안에도 테스트를 넣을 수 있습니다:

mod my_priv_mod {
    fn my_priv_func() -> bool {}

    #[test]
    fn test_priv_func() {
        assert!(my_priv_func());
    }
}

따라서 비공개 항목을 외부 테스트 장치에 어떻게 노출할지 고민하지 않고도 쉽게 테스트할 수 있습니다. 이것이 Rust에서 테스트를 편하게 작성할 수 있는 핵심 이유입니다. 그러나 의미론적으로는 꽤 이상합니다. 테스트가 보이지 않는다면 main 함수는 어떻게 이 테스트들을 호출합니까? rustc --test는 정확히 무엇을 합니까?

#[test]는 컴파일러의 rustc_ast 안에서 구문 변환으로 구현됩니다. 본질적으로 이것은 크레이트를 3단계로 다시 작성하는 정교한 macro입니다:

1단계: 재내보내기

앞에서 말했듯이 테스트는 비공개 모듈 안에 있을 수 있으므로, 기존 코드를 깨뜨리지 않고 테스트를 main 함수에 노출할 방법이 필요합니다. 이를 위해 rustc_ast는 테스트를 재귀적으로 재내보내는 __test_reexports라는 로컬 모듈을 만듭니다. 이 확장을 거치면 위 예제는 다음과 같이 바뀝니다:

mod my_priv_mod {
    fn my_priv_func() -> bool {}

    pub fn test_priv_func() {
        assert!(my_priv_func());
    }

    pub mod __test_reexports {
        pub use super::test_priv_func;
    }
}

이제 테스트에 my_priv_mod::__test_reexports::test_priv_func로 접근할 수 있습니다. 모듈 구조가 더 깊으면 __test_reexports가 테스트를 포함한 모듈을 재내보내므로, a::b::my_test에 있는 테스트는 a::__test_reexports::b::__test_reexports::my_test가 됩니다. 이 과정은 꽤 안전해 보이지만, __test_reexports 모듈이 이미 있다면 어떻게 됩니까? 답은 아무 일도 일어나지 않는다는 것입니다.

그 이유를 설명하려면 Rust의 추상 구문 트리가 식별자를 어떻게 표현하는지 이해해야 합니다. 모든 함수, 변수, 모듈 등의 이름은 문자열로 저장되지 않고, 본질적으로 식별자마다 부여된 ID 번호인 불투명한 Symbol로 저장됩니다. 컴파일러는 별도의 해시 테이블을 두어 필요할 때(예: 구문 오류를 출력할 때) Symbol의 사람이 읽을 수 있는 이름을 복원할 수 있게 합니다. 컴파일러는 __test_reexports 모듈을 생성할 때 그 식별자에 새 Symbol을 생성합니다. 따라서 컴파일러가 생성한 __test_reexports는 여러분이 직접 작성한 모듈과 이름은 같을 수 있어도 Symbol은 같지 않습니다. 이 기법은 코드 생성 중에 이름이 충돌하는 것을 막으며, Rust macro 위생성의 기반이 됩니다.

2단계: 하네스 생성

이제 크레이트 루트에서 테스트에 접근할 수 있으므로, 이 테스트들로 무언가를 해야 합니다. 이를 위해 rustc_ast는 다음과 같은 모듈을 생성합니다:

#[main]
pub fn main() {
    extern crate test;
    test::test_main_static(&[&path::to::test1, /*...*/]);
}

여기서 path::to::test1은 test::TestDescAndFn 타입의 상수입니다.

이 변환은 단순하지만, 테스트가 실제로 어떻게 실행되는지 잘 보여 줍니다. 테스트는 배열로 모인 뒤 test_main_static이라는 테스트 러너에 전달됩니다. TestDescAndFn이 정확히 무엇인지는 나중에 다시 다루겠습니다. 지금 중요한 점은 Rust core의 일부이면서 테스트용 런타임 전체를 구현하는 test라는 크레이트가 있다는 것입니다. test의 인터페이스는 안정화되지 않았으므로, 이 크레이트를 안정적으로 사용하는 유일한 방법은 #[test] 매크로를 쓰는 것입니다.

3단계: 테스트 객체 생성

이전에 Rust로 테스트를 작성해 본 적이 있다면 테스트 함수에서 사용할 수 있는 선택적 속성 중 일부에 익숙할 것입니다. 예를 들어, 테스트가 패닉을 일으킬 것으로 예상되는 경우 테스트에 #[should_panic] 어노테이션을 붙일 수 있습니다. 대략 다음과 같은 모습입니다.

#[test]
#[should_panic]
fn foo() {
    panic!("intentional");
}

이는 테스트가 단순한 함수 그 이상이며 설정 정보도 함께 가지고 있다는 뜻입니다. test는 이 설정 데이터를 TestDesc라는 struct로 인코딩합니다. 크레이트의 각 테스트 함수에 대해 rustc_ast는 해당 함수의 속성을 파싱하고 TestDesc 인스턴스를 생성합니다. 그런 다음 TestDesc와 테스트 함수를 결합하여, 이름에서 짐작할 수 있듯이 TestDescAndFn이라는 struct를 만듭니다. test_main_static은 이 구조체를 대상으로 동작합니다. 특정 테스트에 대해 생성된 TestDescAndFn 인스턴스는 다음과 같습니다.

self::test::TestDescAndFn{
  desc: self::test::TestDesc{
    name: self::test::StaticTestName("foo"),
    ignore: false,
    should_panic: self::test::ShouldPanic::Yes,
    allow_fail: false,
  },
  testfn: self::test::StaticTestFn(||
    self::test::assert_test_result(::crate::__test_reexports::foo())),
}

이러한 테스트 객체의 배열이 만들어지면, 2단계에서 생성된 하네스를 통해 테스트 러너에 전달됩니다.

생성된 코드 살펴보기

nightly rustc에는 unpretty라는 불안정 플래그가 있으며, 이를 사용하여 macro 확장 이후의 모듈 소스를 출력할 수 있습니다.

$ rustc my_mod.rs -Z unpretty=hir