What Is Fuzz Testing?
A conventional test suite covers the cases you think of: a valid string, an empty one, maybe a boundary value. Fuzz testing takes a different approach — it generates and mutates inputs automatically, then passes them to your code looking for failures you never anticipated.
Fuzz, a package by Jon Purvis, brings this technique directly into Pest 5 tests. Under the hood it uses nikic's PHP-Fuzzer to mutate strings and report any failures through Pest's familiar output.
How Coverage-Guided Fuzzing Works
A basic fuzzer mutates an input — adding, removing, or replacing characters — and runs your code with the result. A coverage-guided fuzzer goes further: it watches which code paths each input exercises. When a mutated input reaches a previously unexplored branch, the fuzzer saves it to a corpus and uses it as the basis for further mutations.
This matters for code with successive validation steps. Once an input passes the first check, the fuzzer can focus on mutating strings that reach the second check, and so on. PHP-Fuzzer tracks transitions between PHP code blocks to collect this feedback. Fuzz handles the instrumentation for you — no Xdebug or --coverage flag required.
Installation
Fuzz requires PHP 8.4+ and Pest 5. Install it as a dev dependency:
composer require jonpurvis/fuzz --dev
A Practical Example
Consider a helper that parses a rate-limit spec like 100/60s into requests per second:
namespace App;
final class RateLimit
{
public static function perSecond(string $spec): float
{
$parts = explode('/', $spec);
$count = (int) $parts[0];
$window = (int) rtrim($parts[1] ?? '1s', 's');
return $count / $window;
}
}
The helper does no input validation. A fuzz test can probe it for crashes:
use App\RateLimit;
use function Fuzz\fuzz;
$target = static function (string $input): void {
RateLimit::perSecond($input);
};
test('rate limit spec parser never fatals', function () use ($target): void {
fuzz($target)
->seed(['100/60s', '5/1s', '1000/3600s'])
->withDictionary(['/', 's', '0', '1'])
->runs(2000)
->maxLen(16)
->run('rate-limit-parser');
});
seed()— starting examples the fuzzer mutates from.withDictionary()— fragments the fuzzer may insert (does not restrict other characters).runs()— total mutation budget.maxLen()— maximum byte length of generated strings.run()— unique name used to separate corpus and crash files.
Important: define $target outside test(). Fuzz runs the closure in a separate PHP process where Pest's generated test class is unavailable; keeping it outside avoids that dependency and ensures coverage is recorded correctly.
What Fuzz Found
In a test run, Fuzz produced the input 5/. The missing window segment becomes an empty string, PHP casts it to 0, and dividing by zero throws a DivisionByZeroError. Pest reports the test as failed. The crashing input is saved under .pest/fuzz-crashes/ so you can reproduce and fix it.
What the Test Actually Checks
By default, Fuzz fails on TypeError, unsuppressed PHP warnings and notices, and division-by-zero errors. Ordinary exceptions — including Laravel validation exceptions — are ignored unless you use the allow() method to opt specific exception types in.
To catch wrong return values as well as crashes, add a Pest expectation inside the target closure. For example, an encode/decode round-trip test could assert that decoding an encoded string always returns the original value.
A per-input timeout is available via timeout(), which requires the pcntl extension.
Fitting Fuzz Into Your Workflow
Fuzz complements, rather than replaces, your regular tests and datasets. Use it when code accepts a wide range of possible inputs — parsers, format converters, user-supplied text handlers — where listing every edge case by hand is impractical.
- Keep a small run budget (
runs) in your normal test suite for fast feedback. - Run a larger budget in a scheduled CI job for deeper exploration.
- After fixing a crash, add the failing input to a named dataset so regression is caught by ordinary tests.
Key Takeaways
- Fuzz for Pest wraps PHP-Fuzzer with a clean Pest 5 API.
- Coverage-guided mutation finds inputs that reach new code branches automatically.
- No Xdebug or
--coverageflag needed — instrumentation is built in. - Failing inputs are saved to
.pest/fuzz-crashes/for easy reproduction. - Define the target closure outside
test()to ensure coverage recording works. - Combine fuzz tests with conventional datasets for complete coverage.
Source: Find Unexpected Test Inputs with Fuzz for Pest — Laravel News