테스트 조직화
소스 내부의 cfg test 유닛 테스트와 tests 디렉터리의 통합 테스트를 구분해 공개·비공개 동작을 적절한 경계에서 검증합니다.
테스트는 복잡한 분야라서 사람마다 쓰는 용어와 구조가 다릅니다. 러스트 커뮤니티는 테스트를 크게 유닛 테스트(unit test)와 통합 테스트(integration test) 두 종류로 나누며, 소프트웨어공학에서 테스트를 수준별로 나누는 방법은 통합·인수·회귀 테스트에서 다룹니다. 유닛 테스트는 작고 한곳에 집중하며, 모듈 하나를 따로 떼어 테스트하고 비공개 인터페이스도 테스트할 수 있습니다. 통합 테스트는 라이브러리 바깥에 있으며, 다른 외부 코드와 똑같이 공개 인터페이스만 쓰고 한 테스트에서 여러 모듈을 함께 실행하기도 합니다.
두 종류의 테스트를 모두 써야, 라이브러리의 조각들이 따로따로 그리고 함께 기대한 대로 동작하는지 확인할 수 있습니다. 아래 그림은 테스트를 어디에 두느냐에 따라 무엇에 접근하고 무엇을 확인하는지를 정리한 것입니다.
ARCHITECTURE · TEST TARGET BOUNDARIES
유닛·통합·문서 테스트의 컴파일 경계
테스트의 크기가 아니라 컴파일 대상과 접근 경계가 종류를 가른다. #[cfg(test)] 모듈은 소스 곁에서 비공개 구현까지 보고, tests/*.rs와 문서 테스트는 외부 계약을 검증한다.
- 유닛 테스트
src/lib.rs의 자식tests모듈은 테스트 설정에서 포함되며 조상 모듈의 비공개 아이템도 검증합니다. - 통합 테스트
각
tests/*.rs파일은 별도 크레이트로 컴파일되어 라이브러리의 공개 API를 외부 사용자처럼 호출합니다. - 문서 테스트
rustdoc 예제는 별도 문서 테스트 대상이며 유닛·통합 테스트와 같은 크기 분류가 아니라 다른 컴파일 경계입니다.
- 바이너리 경계
재사용 로직은
src/lib.rs로 옮기고src/main.rs는 얇게 유지합니다. 바이너리 자체는 자식 프로세스로도 검증할 수 있습니다. - 선택 실행
cargo test --test <target>은 한 통합 대상을,cargo test -p <package>은 작업공간의 한 패키지를 고릅니다. 출력 순서는 안정적인 API로 가정하지 않습니다.
유닛 테스트의 개별 함수는 보통 병렬로 실행되고 Cargo는 별도 통합 테스트 대상을 현재 직렬로 실행하지만, 실행·출력 순서는 계약이 아니다.
소스 곁의 #[cfg(test)] 모듈은 자식 모듈이므로 라이브러리의 비공개 구현까지 볼 수 있습니다. tests/*.rs의 통합 테스트와 문서 주석 속 예제로 실행하는 문서 테스트는 라이브러리 바깥에서 공개 API를 부릅니다. 실행 파일 자체의 동작은 별도 프로세스로 실행해 확인합니다. 그림의 화살표는 무엇이 무엇을 부르는지를 나타낼 뿐 실행 순서가 아니며, 유닛 테스트에서 main으로 이어지는 관계도 없습니다.
유닛 테스트
유닛 테스트의 목적은 코드의 각 단위를 나머지 코드와 떼어 놓고 테스트해, 코드가 기대한 대로 동작하는지 아닌지를 빨리 알아내는 것입니다. 유닛 테스트는 테스트할 코드와 함께 src 폴더의 각 파일에 둡니다. 관례로 각 파일에 tests라는 모듈을 만들어 테스트 함수를 담고, 그 모듈에 cfg(test)를 붙입니다.
테스트 모듈과 #[cfg(test)]
tests 모듈에 붙인 #[cfg(test)]는 cargo build가 아니라 cargo test를 실행할 때만 이 테스트 코드를 컴파일하고 실행하라고 러스트에게 알려 줍니다. 그러면 라이브러리만 빌드할 때 컴파일 시간이 줄고, 테스트가 들어가지 않으므로 만들어진 결과물의 크기도 줄어듭니다. 통합 테스트는 다른 폴더에 있으므로 #[cfg(test)]가 필요하지 않습니다. 반면 유닛 테스트는 코드와 같은 파일에 있으므로, 컴파일 결과에 넣지 말아야 한다는 것을 #[cfg(test)]로 알려 줍니다.
테스트 작성 방법에서 adder 프로젝트를 만들 때 Cargo가 만들어 준 코드를 다시 봅니다.
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 모듈에서 cfg 속성은 설정(configuration)을 뜻하며, 뒤따르는 아이템을 특정한 설정 옵션이 있을 때만 넣으라고 러스트에게 알려 줍니다. 여기서 설정 옵션은 러스트가 테스트를 컴파일하고 실행할 때 켜는 test입니다. cfg 속성을 썼으므로 Cargo는 cargo test로 테스트를 실행할 때만 테스트 코드를 컴파일합니다. 여기에는 #[test]가 붙은 함수뿐 아니라 이 모듈 안에 있을 수 있는 도우미 함수도 모두 들어갑니다.
비공개 함수 테스트하기
비공개 함수를 직접 테스트해야 하는지를 두고 테스트 커뮤니티의 의견은 갈리며, 다른 언어에서는 비공개 함수를 테스트하기가 어렵거나 불가능하기도 합니다. 어떤 테스트 철학을 따르든, 러스트의 공개 규칙은 비공개 함수를 테스트할 수 있게 해 줍니다. 다음은 비공개 함수 internal_adder가 있는 코드입니다.
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);
}
}internal_adder 함수에는 pub이 붙어 있지 않습니다. 테스트는 그냥 러스트 코드이고 tests 모듈도 그냥 또 하나의 모듈일 뿐입니다. 모듈 트리의 아이템을 가리키는 경로에서 보았듯 자식 모듈의 아이템은 조상 모듈의 아이템을 쓸 수 있습니다. 이 테스트에서는 use super::*로 tests 모듈의 부모에 있는 아이템을 모두 스코프로 가져오므로, 테스트에서 internal_adder를 부를 수 있습니다. 비공개 함수를 테스트하지 않아야 한다고 생각한다면, 러스트가 그렇게 하라고 강요하지는 않습니다.
통합 테스트
러스트에서 통합 테스트는 라이브러리 완전히 바깥에 있습니다. 통합 테스트는 다른 코드와 같은 방식으로 라이브러리를 쓰므로, 라이브러리의 공개 API에 있는 함수만 부를 수 있습니다. 통합 테스트의 목적은 라이브러리의 여러 부분이 함께 올바르게 동작하는지 확인하는 것입니다. 따로 실행하면 잘 동작하는 코드 단위들도 함께 쓰면 문제가 생길 수 있으므로, 통합된 코드를 테스트하는 것도 중요합니다. 통합 테스트를 만들려면 먼저 tests 폴더가 있어야 합니다.
tests 디렉터리
프로젝트 폴더의 맨 위, src 옆에 tests 폴더를 만듭니다. Cargo는 이 폴더에서 통합 테스트 파일을 찾습니다. 통합 테스트 파일은 원하는 만큼 만들 수 있으며, Cargo는 각 파일을 개별 크레이트로 컴파일합니다. 앞의 add_two 코드를 src/lib.rs에 둔 채로 tests 폴더를 만들고 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);
}tests 폴더의 각 파일은 별개의 크레이트이므로, 각 테스트 크레이트의 스코프로 라이브러리를 가져와야 합니다. 그래서 유닛 테스트에서는 필요하지 않던 use adder::add_two;를 코드 맨 위에 둡니다. tests/integration_test.rs의 코드에는 #[cfg(test)]를 붙이지 않아도 됩니다. Cargo는 tests 폴더를 특별하게 다루어, 이 폴더의 파일은 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결과는 유닛 테스트, 통합 테스트, 문서 테스트의 세 부분으로 나옵니다. 괄호 안의 실행 파일 이름 뒤에 붙은 해시와 걸린 시간은 환경마다 다릅니다. 어떤 부분에서든 테스트가 하나라도 실패하면 그다음 부분은 실행되지 않습니다. 예를 들어 유닛 테스트가 실패하면 통합 테스트와 문서 테스트의 결과는 나오지 않는데, 그 테스트들은 유닛 테스트가 모두 통과해야 실행되기 때문입니다.
유닛 테스트의 첫 부분은 지금까지 보던 것과 같습니다. 유닛 테스트마다 한 줄이 나오고, 그 뒤에 유닛 테스트의 요약 줄이 나옵니다. 통합 테스트 부분은 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 파일의 테스트만 실행합니다. 마찬가지로 cargo test --lib는 라이브러리의 유닛 테스트만, cargo test --doc은 문서 테스트만 실행합니다.
통합 테스트 내 서브 모듈
통합 테스트를 늘려 가다 보면 정리를 위해 tests 폴더에 파일을 더 만들고 싶어질 것입니다. 예를 들어 테스트하는 기능에 따라 테스트 함수를 묶을 수 있습니다. 앞에서 말했듯 tests 폴더의 파일은 각각 별개의 크레이트로 컴파일되므로, 사용자가 크레이트를 쓰는 방식에 더 가깝게 각자 따로 된 스코프를 만들 수 있습니다. 다만 이 때문에 tests 폴더의 파일은 src의 파일과 같은 동작을 공유하지 않습니다. 코드를 모듈과 파일로 나누는 방법은 별개의 파일로 모듈 분리하기에서 보았습니다.
tests 폴더의 파일이 다르게 동작한다는 것은, 여러 통합 테스트 파일에서 쓸 도우미 함수가 있을 때 가장 잘 드러납니다. 별개의 파일로 모듈 분리하기의 방법대로 이 함수들을 공통 모듈로 뽑아낸다고 합시다. 예를 들어 tests/common.rs를 만들고 여러 테스트 파일의 여러 테스트 함수에서 부를 setup 함수를 둡니다.
pub fn setup() {
// 라이브러리 테스트에 필요한 준비 코드
}테스트를 다시 실행하면, common.rs에는 테스트 함수가 하나도 없고 어디에서도 setup 함수를 부르지 않았는데도 결과에 common.rs의 새 부분이 나옵니다. 출력의 Running tests/common.rs 줄은 Cargo가 이 파일도 다른 통합 테스트 파일처럼 하나의 테스트 대상으로 실행했다는 뜻입니다.
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결과에 common이 나오고 그 아래에 running 0 tests가 나오는 것은 바라던 바가 아닙니다. 다른 통합 테스트 파일과 코드를 나누어 쓰고 싶었을 뿐입니다. 아래 그림은 공용 코드를 어디에 두어야 독립된 테스트 대상이 되지 않는지를 보여 줍니다.
TREE · TEST DIRECTORY
통합 테스트 파일과 공용 헬퍼의 디렉터리 구조
tests 바로 아래의 Rust 파일은 기본 자동 탐색에서 각각 통합 테스트 대상이 된다. 공용 설정 코드는 tests/common/mod.rs에 두고 각 대상이 mod common;으로 가져온다.
- 프로젝트 루트
src와tests는 나란히 있으며 Cargo는tests의 최상위 Rust 파일을 통합 대상으로 찾습니다. - 별도 대상
tests/foo.rs와tests/bar.rs는 각각 독립된 테스트 크레이트로 컴파일됩니다. - 공용 모듈
tests/common/mod.rs는 최상위 대상이 아니라 가져다 쓰는 모듈입니다. - 명시적 연결
각 테스트 파일에
mod common;을 쓰고common::setup()을 호출합니다. - 선택 실행
cargo test --test foo는foo.rs대상만 선택합니다.
common/mod.rs는 유용한 관례다. 공유 모듈 배치는 가능하지만, 최상위 common.rs를 두면 기본 자동 탐색에서 또 하나의 통합 테스트 대상으로 발견된다.
tests/foo.rs와 tests/bar.rs처럼 tests 바로 아래에 있는 파일은 기본 자동 탐색에서 각각 별도 대상이 됩니다. 공용 코드는 tests/common/mod.rs에 두고, 각 대상이 mod common;으로 선언한 뒤 common::setup()을 부릅니다. 그림의 선은 폴더에 무엇이 들어 있는지를 나타낼 뿐 실행 순서가 아닙니다. cargo test --test foo는 foo.rs 대상만 고릅니다. 결과에 common이 나오지 않게 하려면 tests/common.rs 대신 tests/common/mod.rs를 만들면 되며, 그러면 폴더 구조는 다음과 같습니다.
이것은 별개의 파일로 모듈 분리하기에서 말한, 러스트가 함께 알아듣는 예전 이름 규칙입니다. 파일을 이렇게 이름 지으면 러스트는 common 모듈을 통합 테스트 파일로 다루지 않습니다. setup 함수의 코드를 tests/common/mod.rs로 옮기고 tests/common.rs를 지우면, 결과에 그 부분이 더는 나오지 않습니다. tests 폴더의 하위 폴더에 있는 파일은 별개의 크레이트로 컴파일되지 않고 결과에 부분도 생기지 않습니다. 다만 Cargo는 tests/이름/main.rs 형태는 통합 테스트 대상으로 찾으므로, 하위 폴더라고 해서 모두 빠지는 것은 아닙니다. 자세한 규칙은 Cargo의 통합 테스트 대상 설명에 있습니다.
tests/common/mod.rs를 만든 뒤에는 어느 통합 테스트 파일에서든 이를 모듈로 쓸 수 있습니다. 다음은 tests/integration_test.rs의 it_adds_two 테스트에서 setup 함수를 부르는 예입니다.
use adder::add_two;
mod common;
#[test]
fn it_adds_two() {
common::setup();
let result = add_two(2);
assert_eq!(result, 4);
}mod common; 선언은 별개의 파일로 모듈 분리하기에서 본 모듈 선언과 같습니다. 이렇게 선언하면 테스트 함수에서 common::setup()을 부를 수 있습니다. common 모듈은 이것을 선언한 통합 테스트 크레이트마다 그 안에 따로 컴파일됩니다. 그러므로 tests/foo.rs와 tests/bar.rs가 같은 common 모듈을 써도, 두 대상은 별개의 실행 파일이라서 common 안의 상태를 함께 쓰지 않습니다.
바이너리 크레이트에서의 통합 테스트
프로젝트가 src/lib.rs 없이 src/main.rs만 있는 바이너리 크레이트라면, tests 폴더에 통합 테스트를 만들고 use 문으로 src/main.rs에 정의한 함수를 스코프로 가져올 수 없습니다. 라이브러리 크레이트만 다른 크레이트가 쓸 수 있도록 함수를 내보내며, 바이너리 크레이트는 그 자체로 실행하기 위한 것입니다.
이것이 바이너리를 제공하는 러스트 프로젝트가 대개 src/main.rs를 단순하게 두고, 로직을 src/lib.rs에 두어 그것을 부르게 하는 까닭 가운데 하나입니다. 그렇게 구성하면 통합 테스트에서 use로 라이브러리 크레이트를 가져와 중요한 기능을 테스트할 수 있습니다. 중요한 기능이 동작한다면 src/main.rs의 적은 코드도 동작할 것이고, 그 적은 코드는 따로 테스트하지 않아도 됩니다.
실행 파일 자체가 명령줄 인수를 받아 무엇을 출력하는지까지 확인하고 싶다면, 통합 테스트에서 실행 파일을 별도 프로세스로 실행합니다. Cargo는 통합 테스트를 빌드할 때 바이너리도 함께 빌드하고, 그 경로를 CARGO_BIN_EXE_<이름> 환경 변수로 알려 줍니다.
use std::process::Command;
#[test]
fn runs_binary() {
let output = Command::new(env!("CARGO_BIN_EXE_adder"))
.output()
.expect("failed to run binary");
assert!(output.status.success());
}env! 매크로가 컴파일할 때 adder 바이너리의 경로를 읽어 오고, Command가 그 실행 파일을 실행해 종료 상태와 출력을 돌려줍니다. 이 테스트는 src/main.rs가 있는 패키지에서만 컴파일되며, <이름> 자리에는 바이너리 대상의 이름을 씁니다.
더 깊이: cargo test가 만드는 실행 파일들
#[test] 함수가 실행되는 것은 Cargo가 테스트 대상을 rustc --test로 컴파일하기 때문입니다. rustc 문서에 따르면 이 옵션은 크레이트를 실행 파일로 빌드하면서 표준 라이브러리의 테스트 하네스(test harness)인 libtest를 연결하고, 명령줄 인수를 처리해 테스트를 실행하는 main 함수를 새로 만들어 넣습니다. 원래의 main도 컴파일은 되지만 시작점은 이 새 main이 되며, 같은 옵션이 test 설정 옵션을 켜기 때문에 #[cfg(test)] 모듈이 이때만 들어갑니다.
Cargo 문서는 tests 폴더의 통합 테스트 파일마다 별도의 실행 파일이 만들어지고, cargo test가 그 실행 파일들을 차례로 실행한다고 설명합니다. 그래서 통합 테스트 파일이 많으면 컴파일과 링크가 그만큼 늘어나고, 실행할 때도 여러 CPU를 다 쓰지 못할 수 있습니다. 문서는 통합 테스트가 많다면 통합 테스트 하나를 만들고 그 안을 여러 모듈로 나누는 방법을 권합니다. 그러면 하네스가 모든 #[test] 함수를 찾아 병렬로 실행하고, 모듈 이름으로 실행 범위를 좁힐 수도 있습니다.
테스트 대상의 Cargo.toml 설정에 harness = false를 적으면 --test를 넘기지 않으므로, 테스트를 실행하는 main을 직접 써야 합니다. 이 방법은 다른 테스트 프레임워크를 쓰거나 실행 방식을 직접 정해야 할 때 씁니다. 하네스를 끄더라도 테스트 대상에는 cfg(test)가 켜진다는 점은 같습니다.
정리
러스트의 테스트 기능은 코드를 바꾸더라도 기대한 대로 계속 동작하는지 확인할 방법을 줍니다. 유닛 테스트는 라이브러리의 서로 다른 부분을 따로 실행하며 비공개 구현도 테스트할 수 있습니다. 통합 테스트는 라이브러리의 여러 부분이 함께 올바르게 동작하는지 확인하며, 외부 코드가 쓰는 방식 그대로 라이브러리의 공개 API로 코드를 테스트합니다. 러스트의 타입 시스템과 소유권 규칙이 몇몇 종류의 버그를 막아 주지만, 코드가 기대한 대로 동작하지 않는 논리 버그를 줄이려면 테스트도 중요합니다.
- 유닛 테스트는
src의 각 파일에#[cfg(test)] mod tests로 두며,cargo test를 실행할 때만 컴파일됩니다. tests모듈은 자식 모듈이므로use super::*로 비공개 함수도 테스트할 수 있습니다.tests폴더 바로 아래의 각 파일은 별개의 크레이트로 컴파일되는 통합 테스트이며, 공개 API만 쓸 수 있습니다.- 통합 테스트끼리 함께 쓸 코드는
tests/common/mod.rs에 두고 각 파일에서mod common;으로 선언합니다. cargo test --test 이름은 그 통합 테스트 파일만,--lib는 유닛 테스트만 실행합니다.- 바이너리 크레이트는 로직을
src/lib.rs에 두어야 통합 테스트에서 가져다 쓸 수 있습니다.
연습 문제
1. 어디에 둘까
(가) 비공개 함수 parse_header의 경곗값 처리, (나) 공개 함수 load와 save를 이어서 부를 때의 동작, (다) 실행 파일이 잘못된 인수를 받으면 0이 아닌 종료 상태로 끝나는지를 확인하려 합니다. 각 테스트는 어디에 두나요?
해설 보기
(가)는 비공개 함수를 불러야 하므로 그 함수가 있는 파일의 #[cfg(test)] mod tests에 둡니다. (나)는 외부 코드처럼 공개 API를 이어서 쓰는 것이므로 tests 폴더의 통합 테스트로 둡니다. (다)는 실행 파일의 동작이므로 통합 테스트에서 CARGO_BIN_EXE_<이름>의 경로로 실행 파일을 별도 프로세스로 실행하고 종료 상태를 확인합니다.
2. 몇 개가 실행될까
src/lib.rs에 유닛 테스트 하나, tests/api.rs에 테스트 두 개, tests/io.rs에 테스트 하나가 있고, tests/common/mod.rs에는 setup 함수만 있습니다. cargo test --test api와 cargo test --lib는 각각 무엇을 실행하나요? tests/api.rs에서 비공개 internal_adder를 직접 부를 수 있나요?
해설 보기
--test api는 tests/api.rs로 만든 실행 파일의 테스트 두 개를 실행하고, --lib는 라이브러리의 유닛 테스트 하나를 실행합니다. tests/common/mod.rs는 하위 폴더에 있으므로 따로 대상이 되지 않습니다. 통합 테스트는 외부 크레이트이므로 pub이 없는 internal_adder는 부를 수 없으며, 유닛 테스트에서 확인하거나 공개 함수 add_two를 통해 확인합니다.
이것으로 10장을 마칩니다. 다음 장의 커맨드 라인 인수 받기부터는 이번 장과 앞 장들에서 배운 것을 모아, 파일에서 문자열을 찾는 명령줄 프로그램을 만들어 봅니다.