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("