doordash-oss/PropertyTestingKit

GitHub: doordash-oss/PropertyTestingKit

为 Swift Testing 提供覆盖率引导的模糊测试框架,支持语料库持久化、回归测试与并发竞态调度。

Stars: 14 | Forks: 2

# PropertyTestingKit 为 Swift 提供覆盖率引导的模糊测试。 ## 概述 PropertyTestingKit 为 Swift Testing 引入了覆盖率引导的模糊测试: - **覆盖率引导的模糊测试** - 自动发现能执行新代码路径的输入 - **可替换的覆盖率策略** - 选择判定“有趣”的标准(路径、边或集合新颖性),或编写自定义策略 - **语料库持久化** - 在多次测试运行之间保存并重放有趣的输入 - **回归测试** - 重放已保存的语料库以捕获回归 - **调度模糊测试** - 确定性地探索并发任务交错以暴露依赖顺序的竞态 - **高吞吐量** - 在完全隔离每个测试并发覆盖率的情况下,约 35M 次迭代/秒 ## 环境要求 - macOS 26+ / iOS 26+ - Swift 6.3+ ## 安装说明 ### Swift Package Manager 将 PropertyTestingKit 添加到您的 `Package.swift` 中: ``` dependencies: [ .package(url: "https://github.com/doordash-oss/PropertyTestingKit.git", from: "0.0.1"), ], targets: [ .testTarget( name: "YourTests", dependencies: ["PropertyTestingKit"] ), ] ``` ## 用法 ### 覆盖率引导的模糊测试 `fuzz` 函数会自动生成输入,以最大化代码覆盖率: ``` import Testing import PropertyTestingKit @Test func testDatabaseQuery() async throws { try await fuzz(seeds: [ ("users", 0), ("users", 100), ("orders", -1), ]) { table, limit in let query = buildQuery(table: table, limit: limit) let result = database.execute(query) // Properties that should hold for all inputs #expect(result.isValid || result.hasError) if limit < 0 { #expect(result.hasError, "Negative limit should error") } } } ``` **工作原理:** 1. 从种子值开始(您的种子 + 来自 `MutatorProviding.defaultMutator` 的类型默认值) 2. 运行每个输入并捕获覆盖率 3. 命中新代码路径的输入将被保存到语料库中 4. 对有趣的输入进行变异以发现更多路径 5. 达到时间限制时停止 6. 将最小语料库保存到磁盘以供将来运行使用 **在后续运行中:** - 重放已保存的语料库以检查崩溃(回归测试) ### 覆盖率策略 `fuzz(coverageStrategy:)` 决定什么使输入变得*有趣* —— 即值得保存到语料库并进行进一步变异。共有五种策略可用,从最细粒度到最粗粒度: | `CoverageStrategy` | 输入在以下情况变得有趣… | 备注 | |------|----------|-------| | `.pathTrie` *(默认)* | 其完整的**有序执行路径**是全新的 | 在 trie 中跟踪每个唯一的边*序列*,因此 `A→B→C` 不同于 `A→C→B`。每次边触发为 O(1),由每条边的观察者判定。最敏感 —— 能够区分以不同顺序命中相同边的输入。 | | `.signatureMatch` | 所覆盖边的**确切集合**是全新的 | 无哈希的倒排索引匹配,因此无误报。与顺序无关。 | | `.hitCountBuckets` | 某条边的**命中次数**落入未见过的桶中 | AFL++/libFuzzer 计数器特性:次数按 2 的幂次方桶(1, 2, 3, 4–7, 8–15, 16–31, 32–127, 128+)对每条边进行分类。捕获运行次数有显著差异的循环;忽略桶内的次数抖动。包含 `.newEdge`。 | | `.newEdge` | 命中了**任何**以前未见的边 | 纯边覆盖。最粗粒度 —— 速度最快,语料库最小。 | | `.alwaysInteresting` | 总是 | 每个输入都被无条件保存。用于需要独立于覆盖率的确定性语料库增长的测试。 | ``` @Test func testParser() async throws { // AFL-style: keep an input only when it reaches a brand-new edge try await fuzz(coverageStrategy: .newEdge) { (input: String) in parse(input) } } ``` 更细粒度的策略(`.pathTrie`, `.signatureMatch`)会保留更多输入并进行更深度的探索;`.newEdge` 保留的输入最少且运行速度最快。当*顺序*很重要时,请选用 `.pathTrie` —— 最显著的是调度模糊测试(见下文),因为相同的操作以不同方式交错正是您要寻找的 bug —— 当循环迭代次数很重要时(典型的 AFL++/libFuzzer 行为)选用 `.hitCountBuckets`,当您想要较小的语料库和最大吞吐量时选用 `.newEdge`。 **自定义策略**是一等公民 —— 每个内置策略都是通过相同的公开 API 定义的。策略是*纯粹的判定*:它的决策过程会查看运行覆盖率的惰性视图,并返回该输入是否有趣。存储是引擎的工作;策略永远不会触及语料库。 ``` // Stateless: judge each run's covered edges directly. let lowEdgeCount = CoverageStrategy { coverage in coverage.indices.count > 100 } ``` 对于有状态的策略,在 `makeEngine` 内部构建状态 —— 每个并行引擎只调用一次,因此状态永远不会跨引擎: ``` let myTrie = CoverageStrategy(makeEngine: { let trie = PathTrie() // this engine's state return CoverageEngine( // The measurement half: called on EVERY hit of every edge that // routes to this engine. The second parameter is the first-hit // bit — gate on it for loop immunity, like the built-in .pathTrie. onEdge: { edge, isFirstHit in if isFirstHit { trie.advance(edge) } }, onReset: { trie.reset() } // runs between iterations ) { _ in // the judgement half defer { trie.reset() } return trie.markTerminalIfUnique() } }) ``` 注意: - `decide` 仅在读取时才支付覆盖率快照的开销 —— 那些基于自身 `onEdge` 状态进行判定的策略(如 `.pathTrie`)可以避免内存分配。 - 自定义策略在任何内置策略有效的地方都有效,包括在 `scheduleFuzzing: true` 下。 - 由您自己的 `onEdge`/`decide` 代码触发的边会被记录,但不会被重新观察,因此它们可以存在于被插桩的代码中并安全地获取锁。 ### 语料库存储 语料库与您的测试文件保存在一起: ``` Tests/ MyTests/ ParserTests.swift Corpus/ # Created automatically testParser/ corpus.json # Saved inputs (coverage is re-measured on replay) ``` 将 `Corpus/` 目录提交到版本控制中,以确保 CI 运行的确定性。 ### 模糊测试 vs. 回归测试 有两个入口点。`fuzz(...)` 探索输入并维护语料库;`regress(...)` 仅重放已保存的语料库以验证其是否仍然通过。这种分离是刻意为之的:回归测试不接受任何专用于模糊测试的配置项(`seeds`, `coverageStrategy`, `parallelism`),并且其插件是只能进行观察(`stop` / `recordIssue`)的 `AnalysisPlugin` —— 因此在编译时,绝对不可能为重放过程提供一个会探索或改变语料库的配置或插件。 `fuzz(...)` 接受一个 `persistence:` 策略,用于控制它如何处理现有的语料库: | `CorpusPersistence` | 行为 | |------|----------| | `.auto` | 如果存在语料库则重放,否则从头开始模糊测试并保存(默认) | | `.replace` | 删除任何现有的语料库,从头开始模糊测试,并保存 | | `.extend` | 将现有的语料库作为种子加载,进行模糊测试,并保存 | | `.ephemeral` | 仅在内存中进行模糊测试 —— 忽略任何现有的语料库且不保存(不会进行任何磁盘操作) | **针对单个测试的控制:** ``` @Test func testParser() async throws { // Force re-fuzzing even if a corpus exists try await fuzz(persistence: .replace) { (input: String) in parse(input) } } @Test func testExtendCorpus() async throws { // Build on the existing corpus with a longer duration try await fuzz(persistence: .extend, duration: .seconds(120)) { (input: String) in parse(input) } } @Test func testParserRegression() async throws { // Replay the saved corpus only — fails if any saved input now trips the test try await regress { (input: String) in parse(input) } } ``` **通过环境变量进行套件级控制:** 用户可能希望使用 `FUZZ_CORPUS_MODE=refuzzextend` 在标准 CI 循环之外运行后台模糊测试活动。这允许在快速确定性测试运行和彻底测试之间取得平衡。 ``` # 对所有测试重新进行 fuzzing,替换现有 corpora FUZZ_CORPUS_MODE=refuzzreplace swift test # 通过更多 fuzzing 扩展现有 corpora(持续 2 分钟) FUZZ_CORPUS_MODE=refuzzextend FUZZ_DURATION=120 swift test # CI 模式:强制每个 fuzz test 仅重放——不进行探索(快速、确定性)。 # 无论此变量如何设置,regress(...) 测试始终进行重放。 FUZZ_CORPUS_MODE=regressiononly swift test ``` ### 自定义种子 提供特定领域的种子,以引导 fuzzer 找到边缘情况: ``` @Test func testNumberParser() async throws { try await fuzz(seeds: [ "0", "-0", "+0", // Zero variants String(Int.max), // Boundary String(Int.min), // Boundary "1.5", "1e10", // Invalid formats " 42 ", // Whitespace ]) { input in if let n = NumberParser.parse(input) { // Round-trip property #expect(NumberParser.parse(String(n)) == n) } } } ``` ### 自定义 Mutator 使用特定领域的变异策略代替默认的 `MutatorProviding` 一致性: ``` @Test func testInputValidation() async throws { // Single mutator with multiple strategies try await fuzz(using: String.mutators(.sql, .xss)) { input in let sanitized = sanitize(input) #expect(!sanitized.contains("DROP TABLE")) #expect(!sanitized.contains("