테스트 전략 문서의 검증 코드다.
//! 테스트 종류를 한 파일에서 확인한다: 단위 테스트, #[should_panic], Result를 돌려주는 테스트,
//! proptest 성질 테스트와 축소(shrinking), #[tokio::test], 골든(기대 출력) 비교.
//! 통합 테스트(tests/)와 doc 테스트는 임시 cargo 패키지를 만들어 `cargo test`로 실제로 돌려 본다.
//! 의존성: proptest = "1", tokio = { version = "1", features = ["full"] }
//! 실행: python3 "_시스템/Rust 계획/rs_check.py" "Rust/3.개념집/42_testing-strategy_verify.rs"
use std::time::Duration;
const ALPHABET: &[u8; 62] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
/// 숫자를 62진 문자열로 바꾼다. 단축 주소의 짧은 코드를 만들 때 쓰는 방식이다.
pub fn encode(mut n: u64) -> String {
if n == 0 {
return "0".into();
}
let mut out = Vec::new();
while n > 0 {
out.push(ALPHABET[(n % 62) as usize]);
n /= 62;
}
out.reverse();
String::from_utf8(out).expect("ascii")
}
pub fn decode(s: &str) -> Option<u64> {
if s.is_empty() {
return None;
}
s.bytes().try_fold(0u64, |acc, b| {
let d = ALPHABET.iter().position(|&c| c == b)? as u64;
acc.checked_mul(62)?.checked_add(d)
})
}
/// 일부러 틀린 버전: 글자 3칸짜리 버퍼에 담아서, 62진 네 자리 이상(62^3 = 238328 이상)이면 앞자리를 잃는다.
fn buggy_encode(n: u64) -> String {
let full = encode(n);
let keep = full.len().min(3);
full[full.len() - keep..].to_string()
}
/// 전체 중 일부가 몇 퍼센트인지. whole이 0이면 호출한 쪽의 버그이므로 panic한다.
pub fn percent(part: u64, whole: u64) -> u64 {
assert!(whole > 0, "whole must be positive");
part * 100 / whole
}
/// 사람이 읽는 보고서. 골든 테스트의 대상이다.
pub fn render_report(rows: &[(&str, u64)]) -> String {
let total: u64 = rows.iter().map(|r| r.1).sum();
let mut s = String::from("code hits share\n");
for (code, hits) in rows {
s.push_str(&format!(
"{code:<5} {hits:>4} {:>4}%\n",
percent(*hits, total)
));
}
s
}
const GOLDEN_REPORT: &str = "\
code hits share
a1 30 75%
Zz 10 25%
";
/// 처음 몇 번은 실패하는 외부 호출을 흉내 내고, 재시도해서 성공한 횟수를 돌려준다.
pub async fn with_retry(fail_times: u32, max: u32) -> Result<u32, String> {
let mut attempt = 0;
loop {
attempt += 1;
if attempt > fail_times {
return Ok(attempt);
}
if attempt >= max {
return Err(format!("gave up after {attempt} attempts"));
}
tokio::time::sleep(Duration::from_millis(1)).await;
}
}
/// proptest를 테스트 밖에서 직접 돌려 축소 결과를 본다.
fn shrink_demo() -> Option<u64> {
use proptest::test_runner::{Config, TestError, TestRng, TestRunner};
let config = Config {
failure_persistence: None, // 데모라서 proptest-regressions 파일을 남기지 않는다
..Config::default()
};
let mut runner = TestRunner::new_with_rng(
config.clone(),
TestRng::deterministic_rng(config.rng_algorithm),
);
let result = runner.run(&(0u64..1_000_000), |n| {
proptest::prop_assert_eq!(decode(&buggy_encode(n)), Some(n));
Ok(())
});
match result {
Err(TestError::Fail(_, n)) => Some(n),
_ => None,
}
}
/// 임시 cargo 패키지로 통합 테스트와 doc 테스트를 실제로 돌린다.
fn cargo_layout_demo() -> Result<String, String> {
let root = std::env::temp_dir().join(format!("vr_testing_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let w = |p: &str, s: &str| {
let f = root.join(p);
std::fs::create_dir_all(f.parent().expect("parent")).expect("mkdir");
std::fs::write(f, s).expect("write");
};
w(
"Cargo.toml",
"[package]\nname = \"shortcode\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n",
);
w(
"src/lib.rs",
r#"/// 두 배를 돌려준다.
///
/// ```
/// assert_eq!(shortcode::double(21), 42);
/// ```
///
/// ```compile_fail
/// shortcode::double("x");
/// ```
pub fn double(x: u32) -> u32 {
helper(x) * 2
}
fn helper(x: u32) -> u32 {
x
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn private_helper_is_testable_here() {
assert_eq!(helper(3), 3);
}
}
"#,
);
w("tests/common/mod.rs", "pub fn input() -> u32 {\n 5\n}\n");
w(
"tests/api.rs",
"mod common;\n\n#[test]\nfn doubles_public_api() {\n assert_eq!(shortcode::double(common::input()), 10);\n}\n",
);
let out = std::process::Command::new("cargo")
.args(["test", "--offline"])
.current_dir(&root)
.env_remove("CARGO_TARGET_DIR")
.output()
.map_err(|e| e.to_string())?;
let text =
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr);
let _ = std::fs::remove_dir_all(&root);
if out.status.success() {
Ok(text)
} else {
Err(text)
}
}
fn main() {
let mut ok = true;
let mut check = |cond: bool, what: String| {
println!("[{}] {what}", if cond { "ok" } else { "FAIL" });
ok &= cond;
};
check(
encode(0) == "0" && encode(61) == "z" && encode(62) == "10",
"62진: 0, 61->z, 62->10".into(),
);
check(
decode("10") == Some(62) && decode("").is_none() && decode("-").is_none(),
"decode 경계".into(),
);
check(
decode(&encode(u64::MAX)) == Some(u64::MAX),
"u64::MAX 왕복".into(),
);
check(
decode("zzzzzzzzzzzz").is_none(),
"넘치면 None (checked_mul)".into(),
);
let report = render_report(&[("a1", 30), ("Zz", 10)]);
check(report == GOLDEN_REPORT, format!("골든 비교:\n{report}"));
let shrunk = shrink_demo();
check(
shrunk == Some(238_328),
format!("proptest 축소 결과: {shrunk:?}"),
);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.expect("rt");
check(
rt.block_on(with_retry(2, 5)) == Ok(3),
"재시도: 2번 실패 후 3번째 성공".into(),
);
match cargo_layout_demo() {
Ok(text) => {
let lines: Vec<&str> = text.lines().filter(|l| l.contains("test result")).collect();
for l in text.lines().filter(|l| {
l.contains("Running") || l.contains("Doc-tests") || l.starts_with("test ")
}) {
println!(" | {}", l.trim());
}
for l in &lines {
println!(" {l}");
}
let passed: Vec<&str> = lines
.iter()
.map(|l| l.split(';').next().unwrap_or(""))
.collect();
check(
passed == ["test result: ok. 1 passed"; 4] && text.contains("Doc-tests shortcode"),
"cargo test: 단위 1, 통합 1, doc 테스트 2개가 두 묶음(합쳐 돈 것 1, compile_fail 1)".into(),
);
}
Err(text) => check(false, format!("cargo test 실패:\n{text}")),
}
assert!(ok);
println!("ALL CHECKS PASSED");
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn encodes_small_numbers() {
assert_eq!(encode(10), "A");
assert_eq!(encode(3843), "zz"); // 62*62 - 1
}
#[test]
#[should_panic(expected = "whole must be positive")]
fn percent_of_zero_panics() {
percent(1, 0);
}
#[test]
fn decode_rejects_bad_input() -> Result<(), String> {
let n = decode("1Z").ok_or("decode failed")?;
if n == 62 + 35 {
Ok(())
} else {
Err(format!("got {n}"))
}
}
#[test]
fn report_matches_golden() {
assert_eq!(render_report(&[("a1", 30), ("Zz", 10)]), GOLDEN_REPORT);
}
proptest! {
#[test]
fn roundtrip(n in any::<u64>()) {
prop_assert_eq!(decode(&encode(n)), Some(n));
}
#[test]
fn encode_uses_only_alphabet(n in any::<u64>()) {
prop_assert!(encode(n).bytes().all(|b| ALPHABET.contains(&b)));
}
#[test]
fn longer_code_means_bigger_number(a in 0u64..1_000_000, b in 0u64..1_000_000) {
if encode(a).len() < encode(b).len() {
prop_assert!(a < b);
}
}
}
#[tokio::test]
async fn retry_gives_up() {
assert_eq!(
with_retry(10, 3).await,
Err("gave up after 3 attempts".into())
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn retry_on_multi_thread_runtime() {
let h = tokio::spawn(with_retry(1, 5));
assert_eq!(h.await.unwrap(), Ok(2));
}
}