如何收缩失败用例?

解读

在 Rust 工程落地与国内 CI/CD 流程中,“失败用例”通常指单元测试、集成测试或模糊测试(fuzz)中未通过断言或 panic 的测试
“收缩”即最小化失败输入,让开发者一眼定位缺陷根因,而不是在 10 MB 的 payload 或上千行日志里“海底捞针”。
国内面试官问这道题,不是考你会不会写测试,而是考你能否把“失败现场”压缩成可复现、可 review、可归档的“铁证”
核心诉求:自动化、可集成、不丢信息、不引入新变量

知识点

  1. proptestTestCase::fail 自动收缩算法(shrinker)
  2. quickcheckShrinking trait 与 shrink() 迭代器
  3. cargo-fuzz-minimize 模式与 libFuzzer 的 -shrink=1 开关
  4. Rust 测试宏#[should_panic]Result<(), Box<dyn std::error::Error>> 返回类型对收缩流程的影响
  5. 国内私有源(tuna / ustc / sjtu)加速拉取 libfuzzer-sys 依赖,避免 CI 因拉 crate 超时导致收缩任务被 kill
  6. gitlab-ci / github-actions 缓存 target/fuzzcorpus 目录,减少重复收缩时间
  7. 法规合规:最小化失败用例若包含用户隐私,需脱敏后再落盘,否则审计不通过

答案

分三步给出工程级方案,可直接写进国内互联网厂的自研 CI:

第一步:选型与依赖

[dev-dependencies]
proptest = "1.4"          # 自带 shrink,无需手写
quickcheck = "1.0"        # 老项目若已用,可继续保留

第二步:编写可收缩测试
以“验证 IPv4 地址解析”为例,展示如何把失败输入压到最小。

use proptest::prelude::*;

proptest! {
    #[test]
    fn test_ipv4_doesnt_crash(s in "\\PC*") {
        // 若解析器 panic,proptest 会自动 shrink
        let _ = parse_ipv4(&s);
    }
}

当解析器对 "256.0.0.1" panic 时,proptest 会依次尝试
"256.0.0""256.0""256""25""2"
最终把失败用例收缩到最短 "2",并打印

Minimal failing input: "2"

第三步:CI 集成与落盘
.gitlab-ci.yml 中增加独立 stage,仅当测试失败时触发,避免占用资源:

shrink:
  stage: verify
  image: rust:1.78-slim
  script:
    - apt-get update && apt-get install -y llvm
    - cargo install cargo-fuzz --version ^0.11
    - cargo fuzz run parse_ipv4 -- -minimize=1 -runs=0
  artifacts:
    when: on_failure
    paths:
      - fuzz/artifacts/minimized/
    expire_in: 90 days

关键点

  • 使用 -runs=0 让 libFuzzer 只做收缩,不再探索新路径,10 秒内完成
  • minimized/ 目录作为制品上传,测试、开发、安全三方均可下载复现
  • 若失败用例包含隐私,增加一步 sed 脱敏脚本,符合《个人信息保护法》要求

拓展思考

  1. 混合收缩策略:当输入格式有语法树(如 JSON),可先用 proptest 收缩字节串,再自定义递归 shrinker 把 JSON 压到最小子树,比纯字节级收缩快 5× 以上。
  2. 并发安全:在多线程测试里,失败用例往往由数据竞争触发。此时需在 #[test] 里把线程调度随机种子也打印出来,并把种子一并收缩,否则换台机器就复现不了。
  3. 国内镜像与合规冲突:tuna 源同步有 6 小时延迟,若上游 yanked 了有 CVE 的 fuzz 依赖,CI 仍可能拉到旧版本。建议在 Cargo.lock 里固定 rev,并内部镜像做 GPG 签名,兼顾速度与合规。
  4. 回归用例沉淀:把每次收缩后的最小输入自动写进 regression/ 目录,下次 CI 先跑回归,防止修完又回退,这是国内大厂高优线上事故复盘里的“硬要求”。