테스트 조직화

테스트 조직화

이번에는 테스트를 어떻게 나누고 어디에 둘지 정리하는 이야기를 해볼게요. 챕터 서두에서도 언급했듯이 테스트는 제법 복잡한 분야라서, 사람마다 쓰는 용어나 구성 방식이 조금씩 달라요. Rust 커뮤니티에서는 테스트를 크게 **단위 테스트(unit test)**와 통합 테스트(integration test) 두 갈래로 나눠 생각합니다. 단위 테스트는 작고 한 대상에 집중해서, 한 번에 한 모듈을 따로 떼어 검사하며, 심지어 비공개 인터페이스까지 테스트할 수 있어요. 반면 통합 테스트는 라이브러리 바깥에서 완전히 외부 코드처럼 접근해서, 공개 인터페이스만 사용하고 테스트 하나가 여러 모듈을 오가기도 합니다.

이 두 종류를 모두 쓰는 게 중요한 이유는, 라이브러리를 이루는 조각들이 각각 그리고 함께 기대대로 동작하는지를 확인할 수 있기 때문이에요. 낱개로는 잘 돌던 코드가 서로 합쳐지면 어긋날 수 있으니, 단위 테스트와 통합 테스트를 함께 챙겨야 해요.

출처: The Rust Book

단위 테스트

단위 테스트의 목적은 코드의 각 단위를 나머지에서 분리해 검사함으로써, 코드가 어디서 잘 동작하고 어디서 기대를 벗어나는지를 빠르게 짚어내는 거예요. 단위 테스트는 src 디렉터리 안, 테스트 대상 코드가 있는 각 파일에 넣습니다. 관례는 각 파일에 tests라는 모듈을 만들어 테스트 함수를 넣고, 그 모듈에 cfg(test)를 붙여 표시하는 것이에요.

tests 모듈과 #[cfg(test)]

tests 모듈에 붙인 #[cfg(test)] 애너테이션은 Rust에게 "테스트 코드는 cargo test를 실행할 때만 컴파일하고 돌려라, cargo build 때는 제외하라"고 알려줘요. 이렇게 하면 라이브러리만 빌드할 때 컴파일 시간을 아낄 수 있고, 결과물 아티팩트에도 테스트가 포함되지 않아 공간이 절약돼요. 통합 테스트는 별도 디렉터리에 있으므로 #[cfg(test)]가 필요 없다는 점, 하지만 단위 테스트는 코드와 같은 파일에 있으므로 컴파일 결과에 포함되지 않도록 #[cfg(test)]로 지정해 줘야 한다는 점을 나중에 다시 보게 될 거예요.

이 챕터 첫 부분에서 adder 프로젝트를 만들었을 때, Cargo가 이런 코드를 자동으로 생성해 줬던 걸 기억하시죠?

파일명: src/lib.rs

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

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

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

자동 생성된 tests 모듈에서 cfgconfiguration(구성)의 줄임말로, "특정 구성 옵션이 주어질 때만 다음 항목을 포함하라"고 Rust에 지시해요. 여기서 그 구성 옵션은 test인데, 이는 Rust가 테스트를 컴파일·실행하기 위해 제공하는 것입니다. cfg 애트리뷰트 덕분에 Cargo는 cargo test로 테스트를 직접 실행할 때만 테스트 코드를 컴파일해요. 이 대상에는 #[test]가 붙은 함수는 물론, 그 모듈 안에 있는 보조 함수들까지 모두 포함됩니다.

비공개 함수 테스트

테스트 커뮤니티 안에서도 비공개 함수를 직접 테스트해야 하는지에 대해서는 의견이 갈리는데요, 다른 언어에서는 비공개 함수 테스트가 어렵거나 아예 불가능하기도 해요. 어떤 테스트 철학을 따르든, Rust의 프라이버시 규칙은 비공개 함수 테스트를 허용합니다. Listing 11-12의 코드처럼 비공개 함수 internal_adder가 있을 때를 생각해 볼게요.

파일명: src/lib.rs

pub fn add_two(a: u64) -> u64 {
    internal_adder(a, 2)
}

fn internal_adder(left: u64, right: u64) -> u64 {
    left + right
}

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

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

Listing 11-12: 비공개 함수 테스트하기

internal_adderpub으로 표시되지 않았다는 점을 눈여겨보세요. 테스트도 그저 Rust 코드일 뿐이고, tests 모듈도 평범한 모듈 하나일 뿐입니다. "모듈 트리에서 항목을 가리키는 경로"에서 다뤘듯이, 자식 모듈의 항목은 조상 모듈의 항목을 사용할 수 있어요. 이 테스트에서는 use super::*tests 모듈의 부모에 속한 항목을 전부 스코프로 불러들인 뒤, 테스트가 internal_adder를 호출합니다. 비공개 함수를 테스트해서는 안 된다고 생각한다면, Rust가 강제로 하라고 하지는 않아요.

통합 테스트

Rust에서 통합 테스트는 라이브러리와 완전히 분리된 영역에서 동작해요. 다른 코드와 똑같은 방식으로 라이브러리를 사용하므로, 오직 라이브러리의 공개 API에 속한 함수만 호출할 수 있어요. 목적은 라이브러리의 여러 부분이 서로 올바르게 협력하는지 검증하는 것입니다. 낱개로는 잘 동작하던 코드 조각도 통합되면 문제가 생길 수 있으니, 통합된 코드의 커버리지도 중요해요. 통합 테스트를 만들려면 먼저 tests 디렉터리가 필요합니다.

tests 디렉터리

프로젝트 디렉터리 최상위에, src 옆에 tests 디렉터리를 만듭니다. Cargo는 통합 테스트 파일을 이 디렉터리에서 찾는 것으로 알고 있어요. 원하는 만큼 테스트 파일을 만들 수 있고, Cargo는 각 파일을 개별 크레이트로 컴파일합니다.

통합 테스트를 하나 만들어 볼게요. Listing 11-12의 코드가 여전히 src/lib.rs에 있는 상태에서, tests 디렉터리를 만들고 tests/integration_test.rs라는 새 파일을 생성합니다. 디렉터리 구조는 이렇게 보일 거예요:

adder
├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    └── integration_test.rs

Listing 11-13의 코드를 tests/integration_test.rs 파일에 입력합니다.

파일명: tests/integration_test.rs

use adder::add_two;

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

Listing 11-13: adder 크레이트의 함수에 대한 통합 테스트

tests 디렉터리의 각 파일은 별개의 크레이트이므로, 우리 라이브러리를 각 테스트 크레이트의 스코프로 불러와야 해요. 그래서 단위 테스트에서는 필요 없었던 use adder::add_two;를 코드 맨 위에 추가합니다.

tests/integration_test.rs 안의 어떤 코드에도 #[cfg(test)]를 붙일 필요가 없어요. Cargo는 tests 디렉터리를 특별 취급해서, 이 디렉터리의 파일은 cargo test를 실행할 때만 컴파일하기 때문입니다. 지금 cargo test를 실행해 볼게요:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.31s
     Running unittests src/lib.rs (target/debug/deps/adder-1082c4b063a8fbe6)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-1082c4b063a8fbe6)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

출력의 세 구역이 단위 테스트, 통합 테스트, 문서 테스트(doc test)에 각각 해당해요. 한 구역에서 어떤 테스트라도 실패하면 이후 구역은 실행되지 않는다는 점을 기억해 두세요. 예컨대 단위 테스트가 실패하면 통합·문서 테스트 구역은 아예 출력되지 않아요. 이들은 모든 단위 테스트가 통과해야만 실행되거든요.

첫 번째 구역인 단위 테스트는 지금까지 봐왔던 것과 똑같아요. 단위 테스트마다 한 줄씩(Lising 11-12에서 추가한 internal 하나) 나오고, 그 뒤에 단위 테스트 요약 줄이 붙습니다.

통합 테스트 구역은 Running tests/integration_test.rs라는 줄로 시작해요. 그 다음에 그 통합 테스트의 각 테스트 함수에 대한 줄이 나오고, Doc-tests adder 구역이 시작되기 직전에 통합 테스트 결과 요약 줄이 옵니다.

통합 테스트 파일마다 자기 구역을 갖기 때문에, tests 디렉터리에 파일을 더 추가하면 통합 테스트 구역도 그만큼 늘어나요.

cargo test의 인자로 테스트 함수 이름을 지정해서 특정 통합 테스트 함수만 실행할 수도 있어요. 특정 통합 테스트 파일의 테스트 전체를 실행하려면 cargo test--test 인자 뒤에 파일 이름을 붙이면 됩니다:

$ cargo test --test integration_test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.64s
     Running tests/integration_test.rs (target/debug/deps/integration_test-82e7799c1bc62298)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

이 명령은 tests/integration_test.rs 파일 안의 테스트만 실행합니다.

통합 테스트의 서브모듈

통합 테스트가 늘어나면 정리 목적으로 tests 디렉터리에 파일을 더 만들고 싶어질 거예요. 예를 들어 테스트하는 기능별로 테스트 함수를 묶을 수 있겠죠. 앞서 말했듯 tests 디렉터리의 각 파일은 자기만의 별도 크레이트로 컴파일되는데, 이는 최종 사용자가 크레이트를 쓰는 방식에 더 가깝게 별도 스코프를 만들 수 있어서 유용해요. 다만 그 덕분에 tests 디렉터리의 파일이 7장에서 배운 것처럼 모듈·파일로 코드를 나누는 src 디렉터리의 파일과 같은 동작을 공유하지는 않아요.

tests 디렉터리 파일의 이런 다른 동작이 가장 두드러지는 경우는, 여러 통합 테스트 파일에서 쓰려는 헬퍼 함수 묶음이 있을 때 7장의 "모듈을 서로 다른 파일로 분리하기" 절차를 따라 공통 모듈로 뽑아내려는 상황이에요. 예를 들어 tests/common.rs를 만들고 그 안에 setup이라는 함수를 두면, 여러 테스트 파일의 여러 테스트 함수에서 호출하고 싶은 코드를 setup 안에 추가할 수 있어요:

파일명: tests/common.rs

pub fn setup() {
    // setup code specific to your library's tests would go here
}

테스트를 다시 실행하면, 이 파일에 테스트 함수가 하나도 없고 어디서도 setup을 호출하지 않았는데도 common.rs 파일에 대한 새 구역이 테스트 출력에 나타나는 걸 보게 돼요:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.89s
     Running unittests src/lib.rs (target/debug/deps/adder-92948b65e88960b4)

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/common.rs (target/debug/deps/common-92948b65e88960b4)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/integration_test.rs (target/debug/deps/integration_test-92948b65e88960b4)

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

commonrunning 0 tests와 함께 테스트 결과에 나타나는 건 우리가 원하는 게 아니에요. 그냥 다른 통합 테스트 파일들과 코드를 공유하고 싶었을 뿐이죠. common이 테스트 출력에 나타나는 걸 피하려면 tests/common.rs 대신 tests/common/mod.rs를 만들면 됩니다. 그러면 프로젝트 디렉터리는 이렇게 보여요:

├── Cargo.lock
├── Cargo.toml
├── src
│   └── lib.rs
└── tests
    ├── common
    │   └── mod.rs
    └── integration_test.rs

이건 7장의 "대체 파일 경로"에서 언급했던, Rust도 이해하는 옛 네이밍 관례입니다. 이렇게 파일을 이름 지으면 Rust가 common 모듈을 통합 테스트 파일로 취급하지 않아요. setup 함수 코드를 tests/common/mod.rs로 옮기고 tests/common.rs 파일을 지우면, 테스트 출력의 해당 구역이 더 이상 나타나지 않습니다. tests 디렉터리 하위 디렉터리의 파일은 별도 크레이트로 컴파일되지 않고 테스트 출력에 구역을 만들지도 않아요.

tests/common/mod.rs를 만든 뒤에는 어떤 통합 테스트 파일에서든 모듈로 사용할 수 있어요. tests/integration_test.rsit_adds_two 테스트에서 setup 함수를 호출하는 예시입니다:

파일명: tests/integration_test.rs

use adder::add_two;

mod common;

#[test]
fn it_adds_two() {
    common::setup();

    let result = add_two(2);
    assert_eq!(result, 4);
}

mod common; 선언이 Listing 7-21에서 보여드린 모듈 선언과 같다는 점을 눈여겨보세요. 그런 다음에는 테스트 함수에서 common::setup()을 호출할 수 있어요.

바이너리 크레이트의 통합 테스트

프로젝트가 바이너리 크레이트라서 src/main.rs만 있고 src/lib.rs가 없는 경우에는, tests 디렉터리에 통합 테스트를 만들고 use 문으로 src/main.rs에 정의된 함수를 스코프로 불러올 수 없어요. 다른 크레이트가 사용할 함수를 노출하는 건 라이브러리 크레이트뿐이고, 바이너리 크레이트는 자체적으로 실행되도록 만들어졌거든요.

이것이 바이너리를 제공하는 Rust 프로젝트가 src/main.rs를 단순하게 두고 실제 로직은 src/lib.rs에 두는 이유 중 하나예요. 그런 구조라면 통합 테스트가 use로 중요한 기능을 노출시켜 라이브러리 크레이트를 테스트할 수 있어요. 중요한 기능이 동작하면 src/main.rs의 아주 짧은 코드도 맞물려 동작할 것이고, 그 짧은 코드는 굳이 테스트할 필요가 없습니다.

요약

Rust의 테스트 기능은 코드가 어떻게 동작해야 하는지를 명시해서, 변경을 가해도 기대대로 계속 동작하도록 보장하는 방법을 제공해요. 단위 테스트는 라이브러리의 각 부분을 따로따로 검사하고 비공개 구현 세부사항도 테스트할 수 있어요. 통합 테스트는 라이브러리의 여러 부분이 함께 올바르게 협력하는지 확인하며, 외부 코드가 사용하는 것과 같은 방식으로 라이브러리의 공개 API를 사용해 코드를 테스트합니다. Rust의 타입 시스템과 소유권 규칙이 일부 버그를 막아 주긴 하지만, 코드가 기대대로 동작하도록 하는 데서 비롯되는 로직 버그를 줄이기 위해 테스트는 여전히 중요해요.

이번 챕터와 이전 챕터에서 배운 지식을 한데 모아 프로젝트를 하나 만들어 볼 차례예요!

더 알아보기 (Learn more)