Ce qui se passe
Fuzz, un paquet de Jon Purvis, permet d'écrire un test de fuzzing à l'intérieur d'un test Pest 5. Il s'appuie sur PHP-Fuzzer de nikic pour déformer les chaînes que vous fournissez et remonter les échecs par Pest.
composer require jonpurvis/fuzz --devPrérequis : PHP 8.4+ et Pest 5.
Ce que « guidé par la couverture » veut dire
Un fuzzer ordinaire ajoute, retire ou remplace des morceaux d'une chaîne, puis exécute votre code avec le résultat. Un fuzzer guidé par la couverture observe en plus quels chemins ces entrées empruntent. Quand une entrée atteint une route jamais explorée, il la conserve et s'en sert pour fabriquer d'autres variantes ; la collection ainsi constituée s'appelle le corpus.
L'intérêt se voit sur un parseur : si le code vérifie d'abord la présence d'un crochet ouvrant, la plupart des entrées aléatoires échouent sur ce premier test. Dès qu'une entrée le franchit, le fuzzer peut continuer à la déformer pour atteindre le code qui suit.
PHP-Fuzzer collecte ce retour en suivant les transitions entre blocs de code et
leur fréquence approximative. Fuzz s'en charge pour vous : ni Xdebug ni l'option
--coverage de Pest ne sont nécessaires.
À quoi ça ressemble
L'exemple de l'article part d'un utilitaire qui lit une limite de débit du type
100/60s et la convertit en requêtes par seconde, sans valider son entrée :
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() donne les exemples de départ, withDictionary() fournit des fragments que
le fuzzer peut insérer sans s'y limiter, runs() fixe le budget de recherche et
maxLen() la taille des chaînes générées. Le nom passé à run() sert à séparer
corpus et fichiers de plantage entre les tests.
Dans l'exécution décrite, Fuzz trouve 5/ : la fenêtre manquante devient une
chaîne vide, PHP la convertit en 0, et la division lève une
DivisionByZeroError. L'entrée fautive est écrite dans .pest/fuzz-crashes/,
donc rejouable telle quelle.
Deux pièges
La fonction cible se définit en dehors de test(). Fuzz l'exécute dans un
processus PHP séparé, où la classe de test générée par Pest n'existe pas. Sur la
version 1.0.1, la vérification de l'article montre que le passage direct d'un
Closure::fromCallable() n'enregistre aucune couverture, là où le wrapper
externe fonctionne.
Un test qui passe ne prouve rien d'autre que l'absence de trouvaille. Fuzz
signale les TypeError ainsi que les warnings et notices non supprimés, mais
ignore les exceptions ordinaires par défaut — y compris les exceptions de
validation Laravel. La méthode allow() permet de restreindre cette liste quand
une exception inattendue doit faire échouer le test. Un timeout() par entrée
existe aussi, mais réclame l'extension pcntl.
Autre limite du cas d'exemple : ce test ne détecte qu'un plantage, pas une valeur de retour fausse. Pour couvrir cela, il faut placer une attente Pest dans la fonction cible — par exemple vérifier qu'encoder puis décoder rend la chaîne d'origine.
À retenir
Le fuzzing ne remplace pas vos jeux de données : gardez-les pour les cas connus et les résultats attendus. Fuzz est utile là où le code accepte plus d'entrées que vous ne pouvez en lister — un parseur, un format de saisie utilisateur. Et la guidance par couverture rend le plus de service sur les codes à validations successives, où franchir un contrôle donne accès au suivant.
Pratique : un petit budget dans la suite habituelle, une recherche longue dans un job de CI planifié.