jonpurvis/fuzz
Fuzz - Coverage-guided fuzzing for Pest
A PestPHP plugin that wraps nikic/php-fuzzer so you can write coverage-guided fuzz tests in the Pest style you already know and love: drop a fuzz(...)->run() call inside a normal test(), and it sits alongside the rest of your suite. Under the hood, the fuzzer is steered by which lines of your code each input actually runs. Meaning: if a mutated input only revisits the same happy-path lines the seed already hit, it gets discarded; if it suddenly takes a different if / null / error path, that input is kept and mutated further. That is what “coverage-guided” is.
Introduction
Fuzz is a PestPHP plugin for coverage-guided fuzz testing. It searches for inputs that crash your code (or break invariants), using the familiar Pest syntax so fuzz cases read like any other test in your suite.
Pest datasets are great for confirming cases you already thought of. Fuzz mutates seeds, keeps the ones that run new lines of your code, and hunts for the cases you forgot — TypeErrors, non-finite math, leaky sanitizers, hostile JSON shapes, and more.
Requires PHP 8.4+ and Pest 5:
composer require jonpurvis/fuzz --dev
Examples
Let's say your app handles Stripe-style webhooks and trusts the decoded JSON a little too much:
namespace App\Webhooks;
final class PayloadParser
{
public static function eventName(string $json): string
{
$data = json_decode($json, true);
// Assumes an object with a string "event" — fine for happy paths…
return $data['event'];
}
}
That helper is open to hostile shapes you probably never typed by hand: null, [], {}, {"type":"ping"}, truncated JSON, nested junk, and whatever else arrives on the wire.
You could cover it with a Pest dataset — a predetermined list of values you already thought of:
it('parses known webhook payloads', function (string $json, string $expected): void {
expect(PayloadParser::eventName($json))->toBe($expected);
})->with([
'checkout' => ['{"event":"checkout.session.completed"}', 'checkout.session.completed'],
'invoice' => ['{"event":"invoice.paid"}', 'invoice.paid'],
]);
it('rejects known bad webhook payloads', function (string $json): void {
expect(fn () => PayloadParser::eventName($json))->toThrow(TypeError::class);
})->with([
'empty object' => ['{}'],
'null json' => ['null'],
]);
Datasets are great for regressions and examples you care about by name. They only ever send what you listed.
Or you could use fuzz testing — because the input space is huge, and the fuzzer keeps mutating around your seeds (preferring inputs that hit new lines) until something crashes:
use function Fuzz\fuzz;
test('webhook parser never fatals on hostile JSON', function (): void {
fuzz(Closure::fromCallable([PayloadParser::class, 'eventName']))
->seed([
'{"event":"checkout.session.completed"}',
'{"event":"invoice.paid","id":"in_123"}',
])
->withDictionary(['{', '}', '[', ']', 'null', 'event', ':', ','])
->runs(2000)
->maxLen(64)
->saveCrashes()
->run();
});
Prefer static callables (or Closure::fromCallable) so the isolated worker does not need Pest's generated test class.
Unlike a dataset, this does not check a fixed list of JSON strings. It searches:
- Starts from
seed(...)— your known-good examples become the initial library. - Mutates those bytes repeatedly (flip/delete/insert, splice in dictionary tokens).
- Watches which lines of your PHP ran for that input (coverage).
- If the input hit something new, keeps it and mutates it further. If it only revisited the same lines as before, throws it away.
- Fails the Pest test if the target throws an uncaught
Error/TypeError/ times out — and, withsaveCrashes()(default), writes the payload to.pest/fuzz-crashes/{hash}/crash-*.txt.
That loop is what coverage-guided means. Imagine your seed only walks the happy path. A mutation that still only walks that path teaches the fuzzer nothing, so it moves on. A mutation that suddenly takes a different if / switch / error branch is interesting: the fuzzer keeps that input and breeds more variants from it. Over thousands of runs, that pushes the search toward the weird shapes that actually stress your code, instead of wasting the budget on noise that never leaves the happy path.
So a dataset answers “do these cases I thought of behave?” This fuzz test answers “can we find a case I did not list that still breaks eventName?”
Starting from a seed like {"event":"invoice.paid","id":"in_123"}, mutations might wander into inputs such as:
{}/[]/null— valid JSON, wrong shape (no stringevent){"type":"invoice.paid"}— object, but the key you assumed is missing{"event":null}/{"event":[]}— key present, value not a string{"event":"invoice.paid"— truncated / unbalanced braces{event:"invoice.paid"}— almost-JSON after dictionary splice{"event":"invoice.paid","event":1}— duplicate keys, odd types- binary junk under 64 bytes that still reaches
json_decode
You would rarely hand-author all of those into a dataset. The fuzzer is there to stumble into them (and similar) within the runs budget.
What the chain is doing:
seed([...])— starting examples. Without seeds the fuzzer begins from empty/random input and spends longer reaching interesting JSON.withDictionary([...])— fragments the mutator is allowed to insert (here: JSON punctuation andnull/event). That biases mutations toward structurally relevant junk instead of pure noise. You can also pass a path to a.dictfile.runs(2000)— budget: at most 2000 target executions this test. Higher = more searching, slower CI. Keep this small in the default suite; raise it for overnight/soak runs.maxLen(64)— hard cap on input size in bytes. Stops the fuzzer from growing huge payloads when a small crash is enough.saveCrashes()— when a crash is found, persist the payload as.pest/fuzz-crashes/{hash}/crash-*.txt(override withcrashDir()). Saving does not suppress the failure — Pest still fails so you can replay the input or promote it into a dataset.
Use both: datasets lock in known good/bad cases; fuzz hunts for the ones you forgot. When fuzz finds a crash, paste that payload into a named dataset so it never slips back in.
| Method | Meaning |
|---|---|
runs(int) |
Max target executions (default 1000) |
maxLen(int) |
Max input byte length |
timeout(int) |
Per-input seconds (pcntl) |
withDictionary(array) |
.dict paths and/or keyword strings |
seed(array) |
Starting example inputs (strings or files) |
libraryDir(string) |
Where interesting inputs are kept (default .pest/fuzz-library/{hash}) |
crashDir(string) |
Where crashes are saved (default .pest/fuzz-crashes/{hash}) |
saveCrashes(bool) |
Persist crashing inputs to crashDir as crash-*.txt (default true; does not suppress the failure) |
allow(array) |
Domain exception classes to ignore |
run() |
Execute in an isolated worker |
- Seed — starting example you provide
- Library — growing set of interesting inputs (kept because they found new code paths)
- Dictionary — fragments the mutator may insert (
null,{,<script>, …) - Crash — uncaught
Error/ timeout / failed Pest expectation inside the target
Contributing
Contributions to the package are more than welcome — open an Issue or submit a Pull Request. Please run composer test before opening a PR (see CONTRIBUTING.md).