Rust 테스트 더 알아보기: 목, 속성 기반 테스트, 벤치마크
#[test]와 assert_eq!로 테스트를 쓰는 방법은 이미 알고 있죠(아직이라면 Rust 테스트부터 보세요). 작은 프로그램이라면 그걸로 충분해요. 하지만 실제 프로젝트를 하다 보면 금방 새로운 질문이 생겨요. 테스트는 어디에 둬야 할까요? 메일을 보내거나 시계를 읽는 코드는 어떻게 테스트할까요? 미처 생각하지 못한 그 입력 하나는 어떻게 찾을까요? 그리고 “더 빠르게” 고친 버전은 정말 더 빠를까요?
자동차 정비소를 떠올려 보세요. 기본 테스트는 부품 하나하나가 제대로 움직이는지 확인하는 일이에요. 이 페이지에서는 정비소의 나머지 장비를 갖춰요. 부품을 따로 올려 보는 시험대(페이크와 목), 무작위 요철 수천 번으로 차를 흔들어 보는 기계(속성 기반 테스트), 지난번 모습을 찍어 둔 사진(스냅샷), 그리고 믿을 수 있는 스톱워치(벤치마크)예요.
시작하기 전에 솔직하게 말해 둘게요. Rust Playground의 Run 버튼은 테스트가 아니라 main을 실행해요. 그리고 아래에서 쓰는 테스트용 크레이트도 Playground에는 없어요. 그래서 이 페이지의 예제 대부분은 평범한 컴퓨터에서 작은 프로젝트로 돌린 실제 cargo test와 cargo bench 출력을 보여 줘요. main이 있는 프로그램에는 늘 그렇듯 직접 실행해 보기 버튼이 있어요.
테스트 정리하기
섹션 제목: “테스트 정리하기”이 페이지에서 쓰는 작은 shop 라이브러리의 구조예요.
shop/├── Cargo.toml├── src/│ ├── lib.rs <- 코드 + `mod tests` 안의 단위 테스트│ └── orders.rs├── tests/│ ├── checkout.rs <- 통합 테스트│ └── common/│ └── mod.rs <- 통합 테스트끼리 함께 쓰는 도우미└── benches/ └── stock.rs <- 벤치마크Rust에는 테스트가 세 종류 있고, cargo test는 이걸 전부 실행해요.
- 단위 테스트(unit test)는 코드 바로 옆,
#[cfg(test)] mod tests블록 안에 있어요. 비공개 함수도 볼 수 있어요. - 통합 테스트(integration test)는
tests/폴더에 있어요. 파일 하나하나가 별도의 크레이트로 컴파일되고, 사용자와 똑같이 바깥에서 라이브러리를 써요. 그래서pub항목만 호출할 수 있어요. - 문서 테스트(doc test)는
///주석 안에 있는 코드 예제예요. 문서가 아직도 제대로 동작한다는 걸 증명해 줘요. 자세한 내용은 Rust 문서화 페이지에서 다뤄요.
다음은 src/lib.rs의 일부예요. apply_discount에 붙은 문서 테스트와, mod tests 안의 도우미 함수와 픽스처 함수를 눈여겨보세요.
/// 장바구니의 한 줄. 가격은 센트 단위예요.#[derive(Debug, Clone, PartialEq)]pub struct Item { pub name: String, pub price_cents: u32, pub qty: u32,}
pub fn cart_total(items: &[Item]) -> u32 { items.iter().map(|i| i.price_cents * i.qty).sum()}
/// 가격에서 `percent`만큼 할인해요.////// ```/// use shop::apply_discount;////// assert_eq!(apply_discount(1000, 25), 750);/// ```pub fn apply_discount(price_cents: u32, percent: u32) -> u32 { assert!(percent <= 100, "discount must be at most 100%, got {percent}%"); price_cents - price_cents * percent / 100}
/// "12.50"을 1250센트로 바꿔요.pub fn parse_price(text: &str) -> Result<u32, String> { /* ... */ }
/// 1250센트를 "12.50"으로 바꿔요.pub fn format_price(cents: u32) -> String { format!("{}.{:02}", cents / 100, cents % 100)}
#[cfg(test)]mod tests { use super::*;
// 도우미: 짧게 부를 수 있고, 모든 테스트가 같은 방식으로 상품을 만들어요. fn item(name: &str, price_cents: u32, qty: u32) -> Item { Item { name: name.to_string(), price_cents, qty } }
// 픽스처: 여러 테스트가 함께 쓰는 현실적인 장바구니 하나. fn sample_cart() -> Vec<Item> { vec![item("coffee", 450, 2), item("bagel", 300, 1)] }
#[test] fn total_of_sample_cart() { assert_eq!(cart_total(&sample_cart()), 1200); }
#[test] fn empty_cart_costs_nothing() { assert_eq!(cart_total(&[]), 0); }
#[test] #[should_panic(expected = "at most 100%")] fn discount_over_100_panics() { apply_discount(1000, 150); }
// 테스트가 Result를 돌려줄 수도 있어요. 그러면 안에서 `?`를 쓸 수 있어요. #[test] fn parses_a_price() -> Result<(), String> { let cents = parse_price("12.50")?; assert_eq!(cents, 1250); Ok(()) }
#[test] #[ignore = "slow: checks ten million prices"] fn every_price_round_trips() { for cents in 0..10_000_000 { assert_eq!(parse_price(&format_price(cents)), Ok(cents)); } }}눈여겨볼 점 몇 가지예요.
- 도우미와 픽스처는 그냥 함수예요. Rust에는 특별한 픽스처 시스템이 없지만, 아쉬울 일은 거의 없어요.
#[cfg(test)]안에 있으니 실제 프로그램에는 절대 들어가지 않아요. #[should_panic(expected = "...")]속성은 패닉 메시지에 그 텍스트가 들어 있을 때만 통과해요. 그래서 다른 버그로 생긴 패닉은 통과로 치지 않아요. 더 많은 예제는 Rust 테스트에서 볼 수 있어요.Result를 돌려주는 테스트에서는?를 쓸 수 있어요. 테스트가Err를 돌려주면 실패하고 에러를 출력해요. 예를 들면Error: "missing '.' in \"12,50\""처럼요.#[ignore]속성은 느리거나 특별한 테스트를 기본적으로 건너뛰게 해요.=뒤의 텍스트는 이유이고,cargo test가 그걸 출력해 줘요.
통합 테스트는 공개 API를 통해 라이브러리를 써요. 함께 쓰는 도우미는 tests/common/mod.rs에 있어요.
use shop::Item;
pub fn breakfast_cart() -> Vec<Item> { vec![ Item { name: "coffee".into(), price_cents: 450, qty: 2 }, Item { name: "bagel".into(), price_cents: 300, qty: 1 }, ]}mod common;
use shop::{apply_discount, cart_total, format_price};
#[test]fn discounted_breakfast_total() { let total = cart_total(&common::breakfast_cart()); let to_pay = apply_discount(total, 10); println!("to pay: {}", format_price(to_pay)); assert_eq!(format_price(to_pay), "10.80");}cargo test를 실행하면 테스트 종류마다 구역이 하나씩 나와요.
Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 5 teststest tests::every_price_round_trips ... ignored, slow: checks ten million pricestest tests::parses_a_price ... oktest tests::empty_cart_costs_nothing ... oktest tests::total_of_sample_cart ... oktest tests::discount_over_100_panics - should panic ... ok
test result: ok. 4 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.08s
Running tests/checkout.rs (target/debug/deps/checkout-c1e4aa0cf6375c2a)
running 1 testtest discounted_breakfast_total ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests shop
running 1 testtest src/lib.rs - apply_discount (line 16) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s실행할 테스트 고르기
섹션 제목: “실행할 테스트 고르기”평소에는 매번 모든 테스트를 돌리고 싶지는 않을 거예요. 이럴 때 아래 명령어가 도움이 돼요. 홀로 있는 -- 뒤에 쓴 것은 Cargo가 아니라 테스트 프로그램 자체에 전달돼요.
cargo test discount # 이름에 "discount"가 들어간 테스트만cargo test --test checkout # tests/checkout.rs 파일만cargo test --lib # src/ 안의 단위 테스트만cargo test --doc # 문서 테스트만cargo test -- --ignored # #[ignore] 테스트만cargo test -- --include-ignored # 무시한 테스트까지 전부cargo test -- --nocapture # 통과한 테스트의 println! 출력도 보여 주기cargo test -- --test-threads=1 # 테스트를 하나씩 차례로 실행필터는 모든 테스트 파일에 걸쳐 테스트 전체 이름의 일부와 맞춰 봐요. 여기서는 discount로 단위 테스트 하나와 통합 테스트 하나를 찾았어요.
Running unittests src/lib.rs (target/debug/deps/shop-ffc1628aec947def)
running 1 testtest tests::discount_over_100_panics - should panic ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 6 filtered out; finished in 0.09s
Running tests/checkout.rs (target/debug/deps/checkout-630939367802bf1e)
running 1 testtest discounted_breakfast_total ... ok느린 테스트는 직접 요청할 때만 실행돼요. 여기서는 천만 번 왕복하는 데 3초쯤 걸렸어요.
running 1 testtest tests::every_price_round_trips ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 4 filtered out; finished in 3.20sRust는 기본적으로 통과한 테스트의 println! 출력을 숨겨서 보고서를 깔끔하게 유지해요. --nocapture를 붙이면 보여 줘요.
$ cargo test --test checkout -- --nocapturerunning 1 testto pay: 10.80test discounted_breakfast_total ... ok테스트 대역: 트레이트로 가짜 만들기
섹션 제목: “테스트 대역: 트레이트로 가짜 만들기”어떤 코드는 바깥세상과 이야기해요. 메일을 보내고, API를 호출하고, 시계를 읽어요. 테스트가 진짜 메일을 보내면 곤란하겠죠. 코드가 늘 진짜 시계에 시간을 물어본다면 “밤 9시에는 어떻게 되지?“도 테스트할 수 없어요.
Rust다운 해법은 라이브러리가 전혀 필요 없어요. 그 의존성을 트레이트로 표현하고, 함수가 그 트레이트를 구현한 무엇이든 받게 한 다음, 테스트용으로 작은 페이크(fake, 가짜 구현)를 쓰면 돼요. 실제 프로그램은 진짜를 넘기고, 테스트는 페이크를 넘겨요.
여기서는 주문 확인 기능이 고객에게 메일을 보내고, 큰 주문이면 영업팀에도 알려요.
trait Mailer { fn send(&mut self, to: &str, body: &str);}
// 진짜 메일 발송기. 실제 앱이라면 여기서 메일 서비스를 호출할 거예요.struct ConsoleMailer;
impl Mailer for ConsoleMailer { fn send(&mut self, to: &str, body: &str) { println!("{to}에게 보낸 메일: {body}"); }}
struct Order { id: u32, email: String, total_cents: u32,}
fn confirm_order(order: &Order, mailer: &mut impl Mailer) { let body = format!("주문 #{} 확인 완료", order.id); mailer.send(&order.email, &body); if order.total_cents >= 10_000 { }}
fn main() { let mut mailer = ConsoleMailer; confirm_order(&small, &mut mailer); confirm_order(&big, &mut mailer);}
#[cfg(test)]mod tests { use super::*;
// 페이크: 아무것도 보내지 않고, 호출될 때마다 기록만 해 둬요. #[derive(Default)] struct FakeMailer { sent: Vec<(String, String)>, }
impl Mailer for FakeMailer { fn send(&mut self, to: &str, body: &str) { self.sent.push((to.to_string(), body.to_string())); } }
#[test] fn small_order_sends_one_email() { let mut fake = FakeMailer::default();
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 1); }
#[test] fn big_order_also_tells_sales() { let mut fake = FakeMailer::default();
confirm_order(&order, &mut fake);
assert_eq!(fake.sent.len(), 2); }}실행 결과:
[email protected]에게 보낸 메일: 주문 #7 확인 완료[email protected]에게 보낸 메일: 주문 #8 확인 완료[email protected]에게 보낸 메일: 대량 주문 #8이 예제에는 외부 크레이트가 없으니, Playground에서 Run 옆 메뉴의 Test를 골라 테스트도 돌려 볼 수 있어요.
running 2 teststest tests::small_order_sends_one_email ... oktest tests::big_order_also_tells_sales ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00sconfirm_order는 어떤 발송기를 받는지 모르고, 신경 쓰지도 않아요. 비결은 이게 전부예요. 같은 아이디어를 Clock 트레이트(테스트에서는 밤 9시에 멈춘 시계를 써요), PaymentGateway, 테스트에서는 HashMap으로 저장하는 Storage 트레이트에도 쓸 수 있어요. 시계 버전은 아래 연습 문제에서 직접 해 볼 거예요.
mockall로 목 만들기
섹션 제목: “mockall로 목 만들기”트레이트가 한두 개라면 페이크를 직접 써도 괜찮아요. 많아지면 mockall (영어) 크레이트가 대신 만들어 줄 수 있어요. 테스트와 예제에서만 쓰이도록 dev-dependency로 추가하세요(Cargo.toml의 각 섹션은 Cargo와 크레이트에서 설명해요).
cargo add --dev mockall트레이트에 #[automock]을 붙이면 mockall이 MockMailer 타입을 만들어 줘요. cfg_attr(test, ...) 부분은 “테스트할 때만 이렇게 해”라는 뜻이라서, 목이 실제 빌드에 들어가는 일은 없어요. cfg는 Cargo 피처와 cfg에서 더 자세히 설명해요.
#[cfg(test)]use mockall::automock;
#[cfg_attr(test, automock)]pub trait Mailer { fn send(&mut self, to: &str, body: &str);}
// Order와 confirm_order는 위와 같아요.
#[cfg(test)]mod tests { use super::*;
#[test] fn big_order_also_tells_sales() { let mut mailer = MockMailer::new(); mailer .expect_send() .times(1) .return_const(()); mailer .expect_send() .times(1) .return_const(());
confirm_order(&order, &mut mailer); }}expect_send() 하나하나가 기대(expectation)예요. “send가 withf와 맞는 인자로 정확히 한 번 호출될 거야”라는 뜻이죠. return_const(())는 목이 무엇을 돌려줄지 정해요(여기서는 아무것도 없어요). 테스트가 끝나서 목이 버려질 때, mockall은 모든 기대가 채워졌는지 확인해요.
버그를 잡아내는 모습을 보려면 confirm_order의 조건을 order.total_cents > 12_000으로 바꿔 보세요. 이제 영업팀 메일은 절대 보내지지 않아요.
thread 'orders::tests::big_order_also_tells_sales' (12345) panicked at src/orders.rs:4:18:MockMailer::send: Expectation(<function>) called 0 time(s) which is fewer than expected 1이 메시지는 “기대한 횟수는 1번인데 0번 호출됐어요”라는 뜻이에요.
페이크와 목, 무엇을 쓸까요? 직접 쓴 페이크로 시작하세요. 평범한 Rust 코드라서 읽기 쉽고, 의존성도 늘지 않아요. 흉내 낼 트레이트가 많을 때, 또는 “이 인자로 정확히 N번 호출됐는지”가 정말 중요할 때 mockall을 꺼내세요.
proptest로 하는 속성 기반 테스트
섹션 제목: “proptest로 하는 속성 기반 테스트”보통의 테스트는 내가 고른 예시 하나를 확인해요. “12.50은 1250이 된다”처럼요. 속성 기반 테스트(property-based test)는 모든 입력에서 참이어야 하는 규칙을 적어 두고, 라이브러리가 무작위 입력 수백 개로 그 규칙을 깨 보려고 해요. 내가 생각하지 못한 이상한 값을 하나하나 넣어 보는, 아주 끈기 있는 사람을 고용하는 셈이에요.
좋은 속성은 보통 이런 모양이에요.
- 두 번 하면 원래대로 돌아온다: 리스트를 두 번 뒤집으면 원래 리스트가 된다.
- 갔다가 돌아오면 처음 값이 나온다:
parse(format(x)) == x. - 어떤 사실이 항상 성립한다: 정렬한 리스트는 입력과 길이가 같다.
cargo add --dev proptest로 proptest (영어)를 추가하고, proptest! 매크로 안에 테스트를 써요. 이름 in 전략(name in strategy) 형태의 줄이 어떤 무작위 값을 만들지 정해요.
use proptest::prelude::*;use shop::{format_price, parse_price};
proptest! { #[test] fn reversing_twice_gives_back_the_original(list in prop::collection::vec(any::<i32>(), 0..100)) { let mut copy = list.clone(); copy.reverse(); copy.reverse(); prop_assert_eq!(copy, list); }
#[test] fn price_survives_format_then_parse(cents in 0u32..10_000_000) { prop_assert_eq!(parse_price(&format_price(cents)), Ok(cents)); }}any::<i32>()는 “아무 i32나”, 0u32..10_000_000은 그 범위 안의 숫자, vec(..., 0..100)은 최대 99개짜리 리스트를 뜻해요. 기본적으로 속성마다 무작위 케이스 256개를 돌려요. proptest! 안에서는 assert_eq! 대신 prop_assert_eq!를 써야 proptest가 실패를 제대로 다룰 수 있어요. 둘 다 통과해요.
running 2 teststest price_survives_format_then_parse ... oktest reversing_twice_gives_back_the_original ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s앞에서 본 느린 #[ignore] 테스트와 똑같은 확인이라는 걸 눈치챘나요? 그 테스트는 천만 개의 가격을 전부 확인하느라 3초가 걸렸어요. 속성 테스트는 무작위로 256개를 100분의 1초 만에 확인하니까, 매번 돌려도 부담이 없어요.
이제 일부러 실수를 해 볼게요. 누군가 format_price를 “개선”해서 1,000.00처럼 천 단위 구분 기호를 넣었어요. 보기에는 더 좋지만, parse_price는 깜빡했어요.
pub fn format_price(cents: u32) -> String { let euros = cents / 100; let euros = if euros >= 1000 { format!("{},{:03}", euros / 1000, euros % 1000) } else { euros.to_string() }; format!("{euros}.{:02}", cents % 100)}cargo test --test properties의 실제 출력을 조금 줄여서 보여 드릴게요.
---- price_survives_format_then_parse stdout ----proptest: Saving this and future failures in tests/properties.proptest-regressions
thread 'price_survives_format_then_parse' (12345) panicked at tests/properties.rs:4:1:Test failed: assertion failed: `(left == right)` left: `Err("bad euros in \"1,000.00\"")`, right: `Ok(100000)` at tests/properties.rs:15.minimal failing input: cents = 100000minimal failing input: cents = 100000(“실패하는 가장 작은 입력”)을 보세요. 처음 실패한 무작위 값은 아마 7,384,912 같은 지저분한 값이었을 거예요. 그다음 proptest가 그 값을 축소(shrinking)해요. 여전히 실패하는 더 작은 값을 계속 시도해서, 가장 단순한 값을 알려 줘요. 100000센트는 정확히 1,000.00, 즉 쉼표가 처음으로 붙는 가격이에요. 버그가 어디 있는지 곧바로 알 수 있죠.
proptest는 실패한 케이스를 테스트 옆의 .proptest-regressions 파일에도 저장하고, 다음 실행부터는 그 케이스를 가장 먼저 시도해요. 이 파일을 커밋해 두면 같은 버그가 몰래 돌아오는 일이 없어요. 더 작은 라이브러리가 좋다면 quickcheck (영어)도 기능은 적지만 같은 일을 해요.
insta로 하는 스냅샷 테스트
섹션 제목: “insta로 하는 스냅샷 테스트”어떤 출력은 길어요. 영수증, 에러 보고서, 커다란 JSON 문서 같은 것들이요. 기대하는 텍스트 전체를 assert_eq!에 적는 건 지치는 일이에요. 스냅샷 테스트(snapshot test)는 처음 실행할 때 출력을 파일로 저장하고, 그다음부터는 새 출력을 저장해 둔 그 “사진”과 비교해요.
insta (영어)를 쓰면(cargo add --dev insta) 테스트는 한 줄이면 돼요.
pub fn receipt(items: &[Item]) -> String { let mut out = String::new(); for i in items { let line = format_price(i.price_cents * i.qty); out.push_str(&format!("{:<10} x{} {:>7}\n", i.name, i.qty, line)); } out.push_str(&format!("{:<14}{:>7}\n", "TOTAL", format_price(cart_total(items)))); out}
#[test]fn receipt_layout() { insta::assert_snapshot!(receipt(&sample_cart()));}영수증은 {:<10}, {:>7}로 칸을 맞추기 때문에, 한글은 폭이 두 칸이라 줄이 어긋나요. 그래서 상품 이름과 "TOTAL"은 영어로 두었어요.
첫 실행은 일부러 실패하고, 검토할 수 있도록 .snap.new 파일을 만들어 줘요. 그걸 승인하고 나면(cargo-insta 도구의 cargo insta review로 하거나, 파일 이름을 .snap으로 바꾸면 돼요) 파일은 src/snapshots/에 저장돼요.
---source: src/lib.rsassertion_line: 84expression: receipt(&sample_cart())---coffee x2 9.00bagel x1 3.00TOTAL 12.00나중에 누군가 "TOTAL"을 "Total"로 바꾸면, 테스트가 알아보기 쉬운 diff와 함께 실패해요.
-old snapshot+new results────────────┬─────────────────────────────────────────────────────────────────── 1 1 │ coffee x2 9.00 2 2 │ bagel x1 3.00 3 │-TOTAL 12.00 3 │+Total 12.00────────────┴───────────────────────────────────────────────────────────────────To update snapshots run `cargo insta review` or set `INSTA_UPDATE=always`의도한 변경이라면 새 스냅샷을 승인하면 돼요. 아니라면 방금 버그를 하나 찾은 거예요. 스냅샷은 “이건 실수로 바뀌면 안 돼”를 확인하기에 아주 좋지만, 출력이 올바르다는 건 알려 주지 않아요. 첫 스냅샷은 승인하기 전에 꼼꼼히 읽어 보세요.
criterion으로 하는 벤치마크
섹션 제목: “criterion으로 하는 벤치마크”코드가 빠른지 알고 싶다면 측정하세요. 짐작은 생각보다 자주 틀려요. #[test] 안에서 시간을 재는 것도 잘 안 맞아요. 테스트는 최적화 없이 빌드되고, 한 번 실행한 결과는 잡음이 너무 많거든요.
criterion (영어)은 stable Rust에서 흔히 쓰는 도구예요. 코드를 여러 번 실행하고, 통계를 대신 계산해 주고, 지난 결과를 기억해 뒀다가 빨라졌는지 느려졌는지 알려 줘요. cargo add --dev criterion으로 추가한 다음, Cargo에 벤치마크 파일을 알려 주고 기본 하니스(harness)를 꺼요.
[[bench]]name = "stock"harness = false이 벤치마크가 묻는 질문은 이거예요. 상품 코드 1,000개 중에 재고가 있는지 확인할 때, Vec과 HashSet 중 무엇이 더 빠를까요?
use std::collections::HashSet;use std::hint::black_box;
use criterion::{criterion_group, criterion_main, Criterion};
fn stock_lookup(c: &mut Criterion) { // "SKU-0", "SKU-1", ... 같은 상품 코드 1,000개 let codes: Vec<String> = (0..1000).map(|n| format!("SKU-{n}")).collect(); let code_set: HashSet<String> = codes.iter().cloned().collect(); let wanted = String::from("SKU-999"); // Vec에는 최악의 경우: 맨 마지막 값
c.bench_function("vec contains", |b| { b.iter(|| codes.contains(black_box(&wanted))) }); c.bench_function("hashset contains", |b| { b.iter(|| code_set.contains(black_box(&wanted))) });}
criterion_group!(benches, stock_lookup);criterion_main!(benches);b.iter(...) 안의 코드가 측정 대상이에요. std::hint::black_box는 값을 최적화기에게서 숨겨요. 이게 없으면 컴파일러가 답이 절대 바뀌지 않는다는 걸 알아채고 한 번만 계산해 버릴 수 있어요. 그러면 아무것도 아닌 걸 재게 되죠.
cargo bench로 실행하세요. 항상 최적화된(release) 모드로 빌드해요. 내 컴퓨터에서는 숫자가 다르게 나올 거예요.
vec contains time: [2.4222 µs 2.4470 µs 2.4742 µs]Found 2 outliers among 100 measurements (2.00%) 1 (1.00%) low mild 1 (1.00%) high mild
hashset contains time: [18.514 ns 18.891 ns 19.312 ns]Found 8 outliers among 100 measurements (8.00%) 4 (4.00%) high mild 4 (4.00%) high severe읽는 방법은 이래요. 세 시간 값은 범위예요. 가운데 값이 가장 좋은 추정치이고, 바깥쪽 두 값은 “실제 값은 거의 확실히 이 사이에 있다”는 뜻이에요. 여기서 HashSet은 약 19나노초, Vec은 약 2.4마이크로초가 걸렸으니, 이 경우에는 집합이 100배 넘게 빨라요. “outliers”(이상치)는 유난히 느리거나 빨랐던 몇 번의 실행인데, 컴퓨터에서 돌고 있는 다른 프로그램 때문인 경우가 많아요. 몇 개 정도는 정상이에요.
코드를 바꾼 뒤 다시 실행하면, criterion이 지난 실행과 비교해 줘요.
vec contains time: [2.5124 µs 2.5430 µs 2.5773 µs] change: [−0.5279% +0.8368% +2.1133%] (p = 0.22 > 0.05) No change in performance detected.변화가 1% 미만이라 그냥 잡음일 수도 있으니, criterion은 “No change”(변화 없음)라고 말해요. target/criterion/에 HTML 보고서도 만들어 줘요. 언제 속도를 신경 써야 하고 언제는 아닌지는 성능을 보세요.
오래된 코드에서는 #[bench] 함수를 볼 수도 있어요. 내장된 test::Bencher를 쓰는데, nightly 컴파일러에서만 동작해요.
#![feature(test)] // nightly 전용extern crate test;
#[bench]fn bench_total(b: &mut test::Bencher) { b.iter(|| (1..=1000u64).sum::<u64>());}stable Rust에서는 criterion을 쓰세요.
커버리지와 CI
섹션 제목: “커버리지와 CI”테스트가 생기면 도움이 되는 도구가 두 가지 더 있어요. Rust 도구와 개발 환경의 다른 도구들처럼 cargo install로 설치해요.
cargo-nextest는 더 빠른 테스트 실행기예요. 테스트마다 별도의 프로세스에서 실행하고, 출력이 더 보기 좋고, 불안정한(flaky) 테스트를 다시 시도할 수도 있어요. 하나 알아 둘 점은 문서 테스트를 실행하지 않는다는 거예요. 그러니 cargo test --doc도 함께 돌리세요.
cargo install cargo-nextest --lockedcargo nextest run커버리지(coverage)는 테스트가 실제로 어떤 줄을 실행했는지 알려 줘요. cargo-llvm-cov는 컴파일러에 들어 있는 커버리지 기능을 쓰고, Linux, macOS, Windows에서 모두 동작해요.
cargo install cargo-llvm-covcargo llvm-cov # 터미널에 요약 표cargo llvm-cov --html # target/llvm-cov/html에 둘러볼 수 있는 보고서cargo-tarpaulin은 예전부터 인기 있는 대안이에요. Linux에서 가장 잘 동작하고, CI에서 많이 써요.
cargo install cargo-tarpaulincargo tarpaulin --out Html커버리지는 점수가 아니라 지도로 보세요. 어떤 테스트도 건드리지 않은 코드를 보여 줄 뿐, assert가 올바른 걸 확인하는지는 알려 주지 못해요. assert가 탄탄한 80% 커버리지가 assert가 허술한 100%보다 나아요.
CI(GitHub Actions, GitLab 등)에서는 로컬에서 돌리는 것과 같은 세 명령어로 시작하는 게 좋아요. 그래야 검사가 실패했을 때 어디서든 같은 의미가 돼요.
cargo fmt --checkcargo clippy --all-targets -- -D warningscargo test자주 하는 실수
섹션 제목: “자주 하는 실수”바이너리만 있는 크레이트에 통합 테스트 쓰기. 프로젝트에 src/main.rs만 있으면, tests/ 안의 파일은 거기서 아무것도 가져올 수 없어요.
error[E0432]: unresolved import `todo` --> tests/tasks.rs:1:5 |1 | use todo::add_task; | ^^^^ use of unresolved module or unlinked crate `todo`컴파일러는 “todo라는 모듈이나 크레이트를 찾을 수 없다”고 말하고 있어요. 해결법: 로직을 src/lib.rs로 옮기고 main.rs는 작게 유지하세요. 그러면 main.rs도 라이브러리를 호출하고, 테스트도 라이브러리를 호출해요. 코드를 다시 쓰기도 더 쉬워져요.
함께 쓰는 도우미를 tests/common.rs에 두기. tests/ 바로 아래의 .rs 파일은 하나하나가 별도의 테스트 파일로 취급돼요. 그래서 출력에 쓸모없는 구역이 하나 더 생겨요.
Running tests/common.rs (target/debug/deps/common-634ea0b624054b77)
running 0 tests해결법: tests/common/mod.rs를 쓰고, 필요한 테스트 파일마다 mod common;을 적으세요.
테스트용 크레이트를 [dependencies]에 넣기. proptest, mockall, insta, criterion은 [dev-dependencies]에 들어가야 해요(cargo add --dev를 쓰세요). 그렇지 않으면 내 크레이트를 쓰는 모든 사람이 이것들까지 내려받고 컴파일하게 돼요.
상태를 공유하는 테스트. 테스트는 병렬로 실행돼요. 두 테스트가 같은 파일에 쓰거나 같은 환경 변수를 바꾸면, 무작위로 실패해요. 테스트마다 자기만의 임시 폴더를 주거나(tempfile 크레이트가 도움이 돼요), 최후의 수단으로 --test-threads=1로 실행하세요.
black_box 없이, 또는 테스트 안에서 벤치마크하기. 최적화기는 결과를 쓰지 않는 작업을 지워 버릴 수 있고, 디버그 모드 #[test]에서 잰 시간은 별로 알려 주는 게 없어요. criterion과 black_box를 함께 쓰세요.
연습 문제
섹션 제목: “연습 문제”1. 테스트할 수 있는 인사말
섹션 제목: “1. 테스트할 수 있는 인사말”이 함수는 늘 진짜 시계에 시간을 물어보기 때문에 믿을 만하게 테스트할 수 없어요.
fn greeting() -> &'static str { let hour = current_hour(); // 시스템 시간을 읽어요 match hour { 5..=11 => "좋은 아침이에요", 12..=17 => "좋은 오후예요", _ => "좋은 저녁이에요", }}메서드 fn hour(&self) -> u64 하나를 가진 Clock 트레이트를 받도록 고쳐 보세요. 항상 같은 시간을 돌려주는 FixedClock을 만들고, 경계값인 11시와 12시, 17시와 18시를 테스트하세요. 힌트: struct FixedClock(u64);면 충분해요. 실제 앱에서는 시간을 읽는 SystemClock을 넘기면 돼요.
정답 보기
trait Clock { fn hour(&self) -> u64; // 0부터 23까지}
// 늘 같은 시간을 말하는 시계. 테스트에 딱이에요.struct FixedClock(u64);
impl Clock for FixedClock { fn hour(&self) -> u64 { self.0 }}
fn greeting(clock: &impl Clock) -> &'static str { match clock.hour() { 5..=11 => "좋은 아침이에요", 12..=17 => "좋은 오후예요", _ => "좋은 저녁이에요", }}
fn main() { for hour in [8, 12, 21] { println!("{hour}:00 -> {}", greeting(&FixedClock(hour))); }}
#[cfg(test)]mod tests { use super::*;
#[test] fn morning_ends_at_noon() { assert_eq!(greeting(&FixedClock(11)), "좋은 아침이에요"); assert_eq!(greeting(&FixedClock(12)), "좋은 오후예요"); }
#[test] fn evening_starts_at_six() { assert_eq!(greeting(&FixedClock(17)), "좋은 오후예요"); assert_eq!(greeting(&FixedClock(18)), "좋은 저녁이에요"); }
#[test] fn very_early_is_still_evening() { assert_eq!(greeting(&FixedClock(3)), "좋은 저녁이에요"); }}실행 결과:
8:00 -> 좋은 아침이에요12:00 -> 좋은 오후예요21:00 -> 좋은 저녁이에요이제 greeting은 시간을 어디서 가져올지 스스로 정하지 않아요. 호출하는 쪽이 정하죠. 이 작은 변화 하나로 모든 시간대를 밀리초 만에 테스트할 수 있게 됐어요. Playground 메뉴에서 Test를 고르면 테스트 3개가 모두 통과해요.
2. 규칙을 찾고, proptest에게 확인 맡기기
섹션 제목: “2. 규칙을 찾고, proptest에게 확인 맡기기”" Web Dev " 같은 블로그 태그를 "web-dev"로 바꾸는 함수예요.
fn normalize_tag(tag: &str) -> String { tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")}proptest로 속성 테스트 두 개를 써 보세요. 이미 정규화된 태그를 다시 정규화해도 아무것도 바뀌지 않는다는 것, 그리고 결과에 공백 문자가 절대 없다는 것이에요. 힌트: 전략 ".*"는 무작위 문자열을 만들어요(정규 표현식이에요). “공백 없음”은 prop_assert!(!s.contains(char::is_whitespace))로 확인할 수 있어요.
정답 보기
fn normalize_tag(tag: &str) -> String { tag.to_lowercase().split_whitespace().collect::<Vec<_>>().join("-")}
fn main() { for tag in ["Rust", " Web Dev ", "GAME dev"] { println!("{tag:?} -> {:?}", normalize_tag(tag)); }}
#[cfg(test)]mod tests { use super::*; use proptest::prelude::*;
proptest! { #[test] fn normalizing_twice_changes_nothing(tag in ".*") { let once = normalize_tag(&tag); prop_assert_eq!(normalize_tag(&once), once); }
#[test] fn result_has_no_whitespace(tag in ".*") { let normalized = normalize_tag(&tag); prop_assert!(!normalized.contains(char::is_whitespace)); } }}실행 결과:
"Rust" -> "rust"" Web Dev " -> "web-dev""GAME dev" -> "game-dev"Playground에서 Run은 동작하지만 Test는 동작하지 않아요. Playground에 proptest가 없거든요. 내 프로젝트에서는 cargo add --dev proptest로 추가하고 cargo test를 실행하세요.
running 2 teststest tests::result_has_no_whitespace ... oktest tests::normalizing_twice_changes_nothing ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s무작위 문자열에는 탭, 줄바꿈, 영어가 아닌 글자까지 들어 있어요. 손으로 쓰는 테스트에는 아마 절대 넣지 않을 값들이죠. “두 번 해도 한 번 한 것과 같다”는 정리용 함수라면 어떤 함수든 확인해 볼 만한 속성이에요.
- 단위 테스트는 코드 옆의
#[cfg(test)] mod tests에, 통합 테스트는tests/에(함께 쓰는 도우미는tests/common/mod.rs에), 문서 테스트는///주석에 둬요. - 느린 테스트에는
#[ignore]를 붙이고cargo test -- --ignored로 실행해요.cargo test 이름_일부로 이름을 필터링하고,-- --nocapture로println!출력을 볼 수 있어요. - 바깥세상과 이야기하는 코드를 테스트하려면, 그 의존성을 트레이트 뒤에 두고 테스트에서는 페이크를 넘겨요. 페이크를 직접 쓰기가 번거로워지면 mockall을 써요.
- 속성 기반 테스트(proptest)는 무작위 입력 수백 개로 규칙을 확인하고, 실패하면 가장 단순한 예시로 축소해 줘요. 스냅샷 테스트(insta)는 커다란 출력이 실수로 바뀌는 걸 잡아내요.
- 속도는 criterion과
cargo bench로 재고, 입력은std::hint::black_box로 감싸요.#[bench]는 nightly가 필요해요. - cargo-nextest는 테스트를 더 빠르게 돌리고, cargo-llvm-cov와 cargo-tarpaulin은 테스트가 어떤 줄을 지나갔는지 보여 줘요.
이해도 점검
섹션 제목: “이해도 점검”이해도 점검
1. tests/ 안의 여러 파일이 같은 도우미 함수를 써야 해요. 어디에 두면 될까요?
2. cargo test -- --ignored는 무엇을 하나요?
3. proptest 속성 테스트가 실패했어요. proptest가 minimal failing input으로 보여 주는 값은 무엇일까요?
4. criterion 벤치마크에서 값을 std::hint::black_box로 감싸는 이유는 무엇일까요?
점수: 0 / 4
더 알아보기
섹션 제목: “더 알아보기”- 자동화 테스트 작성하기 — The Rust Programming Language 한국어판: 단위 테스트와 통합 테스트를 쓰고, 실행하고, 정리하는 방법을 다룬 공식 장이에요.
- Testing — Rust by Example (영어): 단위 테스트, 문서 테스트, 통합 테스트, dev-dependencies의 짧은 예제가 있어요.
- The proptest book (영어): 전략, 축소(shrinking), 설정, 그리고 proptest와 quickcheck의 차이를 설명해요.
- mockall 문서 (영어):
#[automock], 기대, 인자 맞추기, 반환값, 구조체를 목으로 만드는 방법까지 다뤄요. - Criterion.rs 사용자 가이드 (영어): 시작하기, 출력 읽는 법, 함수끼리 비교하기, HTML 보고서를 설명해요.