{"id":669,"date":"2026-09-11T05:03:22","date_gmt":"2026-09-11T05:03:22","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/what-is-fuzz-testing-pest-fuzz-found-my-division-by-zero\/"},"modified":"2026-09-11T05:03:22","modified_gmt":"2026-09-11T05:03:22","slug":"what-is-fuzz-testing-pest-fuzz-found-my-division-by-zero","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/what-is-fuzz-testing-pest-fuzz-found-my-division-by-zero\/","title":{"rendered":"What Is Fuzz Testing? Fuzz for Pest Found My Division by Zero"},"content":{"rendered":"<p>Okay, this is going to sound dumb, but I&rsquo;d written maybe four hundred Pest tests before I asked myself what fuzz testing actually is, as opposed to what I vaguely assumed it was. My assumption was &ldquo;random garbage in, see if it crashes.&rdquo; That&rsquo;s roughly right, and also the boring half of the story.<\/p>\n<p>What prompted this was a Laravel News post about <a href=\"https:\/\/laravel-news.com\/pest-fuzz\" rel=\"nofollow noopener\" target=\"_blank\">Fuzz, a package by Jon Purvis<\/a> that plugs coverage-guided fuzzing into Pest 5. I installed it on a Tuesday evening expecting a toy. Within about forty seconds it found a <code>DivisionByZeroError<\/code> in a helper I&rsquo;d tested by hand and shipped to production last year. Not a hypothetical one. The exact same bug, in a parser that does the exact same thing as the example in the article, because apparently we all write the same rate limit parser.<\/p>\n<p>So this is a post about what fuzz testing is, why the coverage-guided part matters more than the random part, and where it fits next to the tests you already have. Plus the bug, obviously.<\/p>\n<h2 id=\"what-fuzz-testing-is-and-what-coverage-guided-adds\">What fuzz testing is, and what &ldquo;coverage-guided&rdquo; adds<\/h2>\n<p>A fuzzer takes an input you give it, mutates it (adds bytes, drops bytes, swaps bytes), and runs your code with the result. It does this thousands of times and reports when something blows up. That&rsquo;s plain fuzzing, and on its own it&rsquo;s mostly noise. Random strings rarely get past the first <code>if<\/code> in a parser, so you spend your budget testing the error path over and over.<\/p>\n<p>A coverage-guided fuzzer watches which paths each input takes through your code. When an input reaches a branch nothing has reached before, the fuzzer keeps it and uses it as a seed for further mutation. The saved set of interesting inputs is called the corpus. The effect is that the fuzzer climbs through your parser one validation step at a time. Once a string gets past &ldquo;does it start with a bracket,&rdquo; the fuzzer keeps bending that string to see what the code after the bracket check does with it.<\/p>\n<p>Fuzz uses <a href=\"https:\/\/github.com\/nikic\/PHP-Fuzzer\" rel=\"nofollow noopener\" target=\"_blank\">nikic&rsquo;s PHP-Fuzzer<\/a> for this. PHP-Fuzzer instruments your code by tracking transitions between blocks and roughly how often each runs, and Fuzz wires that up for you. No Xdebug, no <code>--coverage<\/code> flag. I checked twice because that seemed too easy.<\/p>\n<p>One thing the article says that I want to repeat because it&rsquo;s easy to forget: a passing run means the fuzzer found nothing in that many attempts. It does not mean there&rsquo;s nothing to find.<\/p>\n<h2 id=\"the-bug-it-found-in-my-code\">The bug it found in my code<\/h2>\n<p>Here&rsquo;s the helper, more or less. It reads a spec like <code>100\/60s<\/code> and returns requests per second:<\/p>\n<pre><code class=\"language-php\">namespace App;\n\nfinal class RateLimit\n{\n    public static function perSecond(string $spec): float\n    {\n        $parts = explode('\/', $spec);\n\n        $count = (int) $parts[0];\n        $window = (int) rtrim($parts[1] ?? '1s', 's');\n\n        return $count \/ $window;\n    }\n}\n<\/code><\/pre>\n<p>My existing tests, and I&rsquo;m not proud of this, looked like a dataset of the happy cases:<\/p>\n<pre><code class=\"language-php\">test('parses rate limit specs', function (string $spec, float $expected) {\n    expect(RateLimit::perSecond($spec))-&gt;toBe($expected);\n})-&gt;with([\n    ['100\/60s', 100 \/ 60],\n    ['5\/1s', 5.0],\n    ['1000\/3600s', 1000 \/ 3600],\n]);\n<\/code><\/pre>\n<p>Green. Shipped. Here&rsquo;s the fuzz test, which needs PHP 8.4 and Pest 5:<\/p>\n<pre><code class=\"language-bash\">composer require jonpurvis\/fuzz --dev\n<\/code><\/pre>\n<pre><code class=\"language-php\">use App\\RateLimit;\nuse function Fuzz\\fuzz;\n\n$target = static function (string $input): void {\n    RateLimit::perSecond($input);\n};\n\ntest('rate limit spec parser never fatals', function () use ($target): void {\n    fuzz($target)\n        -&gt;seed(['100\/60s', '5\/1s', '1000\/3600s'])\n        -&gt;withDictionary(['\/', 's', '0', '1'])\n        -&gt;runs(2000)\n        -&gt;maxLen(16)\n        -&gt;run('rate-limit-parser');\n});\n<\/code><\/pre>\n<p>It found <code>5\/<\/code>. Empty window, cast to int, zero, division by zero, <code>DivisionByZeroError<\/code>. The same input the Laravel News run found, which I take as evidence that the search is guided and not lucky.<\/p>\n<p>A few details in that snippet that aren&rsquo;t decoration. <code>seed()<\/code> gives the fuzzer starting points; <code>withDictionary()<\/code> gives it fragments to splice in without limiting it to those characters. <code>runs(2000)<\/code> is the budget and <code>maxLen(16)<\/code> caps generated strings at 16 bytes, which keeps things fast. The name passed to <code>run()<\/code> matters because Fuzz uses it to separate saved corpus and crash files per test. And the <code>$target<\/code> closure sits outside <code>test()<\/code> on purpose. Fuzz runs your function in a separate PHP process where Pest&rsquo;s generated test class doesn&rsquo;t exist, and the article notes that passing <code>Closure::fromCallable()<\/code> directly recorded no coverage in their check of v1.0.1. Keep the wrapper.<\/p>\n<p>Crashes land in <code>.pest\/fuzz-crashes\/<\/code> by default. The workflow I&rsquo;ve settled on is: read the crash file, fix the parser, add the input to the named dataset with an assertion about the correct behaviour, and let the fuzz test go back to hunting for the next one.<\/p>\n<h2 id=\"crash-only-versus-checking-the-answer\">Crash-only versus checking the answer<\/h2>\n<p>The test above only fails on a crash. A wrong return value sails through. <code>5\/0s<\/code> would happily divide by zero, but <code>5\/x<\/code> returns <code>5.0<\/code> because <code>(int) 'x'<\/code> is zero and, wait, no, that also divides by zero. Fine, bad example. <code>100\/6O<\/code> (letter O) returns 100\/6 because <code>(int) '6O'<\/code> is 6. Nothing fatals. The test passes. The value is wrong.<\/p>\n<p>To catch that class of bug you put a Pest expectation inside the target function so every generated input gets checked. For something like an encoder, the natural check is a round trip:<\/p>\n<pre><code class=\"language-php\">$target = static function (string $input): void {\n    $encoded = Slug::encode($input);\n    expect(Slug::decode($encoded))-&gt;toBe($input);\n};\n<\/code><\/pre>\n<p>Now the fuzzer isn&rsquo;t just looking for fatals. It&rsquo;s looking for any input where your invariant doesn&rsquo;t hold, and it&rsquo;s steering toward the interesting parts of the code while it does it. That&rsquo;s the version of fuzzing that feels less like a smoke test and more like a second developer who&rsquo;s better at being annoying than you are.<\/p>\n<p>Worth knowing what Fuzz counts as a failure by default: <code>TypeError<\/code>, unsuppressed warnings and notices, and similar. Ordinary exceptions are ignored, including Laravel&rsquo;s validation exceptions, which is the right default for app code where throwing on bad input is the intended behaviour. The <code>allow()<\/code> method narrows that list when you want an unexpected exception type to fail the run. There&rsquo;s also a per-input <code>timeout()<\/code> if you&rsquo;re worried about a pathological input hanging, which needs <code>pcntl<\/code>.<\/p>\n<h2 id=\"where-it-fits-and-where-it-doesnt\">Where it fits, and where it doesn&rsquo;t<\/h2>\n<p>I don&rsquo;t think fuzzing replaces anything I already do, and I&rsquo;d be suspicious of anyone who says it does. Named datasets stay. They document the behaviour you designed. Fuzz is for the gap between &ldquo;the inputs I thought of&rdquo; and &ldquo;the inputs a form field will actually receive,&rdquo; which for anything parsing user text is enormous.<\/p>\n<p>The rate limit helper is a slightly unfair demo because it barely branches. Coverage guidance earns its keep in parsers with successive checks, where getting past one gate opens up the next. A CSV importer. A query-string filter DSL. That thing that turns <code>\"next tuesday 3pm\"<\/code> into a Carbon instance. If your app has one of those and it&rsquo;s tested with a dataset of five strings, that&rsquo;s where I&rsquo;d start.<\/p>\n<p>On budget: I keep <code>runs()<\/code> low in the normal suite so it adds a couple of seconds, and I&rsquo;ve got a scheduled GitHub Actions job that runs the same tests with a much bigger budget nightly. If you set up workflows that way already, this slots in next to whatever else you run on a cron. I covered the reusable-workflow side of that in <a href=\"https:\/\/abrarqasim.com\/blog\/github-actions-reusable-workflows-the-bug-i-fixed-eleven-times\" rel=\"noopener\">the bug I fixed eleven times<\/a>. And if you&rsquo;re still deciding whether to be on Pest at all, I wrote up <a href=\"https:\/\/abrarqasim.com\/blog\/pest-vs-phpunit-2026-what-i-actually-reach-for\" rel=\"noopener\">where I reach for Pest versus PHPUnit<\/a> and nothing here changes that answer.<\/p>\n<p>The <a href=\"https:\/\/github.com\/JonPurvis\/fuzz#readme\" rel=\"nofollow noopener\" target=\"_blank\">Fuzz README<\/a> covers the rest of the API, including custom storage directories and dictionary options. It&rsquo;s short. Read it.<\/p>\n<h2 id=\"what-im-still-unsure-about\">What I&rsquo;m still unsure about<\/h2>\n<p>Two things. First, I haven&rsquo;t run this against anything that touches the database, and I suspect the separate-process model means the Laravel container isn&rsquo;t booted in the target, so anything that reaches for a facade is going to have a bad time. Pure functions are the sweet spot for now. Second, I&rsquo;m not sure how the corpus should be treated in version control. Committing it means the fuzzer starts warm on every machine; not committing it means every fresh clone starts from seeds. I&rsquo;ve committed it for now and I&rsquo;ll report back if that turns out to be a mistake.<\/p>\n<p>I do a fair amount of &ldquo;why does this parser fall over on real data&rdquo; work for clients, and the honest answer is usually that nobody wrote the tests I&rsquo;d have written. This is the first tool I&rsquo;ve used in PHP that writes those tests for me. It&rsquo;s not magic. It&rsquo;s a loop with good taste. I&rsquo;ll take it. If you want that kind of second pass on something you&rsquo;ve shipped, <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">that&rsquo;s the sort of thing I do<\/a>.<\/p>\n<h2 id=\"this-week\">This week<\/h2>\n<p>Find one function in your app that takes a string from the outside world and turns it into something structured. Write a fuzz test for it with three seeds, a small dictionary, and <code>runs(500)<\/code>. Run it once. If it finds nothing, raise the budget and run it again before bed. If it finds something, add the crash input to your dataset and fix the parser. Then decide whether the coverage guidance earned a permanent spot in your suite. Mine did, and it took one evening to find out.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Fuzz testing explained through a real bug: coverage-guided fuzzing in Pest 5 found a DivisionByZeroError my hand-written dataset missed. Setup and where it fits.<\/p>\n","protected":false},"author":2,"featured_media":668,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Fuzz testing explained through a real bug: coverage-guided fuzzing in Pest 5 found a DivisionByZeroError my hand-written dataset missed. Setup and where it fits.","rank_math_focus_keyword":"what is fuzz testing","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[52,45],"tags":[740,56,222,53,30],"class_list":["post-669","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-php","category-programming","tag-fuzzing","tag-laravel","tag-pest","tag-php","tag-testing"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/669","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=669"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/669\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/668"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=669"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=669"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=669"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}