테스트 작성 방법
테스트 함수와 assert 계열 매크로로 결과·동등성·패닉을 검증하고 Result를 반환하는 실패 검사 방식을 익힙니다.
에츠허르 다익스트라(Edsger W. Dijkstra)는 1972년 에세이 「겸손한 프로그래머(The Humble Programmer)」에서, 프로그램 테스트는 버그가 있다는 것을 보여 주는 데는 아주 효과적이지만 버그가 없다는 것을 보여 주기에는 턱없이 부족하다고 말했습니다. 그렇다고 할 수 있는 만큼 테스트하지 말라는 뜻은 아닙니다. 프로그램이 올바르다는 것은 코드가 의도한 대로 동작한다는 뜻이며, 러스트는 프로그램이 올바르도록 많은 신경을 써서 설계되었지만, 올바름을 증명하기는 복잡하고 쉽지 않습니다. 러스트의 타입 시스템이 이 일의 큰 부분을 맡지만 모든 것을 잡아내지는 못하므로, 러스트는 자동화된 소프트웨어 테스트를 쓰는 기능을 언어에서 지원합니다.
예를 들어 넘겨받은 수에 2를 더하는 add_two 함수를 쓴다고 합시다. 시그니처는 정수를 매개변수로 받아 정수를 돌려주며, 이 함수를 구현하고 컴파일하면 러스트가 지금까지 본 타입 검사와 대여 검사를 모두 해서 String이나 올바르지 않은 참조를 넘기지 못하게 해 줍니다. 하지만 러스트는 이 함수가 정확히 우리가 의도한 일, 곧 매개변수에 10이나 -50이 아니라 2를 더한 값을 돌려주는지는 확인할 수 없습니다. 그럴 때 테스트를 씁니다. add_two에 3을 넘기면 5가 돌아온다고 단언(assert)하는 테스트를 쓰고, 코드를 고칠 때마다 이 테스트를 실행해 올바르던 동작이 바뀌지 않았는지 확인할 수 있습니다.
테스트는 복잡한 기술이어서, 좋은 테스트를 쓰는 방법을 이 장에서 모두 다룰 수는 없습니다. 10장에서는 러스트의 테스트 기능, 곧 테스트를 쓸 때 쓰는 표기와 매크로, 테스트를 실행할 때의 기본 동작과 옵션, 테스트를 유닛 테스트와 통합 테스트로 정리하는 방법을 다룹니다. 아래 그림은 테스트를 만들어 실행하고, 실패하면 범위를 좁혀 다시 실행하는 흐름입니다.
Rust 테스트 작성부터 실패 근거 재확인까지
대상 선택/컴파일→준비→실행→단언 통과/실패→요약/필터 재실행의 실제 검증 작업이다.
Rust · cargo test · flowchart
테스트는 범위를 고르고, 계약을 실행하고, 근거로 다시 좁힌다
컴파일 성공은 타입·대여 규칙을, 테스트 결과는 기대 동작을 확인합니다. Cargo가 고른 패키지·대상의 테스트 코드를 컴파일하면 테스트 실행기가 #[test] 함수를 발견하고, 준비, 실행, 단언의 결과를 선택된 집합의 요약으로 보고합니다.
scope · discovery
1. 실행할 패키지·대상을 고르고 컴파일
Cargo는 선택된 패키지와 테스트 대상을 컴파일합니다. 일반 테스트 실행 바이너리에서는 #[test] 함수가 실행 대상으로 발견됩니다.
arrange
2. 입력과 상태 준비
테스트가 필요한 입력, fixture, 초기 상태를 작고 독립적인 범위에 둡니다.
act
3. 검사할 동작 실행
대상 함수나 메서드를 호출해 실제 값 또는 실패 결과를 얻습니다.
assert
4. 기대 계약과 실제 결과 비교
bool, 동등성, 예상한 패닉, 또는 반환형의 Termination 결과로 통과와 실패를 가릅니다.
evidence
5. 통과·실패 근거 읽기
참, 예상한 패닉, Ok(())는 계약에 따라 통과할 수 있습니다. 거짓, 예상 밖 패닉, 예상한 패닉의 부재나 expected 문자열 불일치, Err(E)는 실패 근거가 됩니다.
summary · rerun
6. 선택된 집합을 요약하고 다시 좁히기
cargo test name_filter는 전체 테스트 경로의 부분 문자열 필터이므로 여러 테스트를 고를 수 있습니다. 각 테스트 실행 바이너리 안의 함수들은 기본적으로 병렬 실행됩니다.
여러 일반 테스트 대상은 별도 실행 바이너리로 순차 실행되지만, 각 바이너리 안의 테스트 함수는 기본적으로 병렬 실행됩니다. 공유 상태나 완료 순서에 의존하지 말고, 직렬 실행이 필요하면 cargo test -- 뒤에 --test-threads=1을 전달합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 본문·주석 | 테스트에 필요한 입력, fixture, 초기 상태를 작고 독립적인 범위에 둡니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 대상 컴파일 선택한 테스트 대상을 컴파일한다. |
|
|
| 실행·단언 준비한 값으로 동작하고 assert를 확인한다. |
|
|
| 요약과 재실행 실패 범위를 좁혀 같은 계약을 다시 검사한다. |
|
|
Rust 테스트 작성부터 실패 근거 재확인까지
대상 선택/컴파일→준비→실행→단언 통과/실패→요약/필터 재실행의 실제 검증 작업이다.
Rust · cargo test · flowchart
테스트는 범위를 고르고, 계약을 실행하고, 근거로 다시 좁힌다
컴파일 성공은 타입·대여 규칙을, 테스트 결과는 기대 동작을 확인합니다. Cargo가 고른 패키지·대상의 테스트 코드를 컴파일하면 테스트 실행기가 #[test] 함수를 발견하고, 준비, 실행, 단언의 결과를 선택된 집합의 요약으로 보고합니다.
scope · discovery
1. 실행할 패키지·대상을 고르고 컴파일
Cargo는 선택된 패키지와 테스트 대상을 컴파일합니다. 일반 테스트 실행 바이너리에서는 #[test] 함수가 실행 대상으로 발견됩니다.
arrange
2. 입력과 상태 준비
테스트가 필요한 입력, fixture, 초기 상태를 작고 독립적인 범위에 둡니다.
act
3. 검사할 동작 실행
대상 함수나 메서드를 호출해 실제 값 또는 실패 결과를 얻습니다.
assert
4. 기대 계약과 실제 결과 비교
bool, 동등성, 예상한 패닉, 또는 반환형의 Termination 결과로 통과와 실패를 가릅니다.
evidence
5. 통과·실패 근거 읽기
참, 예상한 패닉, Ok(())는 계약에 따라 통과할 수 있습니다. 거짓, 예상 밖 패닉, 예상한 패닉의 부재나 expected 문자열 불일치, Err(E)는 실패 근거가 됩니다.
summary · rerun
6. 선택된 집합을 요약하고 다시 좁히기
cargo test name_filter는 전체 테스트 경로의 부분 문자열 필터이므로 여러 테스트를 고를 수 있습니다. 각 테스트 실행 바이너리 안의 함수들은 기본적으로 병렬 실행됩니다.
여러 일반 테스트 대상은 별도 실행 바이너리로 순차 실행되지만, 각 바이너리 안의 테스트 함수는 기본적으로 병렬 실행됩니다. 공유 상태나 완료 순서에 의존하지 말고, 직렬 실행이 필요하면 cargo test -- 뒤에 --test-threads=1을 전달합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 본문·주석 | 테스트에 필요한 입력, fixture, 초기 상태를 작고 독립적인 범위에 둡니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 대상 컴파일 선택한 테스트 대상을 컴파일한다. |
|
|
| 실행·단언 준비한 값으로 동작하고 assert를 확인한다. |
|
|
| 요약과 재실행 실패 범위를 좁혀 같은 계약을 다시 검사한다. |
|
|
cargo test는 먼저 고른 패키지의 테스트 대상을 컴파일하고, 테스트 실행기가 테스트 함수를 모두 찾아 각각 실행합니다. 기본으로는 여러 테스트를 동시에 실행하며, 이 동작은 테스트 실행 제어에서 다룹니다. 테스트 함수는 대개 본문에서 세 가지 일을 합니다. 필요한 데이터나 상태를 준비하고, 테스트할 코드를 실행하고, 결과가 기대한 대로인지 단언합니다. 단언이 참이면 통과, 거짓이거나 예상하지 못한 패닉이 나면 실패이며, 실패하면 그 테스트만 골라 다시 실행하며 원인을 좁힙니다. 이 글에서는 test 속성과 assert! 매크로를 보고, 이어지는 두 글에서 assert_eq!와 assert_ne!, 그리고 should_panic 속성과 Result를 돌려주는 테스트를 다룹니다.
테스트 함수 파헤치기
가장 간단하게 말하면 러스트의 테스트는 test 속성을 붙인 함수입니다. 속성(attribute)은 러스트 코드 조각에 대한 메타데이터로, 4장에서 구조체에 붙인 derive도 속성입니다. 함수를 테스트 함수로 바꾸려면 fn 앞줄에 #[test]를 붙입니다. cargo test로 테스트를 실행하면, 러스트는 test 속성을 붙인 함수를 실행하고 각 테스트가 통과했는지 실패했는지 알려 주는 테스트 실행 바이너리를 만듭니다.
Cargo로 새 라이브러리 프로젝트를 만들 때마다 테스트 함수가 하나 든 테스트 모듈이 함께 만들어집니다. 이 모듈이 테스트를 쓰는 틀을 주므로, 새 프로젝트를 시작할 때마다 정확한 구조와 문법을 찾아볼 필요가 없습니다. 테스트 함수와 테스트 모듈은 원하는 만큼 더할 수 있습니다. 먼저 이 틀의 테스트로 테스트가 어떻게 동작하는지 알아본 뒤, 우리가 쓴 코드를 부르고 그 동작이 맞는지 단언하는 실제 테스트를 써 봅니다. 두 수를 더하는 adder라는 라이브러리 프로젝트를 만듭니다.
cargo new adder --lib
cd adderadder 라이브러리의 src/lib.rs에는 다음 내용이 들어 있습니다. --lib로 만들면 cargo new는 src/main.rs 대신 이 파일을 만들고, 예제 함수 add와 그 함수를 검사하는 테스트 하나를 미리 넣어 둡니다.
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);
}
}#[test]는 이 함수가 테스트 함수라는 표시이므로, 테스트 실행기가 이 함수를 테스트로 다룹니다. tests 모듈 안에는 흔한 상황을 준비하거나 자주 쓰는 연산을 하는 테스트가 아닌 함수도 둘 수 있으므로, 어느 함수가 테스트인지 언제나 표시해야 합니다. #[cfg(test)]와 use super::*;는 테스트 조직화에서 설명합니다. 이 함수의 본문은 add로 2와 2를 더한 result가 4와 같다고 assert_eq! 매크로로 단언합니다. 이 단언은 흔한 테스트 형식의 예입니다. 테스트를 실행해 통과하는지 봅니다.
cargo test Compiling adder v0.1.0 (file:///projects/adder)
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.57s
Running unittests src/lib.rs (target/debug/deps/adder-01f4eac7a4f1ab01)
running 1 test
test tests::it_works ... 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경로 끝의 해시와 시간은 실행할 때마다 다를 수 있습니다. Cargo가 테스트를 컴파일하고 실행했으며, running 1 test 다음 줄에 만들어진 테스트 함수의 이름 tests::it_works와 그 결과 ok가 나옵니다. 전체 요약 test result: ok.는 모든 테스트가 통과했다는 뜻이고, 1 passed; 0 failed는 통과한 테스트와 실패한 테스트의 수입니다.
테스트를 무시하도록 표시해 특정한 경우에 실행되지 않게 할 수 있는데, 이는 특별 요청이 없다면 일부 테스트 무시하기에서 다루며, 여기서는 그런 테스트가 없으므로 요약에 0 ignored가 나옵니다. cargo test에 인수를 넘기면 이름이 그 문자열과 맞는 테스트만 실행할 수 있는데, 이를 필터링이라고 하며 이름을 지정해 일부 테스트만 실행하기에서 다룹니다. 여기서는 필터링하지 않았으므로 0 filtered out이 나옵니다. 0 measured는 성능을 재는 벤치마크 테스트의 수인데, 지금 벤치마크 테스트는 나이틀리(nightly) 러스트에서만 쓸 수 있습니다. 자세한 내용은 벤치마크 테스트 문서에 있습니다.
Doc-tests adder로 시작하는 다음 부분은 문서 테스트의 결과입니다. 아직 문서 테스트는 없지만, 러스트는 API 문서에 적은 코드 예제를 컴파일할 수 있어서 문서와 코드가 어긋나지 않게 해 줍니다. 문서 테스트를 쓰는 방법은 테스트로서의 문서화 주석에서 다루며, 지금은 Doc-tests 부분을 무시합니다.
이제 테스트를 우리에게 맞게 바꿔 봅니다. 먼저 it_works 함수의 이름을 exploration으로 바꿉니다.
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exploration() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}다시 cargo test를 실행하면 결과에 it_works 대신 test tests::exploration ... ok가 나옵니다. 이번에는 실패하는 테스트를 더해 봅니다. 테스트 함수 안에서 무언가 패닉을 내면 그 테스트는 실패합니다. 테스트는 저마다 새 스레드에서 실행되며, 메인 스레드는 테스트 스레드가 죽은 것을 보고 그 테스트를 실패로 표시합니다. 8장에서 본 것처럼 패닉을 내는 가장 간단한 방법은 panic! 매크로를 부르는 것이므로, another라는 테스트를 더합니다.
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exploration() {
let result = add(2, 2);
assert_eq!(result, 4);
}
#[test]
fn another() {
panic!("Make this test fail");
}
}running 2 tests
test tests::another ... FAILED
test tests::exploration ... ok
failures:
---- tests::another stdout ----
thread 'tests::another' (6017887) panicked at src/lib.rs:17:9:
Make this test fail
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::another
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`test tests::another 줄은 ok 대신 FAILED를 보여 주며, 개별 결과와 요약 사이에 두 부분이 새로 나옵니다. 첫 부분은 테스트마다 실패한 자세한 까닭을 보여 주며, another가 src/lib.rs의 17번째 줄에서 Make this test fail 패닉으로 실패했다는 것을 알려 줍니다. 괄호 안의 숫자는 그 테스트를 실행한 스레드의 ID입니다. 다음 부분은 실패한 테스트의 이름만 모아 보여 주므로, 테스트와 실패 출력이 많을 때 쓸모가 있습니다. 실패한 테스트의 이름을 써서 그 테스트만 실행하면 디버깅이 쉬워지며, 테스트를 실행하는 여러 방법은 테스트 실행 제어에서 다룹니다. 마지막 요약 줄은 전체 결과가 FAILED이고, 테스트 하나는 통과하고 하나는 실패했다고 알려 줍니다.
assert! 매크로로 결과 검사하기
표준 라이브러리의 assert! 매크로는 테스트에서 어떤 조건이 참인지 확인할 때 쓸모가 있습니다. assert!에는 불리언으로 계산되는 인수를 줍니다. 값이 true이면 아무 일도 일어나지 않고 테스트가 통과하며, false이면 assert!가 panic!을 불러 테스트가 실패합니다. assert!로 코드가 의도대로 동작하는지 확인할 수 있습니다.
메서드 문법의 Rectangle 구조체와 can_hold 메서드를 src/lib.rs에 넣고, assert!로 테스트를 써 봅니다.
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
}can_hold는 불리언을 돌려주므로 assert!에 쓰기 좋습니다. 너비 8, 높이 7인 사각형이 너비 5, 높이 1인 사각형을 담을 수 있는지 확인하는 테스트와, 반대로 작은 사각형은 큰 사각형을 담을 수 없는지 확인하는 테스트를 tests 모듈에 둡니다.
#[cfg(test)]
mod tests {
use super::*;
#[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));
}
#[test]
fn smaller_cannot_hold_larger() {
let larger = Rectangle {
width: 8,
height: 7,
};
let smaller = Rectangle {
width: 5,
height: 1,
};
assert!(!smaller.can_hold(&larger));
}
}tests 모듈 안에 use super::*;를 두었습니다. tests 모듈은 모듈 경로와 아이템 참조의 가시성 규칙을 따르는 보통의 모듈이며 안쪽 모듈이므로, 바깥 모듈의 테스트할 코드를 안쪽 모듈의 스코프로 가져와야 합니다. 여기서 글롭을 써서 바깥 모듈에 정의한 것을 모두 tests 모듈에서 쓸 수 있게 했습니다. 둘째 테스트에서는 can_hold가 false를 돌려야 하므로, !로 뒤집은 결과를 assert!에 넘겼습니다. 두 테스트 모두 통과합니다.
running 2 tests
test tests::larger_can_hold_smaller ... ok
test tests::smaller_cannot_hold_larger ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s코드에 버그를 넣으면 어떻게 되는지 봅니다. 너비를 비교할 때 >를 <로 바꿔 can_hold를 고칩니다.
impl Rectangle {
fn can_hold(&self, other: &Rectangle) -> bool {
self.width < other.width && self.height > other.height
}
}running 2 tests
test tests::larger_can_hold_smaller ... FAILED
test tests::smaller_cannot_hold_larger ... ok
failures:
---- tests::larger_can_hold_smaller stdout ----
thread 'tests::larger_can_hold_smaller' (6017887) panicked at src/lib.rs:28:9:
assertion failed: larger.can_hold(&smaller)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace테스트가 버그를 잡아냈습니다. larger.width는 8이고 smaller.width는 5이므로, 버그가 든 can_hold의 너비 비교 8 < 5가 거짓이 되어 false를 돌려줍니다. assert!는 실패하면 단언한 식을 메시지로 보여 주므로, 어떤 조건이 깨졌는지 바로 알 수 있습니다.
정리
- 러스트의 테스트는
#[test]를 붙인 함수이며,cargo test가 테스트를 컴파일하고 실행해 결과를 보여 줍니다. cargo new --lib는#[cfg(test)]아래의tests모듈에 틀이 되는 테스트를 만들어 줍니다.- 테스트는 저마다 새 스레드에서 실행되며, 그 안에서 패닉이 나면 실패합니다.
- 결과 요약은 통과, 실패, 무시, 측정, 필터로 빠진 테스트의 수를 보여 주고, 실패한 테스트는 까닭과 이름을 따로 모아 보여 줍니다.
assert!(조건)은 조건이 거짓이면 그 식을 메시지로 보여 주며 테스트를 실패시킵니다.tests모듈은 보통의 안쪽 모듈이므로use super::*;로 바깥 모듈의 코드를 가져옵니다.
연습 문제
1. 버그를 넣었을 때
can_hold의 높이 비교를 >에서 >=로 바꾸면 두 테스트는 어떻게 되나요? 그 결과로 버그가 없다고 할 수 있나요?
해설 보기
두 테스트 모두 통과합니다. 큰 사각형의 높이 7과 작은 사각형의 높이 1은 같지 않으므로 >와 >=의 결과가 같기 때문입니다. 테스트가 통과했다고 버그가 없는 것은 아니며, 높이가 같은 두 사각형을 비교하는 테스트를 더해야 이 차이를 잡을 수 있습니다. 다익스트라의 말처럼 테스트는 버그가 있다는 것은 보여 주지만 없다는 것은 보여 주지 못합니다.
2. 실패 출력 읽기
어떤 테스트가 thread 'tests::parses_header' (12345) panicked at src/lib.rs:40:9:와 assertion failed: header.is_valid()를 출력했습니다. 무엇을 알 수 있나요?
해설 보기
tests 모듈의 parses_header 테스트가 src/lib.rs의 40번째 줄에서 실패했고, 그 줄의 assert!(header.is_valid())에서 is_valid()가 false를 돌려주었다는 것을 알 수 있습니다. 괄호 안의 숫자는 테스트 스레드의 ID입니다. 그 테스트만 다시 실행하려면 cargo test parses_header처럼 이름으로 고릅니다.
다음 글 동등성 검사와 실패 메시지에서는 두 값이 같은지 확인하는 assert_eq!와 assert_ne!, 그리고 실패했을 때 보여 줄 메시지를 직접 정하는 방법을 살펴봅니다.