Rust 파라미터화 테스트: rstest 크레이트 사용법
Rust로 함수를 하나 만들면, 그 함수가 여러 입력에서 제대로 동작하는지 확인하고 싶어집니다.
점수를 학점으로 바꾸는 함수라면 95점은 A, 85점은 B, 40점은 F가 나오는지 일일이 따져봐야 하죠.
그런데 이걸 #[test] 함수로 하나하나 적다 보면 거의 똑같은 코드가 입력값만 바뀐 채 우수수 늘어납니다.
이럴 때 손이 가는 도구가 rstest입니다. 입력과 기대값만 나열해 두면 테스트 함수를 입력 개수만큼 자동으로 찍어내 주거든요. assert 매크로로 값을 검증하는 기본기 위에, “같은 검증을 여러 입력으로 반복”하는 부분을 깔끔하게 얹어 주는 셈이죠. 이번 글에서는 rstest로 파라미터화 테스트와 픽스처를 어떻게 쓰는지 차근차근 살펴보겠습니다.
rstest가 해결하는 문제
먼저 rstest 없이 학점 함수를 테스트한다고 해봅시다. 검증하려는 함수는 이렇게 생겼고요.
fn grade(score: u32) -> char {
match score {
90..=100 => 'A',
80..=89 => 'B',
70..=79 => 'C',
60..=69 => 'D',
_ => 'F',
}
}
확인하고 싶은 입력이 다섯 개라면, 표준 #[test]로는 보통 이렇게 적게 됩니다.
#[test]
fn grade_95_is_a() {
assert_eq!(grade(95), 'A');
}
#[test]
fn grade_85_is_b() {
assert_eq!(grade(85), 'B');
}
// C, D, F도 똑같은 모양으로 계속 반복...
함수 이름만 다를 뿐 본문은 assert_eq! 한 줄씩, 사실상 복사-붙여넣기입니다.
케이스를 하나 더 넣으려면 함수를 통째로 또 적어야 하죠.
그렇다고 반복문 하나에 몰아넣는 것도 답은 아닙니다.
#[test]
fn grades() {
let cases = [(95, 'A'), (85, 'B'), (75, 'C'), (65, 'D'), (40, 'F')];
for (score, expected) in cases {
assert_eq!(grade(score), expected);
}
}
코드는 짧아졌지만 대신 잃는 게 있습니다.
배열 중간의 (75, 'C')에서 실패하면 그 뒤 케이스는 아예 실행되지 않고, 테스트 리포트에는 grades 하나만 빨갛게 뜨거든요.
어느 입력에서 깨졌는지는 패닉 메시지를 들여다봐야 비로소 알 수 있습니다.
rstest는 이 두 방식의 좋은 점만 취합니다. 코드는 반복문처럼 짧게 적되, 리포트는 개별 함수처럼 입력마다 따로 나오게 해주죠.
설치하기
rstest는 테스트에서만 쓰이니 Cargo.toml의 [dev-dependencies]에 넣습니다.
운영 바이너리에는 한 줄도 섞이지 않도록 분리해 두는 거죠.
cargo add rstest --dev
Adding rstest v0.26.1 to dev-dependencies
Cargo.toml을 직접 열어 적어도 결과는 같습니다.
[dev-dependencies]
rstest = "0.26"
케이스 나열하기: #[case]
이제 앞의 학점 테스트를 rstest로 바꿔 보겠습니다.
use rstest::*;
#[rstest]
#[case::a_grade(95, 'A')]
#[case::b_grade(85, 'B')]
#[case::c_grade(75, 'C')]
#[case::d_grade(65, 'D')]
#[case::f_grade(40, 'F')]
fn grades_score_correctly(#[case] score: u32, #[case] expected: char) {
assert_eq!(grade(score), expected);
}
#[test] 자리에 #[rstest]를 답니다.
함수 위에 쌓인 #[case(...)]가 입력 한 세트씩이고, 파라미터에 붙인 #[case]는 그 값을 선언된 순서대로 받아 가는 자리예요.
#[case(95, 'A')]라면 첫 번째 #[case] 파라미터 score에 95가, 두 번째 expected에 'A'가 들어가는 식이죠.
:: 뒤에 붙인 a_grade 같은 이름은 생략해도 되지만, 적어 두면 생성되는 테스트 이름에 그대로 들어가서 리포트가 한결 읽기 좋아집니다.
cargo test를 돌려 보면 케이스 다섯 개가 각각 독립된 테스트로 잡힙니다.
running 5 tests
test tests::grades_score_correctly::case_1_a_grade ... ok
test tests::grades_score_correctly::case_2_b_grade ... ok
test tests::grades_score_correctly::case_3_c_grade ... ok
test tests::grades_score_correctly::case_4_d_grade ... ok
test tests::grades_score_correctly::case_5_f_grade ... ok
test result: ok. 5 passed; 0 failed; finished in 0.00s
함수는 하나만 적었는데 테스트는 다섯 개가 생겼죠.
이제 case_3_c_grade 하나만 실패해도 나머지 넷은 멀쩡히 통과하고, 리포트에 어느 케이스가 깨졌는지 이름으로 바로 드러납니다.
반복문에 몰아넣었을 때 잃었던 것을 그대로 되찾은 셈이에요.
값 목록으로 조합 만들기: #[values]
입력과 기대값이 짝지어 다니는 게 아니라, “이 입력들 중 어느 것이든 결과는 같아야 한다”를 확인하고 싶을 때가 있습니다.
예를 들어 60점 이상은 무엇이든 F가 아니어야 한다는 식이죠.
이럴 때는 #[values(...)]로 값만 죽 나열하면 됩니다.
#[rstest]
fn passing_scores_are_never_f(#[values(60, 75, 88, 100)] score: u32) {
assert_ne!(grade(score), 'F');
}
#[values]에 적은 값 하나하나가 별도 테스트로 펼쳐집니다.
running 4 tests
test tests::passing_scores_are_never_f::score_1_60 ... ok
test tests::passing_scores_are_never_f::score_2_75 ... ok
test tests::passing_scores_are_never_f::score_3_88 ... ok
test tests::passing_scores_are_never_f::score_4_100 ... ok
test result: ok. 4 passed; 0 failed; finished in 0.00s
#[values]가 진가를 발휘하는 건 #[case]나 다른 #[values]와 함께 쓸 때입니다.
파라미터마다 따로 나열한 값들이 곱집합(데카르트 곱)으로 조합되거든요.
가령 #[case] 2개와 #[values] 값 3개를 한 함수에 같이 두면 2 × 3, 즉 여섯 개의 테스트가 자동으로 만들어집니다.
경계값 몇 개를 빠르게 교차 검증하고 싶을 때 손이 가장 적게 가는 방법이죠.
픽스처로 준비 코드 재사용하기: #[fixture]
테스트마다 똑같은 준비 과정이 반복되는 경우도 흔합니다.
객체를 만들고, 초기 상태를 채워 넣고, 그 위에서 검증을 시작하는 식이죠.
rstest는 이 준비 과정을 #[fixture]로 떼어내 재사용하게 해줍니다.
장바구니를 예로 들어 볼게요.
#[derive(Default)]
struct Cart {
items: Vec<(String, u32)>,
}
impl Cart {
fn add(&mut self, name: &str, price: u32) {
self.items.push((name.to_string(), price));
}
fn total(&self) -> u32 {
self.items.iter().map(|(_, price)| price).sum()
}
}
상품 두 개가 담긴 장바구니를 픽스처로 정의해 둡니다.
#[fixture]
fn cart() -> Cart {
let mut cart = Cart::default();
cart.add("keyboard", 30000);
cart.add("mouse", 20000);
cart
}
이제 테스트 함수의 파라미터에 픽스처와 같은 이름(cart)을 적기만 하면 됩니다.
rstest가 알아서 픽스처 함수를 호출해 그 반환값을 주입해 주거든요.
#[rstest]
fn cart_starts_with_two_items(cart: Cart) {
assert_eq!(cart.items.len(), 2);
}
#[rstest]
fn cart_total_sums_prices(cart: Cart) {
assert_eq!(cart.total(), 50000);
}
running 2 tests
test tests::cart_starts_with_two_items ... ok
test tests::cart_total_sums_prices ... ok
test result: ok. 2 passed; 0 failed; finished in 0.00s
두 테스트가 똑같은 준비 코드를 공유하지만, 각자 받는 Cart는 별개의 인스턴스입니다.
픽스처는 테스트마다 새로 호출되니 한 테스트에서 장바구니를 휘저어도 옆 테스트에는 영향이 없죠.
픽스처가 특히 좋은 건, 케이스와 한 함수에서 섞어 쓸 수 있다는 점입니다. 준비된 장바구니에 상품을 더 담았을 때 합계가 맞는지, 여러 경우로 검증해 보겠습니다.
#[rstest]
#[case::add_book(15000, 65000)]
#[case::add_pen(2000, 52000)]
fn adding_item_updates_total(mut cart: Cart, #[case] price: u32, #[case] expected: u32) {
cart.add("extra", price);
assert_eq!(cart.total(), expected);
}
cart는 픽스처에서 주입받고, price와 expected는 #[case]에서 받아 옵니다.
픽스처로 공통 준비를, 케이스로 입력 변형을 나눠 맡기니 테스트 의도가 또렷하게 드러나죠.
픽스처에 값을 주입하기: #[default]와 #[with]
픽스처도 인자를 받을 수 있습니다.
#[default(...)]로 기본값을 정해 두면, 평소엔 그 값으로 동작하다가 특정 테스트에서만 다른 값으로 바꿔 끼울 수 있어요.
상품 개수를 인자로 받는 장바구니 픽스처를 만들어 보겠습니다.
#[fixture]
fn sized_cart(#[default(2)] count: u32) -> Cart {
let mut cart = Cart::default();
for _ in 0..count {
cart.add("item", 10000);
}
cart
}
기본값을 그대로 쓰는 테스트는 인자를 적을 필요가 없습니다.
값을 바꾸고 싶은 테스트에서만 파라미터 앞에 #[with(...)]를 붙여 덮어쓰면 되고요.
#[rstest]
fn uses_default_size(sized_cart: Cart) {
assert_eq!(sized_cart.items.len(), 2);
}
#[rstest]
fn overrides_size(#[with(5)] sized_cart: Cart) {
assert_eq!(sized_cart.items.len(), 5);
}
uses_default_size는 #[default(2)] 덕분에 상품 두 개짜리 장바구니를, overrides_size는 #[with(5)]로 다섯 개짜리 장바구니를 받습니다.
같은 픽스처를 토대로 상황별 변형만 살짝 얹는 방식이라, 비슷비슷한 준비 코드를 여러 벌 만들지 않아도 됩니다.
비동기 테스트 다루기
tokio 기반의 비동기 코드를 테스트할 때도 rstest는 자연스럽게 어울립니다.
픽스처를 async로 선언하고, 테스트 파라미터에 #[future]를 붙여 “이건 아직 await하지 않은 미래값”이라고 알려 주면 됩니다.
#[fixture]
async fn remote_price() -> u32 {
25000
}
#[rstest]
#[tokio::test]
async fn awaits_fixture_manually(#[future] remote_price: u32) {
assert_eq!(remote_price.await, 25000);
}
#[future]가 붙은 파라미터는 테스트 본문에서 직접 .await로 풀어 써야 합니다.
그런데 .await를 매번 적는 게 번거롭다면 #[awt] 속성으로 자동화할 수 있어요.
#[rstest]
#[awt]
#[tokio::test]
async fn awaits_fixture_automatically(#[future] remote_price: u32) {
assert_eq!(remote_price, 25000);
}
함수 위에 #[awt]를 달면 #[future] 파라미터들이 본문에 들어오기 전에 알아서 await됩니다.
그래서 본문에서는 .await 없이 평범한 값처럼 다룰 수 있죠.
참고로 #[tokio::test]는 비동기 런타임을 띄우는 역할이라, 이 조합을 쓰려면 tokio도 [dev-dependencies]에 함께 넣어 둬야 합니다.
마치며
지금까지 rstest로 Rust 테스트를 다루는 방법을 살펴봤습니다.
#[case]로 입력과 기대값을 나열해 파라미터화 테스트를 만들고, #[values]로 값을 조합하고, #[fixture]로 준비 코드를 재사용하는 흐름을 짚어봤어요.
#[default]와 #[with]로 픽스처에 값을 주입하는 법, 그리고 #[future]·#[awt]로 비동기 테스트를 다루는 법까지 함께 다뤘습니다.
rstest의 핵심은 결국 “테스트 함수 하나로 여러 테스트를 찍어낸다”는 한 줄로 요약됩니다. 덕분에 같은 검증을 입력만 바꿔 반복하던 코드가 눈에 띄게 줄고, 실패했을 때 어느 입력이 문제였는지도 곧바로 드러나죠. 값 검증 자체의 기본기가 궁금하다면 assert 매크로를, 외부 의존성을 가짜로 바꾸는 모킹이 필요하다면 mockall이나 mockito를 함께 보면 테스트 도구 상자가 한결 든든해집니다.
더 자세한 내용은 rstest 공식 문서를 참고하세요.
This work is licensed under
CC BY 4.0