RUST · 심층 가이드
Rust 테스트 완전 정리
단언과 테이블 기반 테스트, 경계값, 목 객체, 비동기 테스트, 통합 테스트 스케치까지 Rust 테스트 15가지 형태를 예제와 함께 정리했습니다.
Rust는 테스트 도구를 언어와 빌드 도구에 내장해 두었습니다. 별도 프레임워크를 고르는 단계가 없고 #[test]를 붙인 함수와 cargo test만으로 시작됩니다. 테스트를 소스 파일 안에 함께 두는 관행 덕분에 비공개 함수도 그대로 검증할 수 있는데, 이는 테스트를 별도 디렉터리로 강제하는 언어들과 뚜렷하게 다른 점입니다.
동등성 단언으로 감을 잡은 뒤 테이블 기반 테스트와 매개변수화 테스트를 보면 입력 묶음을 한 번에 돌리는 방식이 정리됩니다. 경계값 테스트는 여기에 붙여 읽을 때 효과가 좋습니다. 시계 목 객체와 저장소 목 객체는 Rust에 목 프레임워크가 기본 탑재돼 있지 않다는 사실을 드러내는데, 대신 트레이트로 경계를 긋고 테스트용 구현을 끼우는 방식이 표준에 가깝습니다.
놓치기 쉬운 점은 cargo test가 테스트를 여러 스레드에서 동시에 돌린다는 것입니다. 환경 변수를 바꾸거나 같은 임시 파일 이름을 쓰거나 전역 상태를 건드리는 테스트가 둘 이상이면, 각각은 통과하는데 함께 돌리면 간헐적으로 실패합니다. 공유 자원을 쓰는 테스트는 이름을 겹치지 않게 하거나 직렬 실행을 강제해야 합니다.
01동등성 단언
assert!(1 + 1 > 1)로 조건이 참인지만 확인하고, 거짓이면 패닉을 내며 멈춥니다. 값이 서로 같은지 보는 자리라면 assert_eq!가 더 낫습니다. 실패했을 때 좌우 값을 함께 찍어 주기 때문에, 조건이 깨졌다는 사실만 알려 주는 assert!보다 원인을 좁히기 쉽습니다.
Rust code
// 동등성 단언
fn main() {
assert!(1 + 1 > 1);
}assert_eq!는 실패했을 때 좌우 값을 함께 찍어 주므로, 조건이 깨졌다는 사실만 알려 주는 assert!보다 원인을 좁히기 쉽습니다.
상수끼리만 비교하는 단언은 언제나 참이라 코드가 아무리 바뀌어도 실패하지 않으므로, 검사가 있는 것처럼 보이지만 실제로는 빈 껍데기입니다.
02테이블 기반 테스트
입력 표를 도는 대신 0..1000을 빈 몸통으로 반복하고 걸린 시간을 출력합니다. 사례를 표로 묶을 때는 배열에 입력과 기대값 쌍을 담고 for로 돌리는 형태가 됩니다. 이 경우 어느 사례에서 깨졌는지 메시지에 남지 않으므로, 단언 인자에 인덱스나 입력값을 함께 적어 두어야 합니다.
Rust code
// 테이블 기반 테스트
use std::time::Instant;
fn main() {
let start = Instant::now();
for _ in 0..1000 {}
println!("{:?}", start.elapsed());
}사례를 표로 묶을 때는 입력과 기대값 쌍을 배열에 담고 for로 도는 형태가 되며, 사례를 늘리는 일이 한 줄 추가로 끝납니다.
반복문 안의 단언은 어느 사례에서 깨졌는지 메시지에 남지 않으므로, 단언 인자에 인덱스나 입력값을 함께 적어 두어야 합니다.
03경계값 테스트
덧셈 결과가 원래 값보다 큰지를 한 번 확인하는 단언입니다. 경계를 제대로 보려면 i32::MAX 언저리를 넣어 봐야 하는데, 이 지점에서 오버플로가 나면 디버그 빌드는 패닉하고 릴리스 빌드는 값이 감싸 돌아 조용히 통과합니다. 같은 테스트가 빌드 설정에 따라 다르게 끝나는 셈입니다.
Rust code
// 경계값 테스트
fn main() {
assert!(3 + 1 > 3);
}경계를 제대로 보려면 i32::MAX나 0처럼 극단값을 직접 넣어 봐야 하고, 그 언저리에서 동작이 갈리는지가 핵심입니다.
그 지점에서 오버플로가 나면 디버그 빌드는 패닉하고 릴리스 빌드는 값이 감싸 돌아 통과하므로, 같은 테스트가 빌드 설정에 따라 다르게 끝납니다.
04시계 목 객체
Instant::now()로 진짜 시계를 읽고 있어 출력값이 실행할 때마다 달라집니다. 시간에 기대는 로직을 검사하려면 현재 시각을 trait이나 인자로 주입해 고정값을 넣을 수 있게 만들어야 합니다. Instant는 임의의 시점으로 만들 수 없어서, 목으로 대체할 때는 보통 Duration이나 별도 시각 타입을 씁니다.
Rust code
// 시계 목 객체
use std::time::Instant;
fn main() {
let start = Instant::now();
for _ in 0..1000 {}
println!("{:?}", start.elapsed());
}시간에 기대는 로직은 현재 시각을 트레이트나 인자로 주입받게 만들면, 테스트에서 고정값을 넣어 결과를 결정적으로 만들 수 있습니다.
Instant는 임의의 시점으로 만들 수 없어 목으로 대체하기 어려우므로, 시각을 직접 읽는 코드가 깊이 박히면 테스트가 실행 속도에 좌우됩니다.
05저장소 목 객체
가짜 구현 없이 산술 조건 하나를 assert!로 검사할 뿐입니다. 저장소를 흉내 내려면 접근 동작을 trait으로 뽑고 메모리 HashMap으로 채운 구현을 테스트에서 끼웁니다. 그 구현에 #[cfg(test)]를 붙여 두면 배포 바이너리에는 컴파일조차 되지 않습니다.
Rust code
// 저장소 목 객체
fn main() {
assert!(5 + 1 > 5);
}접근 동작을 트레이트로 뽑고 메모리 HashMap으로 채운 구현을 테스트에서 끼우면, 데이터베이스 없이도 로직을 검사할 수 있습니다.
그 가짜 구현에 #[cfg(test)]를 붙이지 않으면 배포 바이너리에도 함께 컴파일되어, 실수로 실제 코드가 그것을 쓰게 될 여지가 남습니다.
06비동기 테스트
비동기 요소 없이 동기 루프의 실행 시간을 잽니다. async fn을 검사하려면 퓨처를 돌릴 런타임이 필요해서 #[test] 대신 #[tokio::test] 같은 속성을 붙입니다. 표준 테스트 하네스는 퓨처를 실행할 줄 몰라, 그냥 호출만 하면 본문이 한 줄도 돌지 않은 채 경고만 남습니다.
Rust code
// 비동기 테스트
use std::time::Instant;
fn main() {
let start = Instant::now();
for _ in 0..1000 {}
println!("{:?}", start.elapsed());
}async fn을 검사하려면 퓨처를 돌릴 런타임이 필요하므로, #[test] 대신 런타임이 제공하는 테스트 속성을 붙여야 합니다.
표준 테스트 하네스는 퓨처를 실행할 줄 몰라, async 함수를 그냥 호출만 하면 본문이 한 줄도 돌지 않은 채 경고만 남고 통과합니다.
07예외 테스트
성공하는 단언 하나만 있어 실패 경로가 전혀 드러나지 않습니다. Rust에는 예외가 없어 실패는 Result로 돌아오고, 패닉 자체를 검사할 때는 함수에 #[should_panic(expected = "...")]를 붙입니다. expected를 빼면 엉뚱한 이유로 난 패닉까지 통과로 처리되어 검사가 헐거워집니다.
Rust code
// 예외 테스트
fn main() {
assert!(7 + 1 > 7);
}패닉 자체를 검사할 때는 테스트 함수에 #[should_panic(expected = "...")]를 붙여 메시지 일부까지 확인하게 만듭니다.
expected를 빼면 엉뚱한 이유로 난 패닉까지 통과로 처리되므로, 정작 검사하려던 조건이 깨져도 알아채지 못합니다.
08속성 스타일 테스트
무작위 입력을 만들어 내는 부분 없이 정해진 1000회 반복을 한 번 돌립니다. 속성 기반 검사는 입력을 자동으로 생성해 불변식이 깨지는 지점을 찾는 방식입니다. 실패를 발견하면 입력을 줄여 최소 사례까지 좁혀 주기 때문에, 손으로 고른 값으로는 놓치던 경계가 드러납니다.
Rust code
// 속성 스타일 테스트
use std::time::Instant;
fn main() {
let start = Instant::now();
for _ in 0..1000 {}
println!("{:?}", start.elapsed());
}속성 기반 검사는 입력을 자동으로 만들어 불변식이 깨지는 지점을 찾고, 실패하면 최소 사례까지 입력을 줄여 보여 줍니다.
무작위 입력을 쓰면서 실패한 시드를 남기지 않으면 같은 실패를 다시 재현할 수 없어, 원인을 좇는 데 시간이 크게 늘어납니다.
09스냅샷 스타일 테스트
출력 전체를 저장해 두고 대조하는 대신, 조건 하나가 참인지만 봅니다. 스냅샷 방식은 Debug 출력이나 직렬화 결과를 파일로 남기고 다음 실행과 비교하는 형태입니다. 파생된 Debug 형식은 언어가 안정성을 약속하지 않으므로, 그것을 기준선으로 삼으면 컴파일러 판이 바뀔 때 흔들릴 수 있습니다.
Rust code
// 스냅샷 스타일 테스트
fn main() {
assert!(9 + 1 > 9);
}스냅샷 방식은 출력이나 직렬화 결과를 파일로 남기고 다음 실행과 대조하므로, 넓은 범위의 변화를 한 번에 감지할 수 있습니다.
파생된 Debug 출력은 형식이 고정된다고 약속되어 있지 않아, 그것을 기준선으로 삼으면 컴파일러 판이 바뀔 때 테스트가 흔들립니다.
10통합 테스트 스케치
실제 구성 요소를 엮지 않고 시간 측정 골격만 main에 적어 둔 상태입니다. 통합 테스트는 tests/ 디렉터리에 따로 두며, 파일 하나하나가 별도 크레이트로 컴파일됩니다. 그래서 공개 API만 보이고 비공개 함수는 부를 수 없어, 그런 검사는 소스 안쪽 #[cfg(test)] 모듈에 남겨 둡니다.
Rust code
// 통합 테스트 스케치
use std::time::Instant;
fn main() {
let start = Instant::now();
for _ in 0..1000 {}
println!("{:?}", start.elapsed());
}통합 테스트는 tests/ 디렉터리에 두며 파일 하나하나가 별도 크레이트로 컴파일되어, 실제 사용자가 보는 공개 API만 쓰게 됩니다.
그 구조 때문에 비공개 함수는 부를 수 없으므로, 내부 로직 검사를 통합 테스트에 두려 하면 필요 없이 함수를 공개하게 됩니다.
11파서 테스트
파싱은 없고 정수 비교 단언 한 줄이 전부입니다. 파서를 검사할 때는 잘 생긴 입력만이 아니라 빈 문자열이나 중간에 끊긴 값처럼 깨진 사례를 함께 넣어야 합니다. "12".parse::<i32>()는 Result를 돌려주므로, 여기에 unwrap()을 쓰면 실패 원인 대신 패닉 위치만 남습니다.
Rust code
// 파서 테스트
fn main() {
assert!(11 + 1 > 11);
}파서를 검사할 때는 잘 생긴 입력만이 아니라 빈 문자열이나 공백만 있는 값, 중간에 끊긴 값처럼 깨진 사례를 함께 넣어야 합니다.
파싱 결과에 단언 없이 unwrap()만 붙이면 실패했을 때 무엇을 기대했는지가 남지 않고 패닉 위치만 보입니다.
12컬렉션 테스트
컬렉션은 나오지 않고 빈 반복문의 소요 시간을 출력할 뿐입니다. 컬렉션을 비교할 때는 assert_eq!가 Vec끼리 순서까지 따진다는 점을 염두에 둬야 합니다. HashMap이나 HashSet은 순회 순서가 정해져 있지 않으므로, 순서를 전제로 쓴 단언은 언젠가 무작위로 깨집니다.
Rust code
// 컬렉션 테스트
use std::time::Instant;
fn main() {
let start = Instant::now();
for _ in 0..1000 {}
println!("{:?}", start.elapsed());
}assert_eq!는 Vec끼리 순서까지 그대로 비교하므로, 순서가 의미 있는 결과에는 별도 정렬 없이 그대로 쓸 수 있습니다.
HashMap이나 HashSet은 순회 순서가 정해져 있지 않아, 순서를 전제로 쓴 단언은 언젠가 이유 없이 깨집니다.
13문자열 테스트
검사 대상이 문자열이 아니라 정수 식이고, 결과가 항상 참이라 사실상 아무것도 검증하지 않습니다. 문자열을 다룰 때는 len()이 문자 수가 아니라 바이트 수를 돌려준다는 점이 함정입니다. 한글은 대개 한 글자가 3바이트라 chars().count()와 값이 어긋납니다.
Rust code
// 문자열 테스트
fn main() {
assert!(13 + 1 > 13);
}문자열을 검사할 때는 len()이 바이트 수를 돌려준다는 점을 감안해, 글자 수를 확인하려면 chars().count()와 비교해야 합니다.
한글은 대개 한 글자가 3바이트라, 길이 제한을 len()으로 검사하면 사용자가 넣을 수 있는 글자 수가 기대의 3분의 1로 줄어듭니다.
14매개변수화 테스트
매개변수 없이 같은 루프를 한 번 돌리는 코드입니다. 여러 조합을 검사하려면 반복문을 쓰거나 매크로로 사례마다 테스트 함수를 찍어 냅니다. cargo test는 테스트를 여러 스레드에서 동시에 돌리므로, 사례들이 전역 변수나 같은 파일을 공유하면 실행할 때마다 결과가 달라질 수 있습니다.
Rust code
// 매개변수화 테스트
use std::time::Instant;
fn main() {
let start = Instant::now();
for _ in 0..1000 {}
println!("{:?}", start.elapsed());
}여러 조합을 검사할 때는 반복문을 쓰거나 매크로로 사례마다 테스트 함수를 찍어 내면, 실패한 사례만 이름으로 골라 다시 돌릴 수 있습니다.
cargo test는 테스트를 여러 스레드에서 동시에 돌리므로, 사례들이 같은 파일이나 전역 상태를 공유하면 실행할 때마다 결과가 달라집니다.
15간단한 벤치마크
단언 한 줄만 있고 반복도 시간 측정도 없어, 벤치마크보다는 동작 확인에 가깝습니다. 성능을 재려면 최소한 릴리스 빌드가 필요합니다. 디버그 빌드는 최적화가 꺼져 있어 실제보다 훨씬 느린 숫자를 주고, 반대로 결과를 쓰지 않는 계산은 릴리스에서 통째로 제거되어 0에 가깝게 나옵니다.
Rust code
// 간단한 벤치마크
fn main() {
assert!(15 + 1 > 15);
}성능을 재려면 최소한 릴리스 빌드가 필요하고, 결과를 실제로 쓰거나 black_box로 감싸 최적화에 지워지지 않게 해야 합니다.
디버그 빌드에서 잰 숫자는 최적화가 꺼져 있어 실제보다 훨씬 느리므로, 그 값을 근거로 코드를 고치면 엉뚱한 곳을 손보게 됩니다.
정리하며
- 테스트 러너가 내장돼 있어 프레임워크 선택 단계가 없습니다
- 소스 안에 테스트를 두므로 비공개 함수도 그대로 검증할 수 있습니다
- 목 프레임워크 대신 트레이트로 경계를 긋고 테스트 구현을 끼웁니다
- 테스트는 병렬 실행되므로 공유 자원을 쓰면 간헐적 실패가 납니다
더 깊이 들어가고 싶다면 Rust 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.