doordash-oss/PropertyTestingKit
GitHub: doordash-oss/PropertyTestingKit
Stars: 12 | Forks: 1
# PropertyTestingKit
Coverage-guided fuzz testing for Swift.
## Overview
PropertyTestingKit brings coverage-guided fuzzing to Swift Testing:
- **Coverage-guided fuzzing** - Automatically discover inputs that exercise new code paths
- **Corpus persistence** - Save and replay interesting inputs across test runs
- **Regression testing** - Replay saved corpus to catch regressions
- **High throughput** - ~35M iterations/sec with full per-test concurrent coverage isolation
## Requirements
- macOS 26+ / iOS 26+
- Swift 6.3+
## Installation
### Swift Package Manager
Add PropertyTestingKit to your `Package.swift`:
dependencies: [
.package(url: "https://github.com/alex-reilly-dd/PropertyTestingKit.git", from: "0.0.1"),
],
targets: [
.testTarget(
name: "YourTests",
dependencies: ["PropertyTestingKit"]
),
]
## Usage
### Coverage-Guided Fuzzing
The `fuzz` function automatically generates inputs that maximize code coverage:
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")
}
}
}
**How it works:**
1. Starts with seed values (yours + type defaults from `MutatorProviding.defaultMutator`)
2. Runs each input and captures coverage
3. Inputs that hit new code paths are saved to the corpus
4. Mutates interesting inputs to discover more paths
5. Stops when the time limit is reached
6. Saves minimal corpus to disk for future runs
**On subsequent runs:**
- Replays saved corpus to check for crashes (regression testing)
### Corpus Storage
The corpus is saved alongside your test files:
Tests/
MyTests/
ParserTests.swift
Corpus/ # Created automatically
testParser/
corpus.json # Saved inputs + coverage signatures
Commit the `Corpus/` directory to version control for deterministic CI runs.
### Fuzzing vs. regression
There are two entry points. `fuzz(...)` explores inputs and maintains a corpus;
`regress(...)` only replays a saved corpus to verify it still passes. The split is
deliberate: regression takes none of the fuzz-only knobs (`seeds`, `coverageStrategy`,
`parallelism`), and its plugins are `AnalysisHandler`s that can only observe (`stop` /
`recordIssue`) — so it's impossible, at compile time, to hand a replay a configuration or
a plugin that would explore or mutate the corpus.
`fuzz(...)` takes a `persistence:` policy controlling how it treats an existing corpus:
| `CorpusPersistence` | Behavior |
|------|----------|
| `.auto` | Replay the corpus if one exists, otherwise fuzz fresh and save (default) |
| `.replace` | Delete any existing corpus, fuzz fresh, and save |
| `.extend` | Load the existing corpus as seeds, fuzz, and save |
| `.ephemeral` | Fuzz in memory only — ignore any existing corpus and don't save (nothing touches disk) |
**Per-test control:**
@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)
}
}
**Suite-level control via environment:**
Users may want to run background fuzzing campaigns outside of the standard CI loop uising `FUZZ_CORPUS_MODE=refuzzextend`. This allows a balance to be struck between fast deterministic test runs and thorough testing.
# Re-fuzz all tests, replacing existing corpora
FUZZ_CORPUS_MODE=refuzzreplace swift test
# Extend existing corpora with more fuzzing (2 minute duration)
FUZZ_CORPUS_MODE=refuzzextend FUZZ_DURATION=120 swift test
# CI mode: force every fuzz test to replay-only — no exploration (fast, deterministic).
# regress(...) tests always replay regardless of this variable.
FUZZ_CORPUS_MODE=regressiononly swift test
### Custom Seeds
Provide domain-specific seeds to guide the fuzzer toward edge cases:
@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)
}
}
}
### Custom Mutators
Use domain-specific mutation strategies instead of the default `MutatorProviding` conformance:
@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("