jonpurvis/fuzz

Coverage-guided fuzz testing for PestPHP
4 4
Install
composer require jonpurvis/fuzz
Latest Version:v1.0.1
PHP:^8.4
License:MIT
Last Updated:Aug 16, 2026
Links: GitHub  ·  Packagist
Maintainer: JonPurvis

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.

Tests GitHub last commit Packagist PHP Version GitHub issues GitHub Packagist Downloads

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:

  1. Starts from seed(...) — your known-good examples become the initial library.
  2. Mutates those bytes repeatedly (flip/delete/insert, splice in dictionary tokens).
  3. Watches which lines of your PHP ran for that input (coverage).
  4. If the input hit something new, keeps it and mutates it further. If it only revisited the same lines as before, throws it away.
  5. Fails the Pest test if the target throws an uncaught Error / TypeError / times out — and, with saveCrashes() (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 string event)
  • {"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 and null / event). That biases mutations toward structurally relevant junk instead of pure noise. You can also pass a path to a .dict file.
  • 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 with crashDir()). 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).

Useful Links

Related Packages

pestphp/pest-plugin-agent

AI agent verification plugin for Pest

109,887 6
nekofar/pest-plugin-mock-client

Pest Plugin for Mock HTTP client

22 1
quadraecom/pest-plugin-selectors

Add CSS selector-based testing capabilities to Pest

2,035 1
pestphp/pest-plugin-type-coverage

The Type Coverage plugin for Pest PHP.

5,079,643 37