Ton pipeline CI affiche 90 % de couverture, les tests passent en vert, l'équipe se félicite. Trois semaines plus tard, un bug en production : une condition >= là où il fallait >, une règle de remise silencieusement cassée depuis le dernier sprint. Les tests étaient verts. La couverture, aussi. Le problème n'était ni la quantité des tests ni leur vitesse d'exécution — c'était leur incapacité à détecter la régression. C'est précisément ce que le mutation testing mesure, et ce que Pest 3, sorti le 9 septembre 2024, intègre désormais nativement dans l'écosystème PHP sans dépendance supplémentaire.
Ce que 90 % de couverture ne garantit pas
La couverture de code répond à une seule question : cette ligne a-t-elle été exécutée pendant les tests ? Elle ne dit rien sur la pertinence des assertions. Un test peut traverser 50 lignes, vérifier que le résultat n'est pas null, et compter pour 50 lignes couvertes — même si supprimer la moitié de la logique métier ne changerait rien au résultat du test. Voici l'exemple classique qui installe une fausse confiance :
// Test qui couvre sans vraiment tester
it('calcule un total', function (): void {
$calculator = new PricingCalculator();
$result = $calculator->computeTotal(100.0, 5);
expect($result)->toBeFloat(); // toujours vrai — zéro régression détectée
});
it('applique une remise', function (): void {
$calculator = new PricingCalculator();
$result = $calculator->computeTotal(100.0, 10, applyDiscount: true);
expect($result)->toBeLessThan(2000.0); // garde-fou trop large pour être utile
});Ces deux tests poussent la couverture à 100 % sur PricingCalculator. Tu peux changer le taux de TVA, inverser la condition de remise, supprimer l'arrondi — ils restent verts. Le mutation testing retourne le problème : il force le code à muter et vérifie que les tests échouent en conséquence.
Le principe du mutation testing avec Pest 3
À chaque exécution de --mutate, Pest génère des copies du code source en y injectant une modification unitaire — une mutation. Il relance ensuite la suite de tests sur ce code altéré. Si les tests passent malgré la mutation, celle-ci survit : tes assertions ne couvrent pas ce comportement. Si les tests échouent, la mutation est tuée : l'assertion est pertinente et détecte bien un changement de comportement. Pest 3 embarque six familles de mutateurs qui couvrent les cas les plus fréquents en PHP métier :
- ArithmeticOperator :
*↔/,+↔-— révèle si les calculs numériques sont vérifiés avec des valeurs précises - ComparisonOperator :
>↔>=↔<↔<=— l'un des pièges les plus fréquents sur les règles métier à seuil - LogicalOperator :
&&↔||— détecte les conditions composées jamais testées dans tous leurs états - Boolean :
true↔false— révèle les flags dont la valeur n'est jamais vérifiée par les assertions - Return : supprime ou remplace la valeur de retour — détecte les tests qui n'inspectent pas le résultat
- MethodCall : retire l'appel d'une méthode — révèle les effets de bord (persistance, événements) non vérifiés
Le Mutation Score Indicator (MSI) est le rapport entre mutations tuées et total de mutations générées. Un MSI de 40 % signifie que 60 % des modifications automatiques du code source ne sont pas détectées par tes tests. L'objectif n'est pas 100 % — certaines mutations sont sémantiquement équivalentes — mais rester sous 70 % sur du code métier critique est un signal d'alerte sérieux.
Installation et configuration sur Symfony 7.2
# Pest 3 inclut le mutation testing sans paquet supplémentaire
composer require --dev pestphp/pest:"^3.0"
# Initialise la structure (crée Pest.php et le dossier tests/)
./vendor/bin/pest --initConfigure Pest.php à la racine du projet pour faire hériter les tests d'intégration du KernelTestCase Symfony et activer le mutation testing ciblé sur la couche Domain. Commence toujours par un périmètre réduit : le Domain est idéal car il est pur PHP, sans dépendance au framework, ce qui rend chaque cycle de mutation rapide à exécuter.
<?php
// Pest.php
use Tests\Integration\TestCase;
// Les tests d'intégration héritent du KernelTestCase Symfony
pest()
->extend(TestCase::class)
->in('tests/Integration');
// Configuration du mutation testing
pest()
->mutate()
->path('src/Domain') // cible uniquement le domaine métier
->ignore('src/Domain/Shared/ValueObject/Uuid.php') // exclut le code généré/trivial
->covered() // ne mute que les lignes déjà couvertes par les tests
->min(75.0); // fait échouer le CI sous 75 % de MSI<?php
// tests/Integration/TestCase.php
namespace Tests\Integration;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
abstract class TestCase extends KernelTestCase
{
}Pour les tests unitaires sur le Domain (sans Symfony), aucun TestCase n'est nécessaire. Pest fonctionne en PHP pur, ce qui rend les mutations particulièrement rapides à exécuter sur ces classes.
Un exemple concret : calculateur de tarification avec TVA
Prenons un service de calcul de prix — exactement le genre de code où un opérateur mal choisi passe inaperçu pendant des mois. Le service applique une remise volume de 10 % à partir de 10 unités, puis ajoute la TVA.
Votre dette technique s'accumule ?
Demander un audit →<?php
// src/Domain/Pricing/PricingCalculator.php
namespace App\Domain\Pricing;
final readonly class PricingCalculator
{
public function __construct(
private float $vatRate = 0.20,
) {}
/**
* @throws \InvalidArgumentException
*/
public function computeTotal(float $basePrice, int $quantity, bool $applyDiscount = false): float
{
if ($quantity <= 0) {
throw new \InvalidArgumentException('La quantité doit être positive.');
}
$subtotal = $basePrice * $quantity;
if ($applyDiscount && $quantity >= 10) {
$subtotal *= 0.90; // remise 10 %
}
return round($subtotal * (1 + $this->vatRate), 2);
}
}Avec la suite de tests légère montrée plus haut, les deux tests couvrent 100 % des lignes. Voyons ce que Pest 3 en pense quand on lance les mutations.
Lancer les mutations et décrypter le rapport
# Lancer le mutation testing sur le projet complet (périmètre défini dans Pest.php)
./vendor/bin/pest --mutate
# En ciblant une classe précise (utile en début de démarche)
./vendor/bin/pest --mutate --class="App\Domain\Pricing\PricingCalculator"
# Paralléliser pour les gros projets
./vendor/bin/pest --mutate --parallel --covered-onlyMUTATIONS ................................................................
src/Domain/Pricing/PricingCalculator.php
✓ Killed ArithmeticOperator (ligne 22) $basePrice * $quantity → $basePrice / $quantity
✗ Survived ComparisonOperator (ligne 17) $quantity <= 0 → $quantity < 0
✓ Killed LogicalOperator (ligne 24) $applyDiscount && $quantity >= 10 → false
✗ Survived ComparisonOperator (ligne 24) $quantity >= 10 → $quantity > 10
✗ Survived ArithmeticOperator (ligne 25) $subtotal *= 0.90 → $subtotal *= 1.90
Mutations : 5. Killed : 2. Survived : 3. Not covered : 0.
Mutation score : 40.00 %Avec un MSI de 40 %, la suite est objectivement faible malgré 100 % de couverture. Les trois mutations survivantes sont parlantes : changer <= 0 en < 0 (une quantité nulle ne lèverait plus d'exception), transformer >= 10 en > 10 (la remise n'est pas appliquée à exactement 10 unités), ou modifier le coefficient de remise de 0.90 à 1.90 — aucun de ces changements n'est détecté. Les assertions sont trop vagues pour capturer ces écarts de comportement précis.
Corriger les mutations survivantes sans réécrire la suite
La stratégie efficace n'est pas de réécrire tous les tests, mais d'ajouter des assertions précises sur les valeurs exactes et les frontières. Chaque mutation survivante est un test manquant bien identifié. Voici la suite corrigée :
<?php
// tests/Unit/Domain/Pricing/PricingCalculatorTest.php
use App\Domain\Pricing\PricingCalculator;
// Pest 3 : lie explicitement ce fichier à la classe testée (utile pour --covered-only)
covers(PricingCalculator::class);
// Tue ArithmeticOperator (*→/) et valide le taux de TVA
it('calcule le total TTC : 100 € × 1 unité à 20 % TVA = 120 €', function (): void {
$calculator = new PricingCalculator(vatRate: 0.20);
expect($calculator->computeTotal(100.0, 1))->toBe(120.0);
});
// Tue ComparisonOperator (>=10 → >10) : le seuil exact doit déclencher la remise
it('applique la remise exactement au seuil de 10 unités', function (): void {
$calculator = new PricingCalculator(vatRate: 0.20);
// 100 × 10 × 0,90 × 1,20 = 1 080,00
expect($calculator->computeTotal(100.0, 10, applyDiscount: true))->toBe(1080.0);
});
// Tue ArithmeticOperator (0.90 → 1.90) : valeur exacte après remise
it('la remise réduit le sous-total de 10 % avant TVA', function (): void {
$calculator = new PricingCalculator(vatRate: 0.20);
// 50 × 11 × 0,90 × 1,20 = 594,00 — discrimine 0,90 de tout autre coefficient
expect($calculator->computeTotal(50.0, 11, applyDiscount: true))->toBe(594.0);
});
// Tue ComparisonOperator (<=0 → <0) : quantité nulle doit lever une exception
it('lève une exception pour quantité nulle ou négative', function (): void {
$calculator = new PricingCalculator(vatRate: 0.20);
expect(fn () => $calculator->computeTotal(100.0, 0))
->toThrow(\InvalidArgumentException::class, 'La quantité doit être positive.');
expect(fn () => $calculator->computeTotal(100.0, -1))
->toThrow(\InvalidArgumentException::class);
});
// Pas de remise sous le seuil, même avec le flag
it("9 unités avec applyDiscount:true ne bénéficient d'aucune réduction", function (): void {
$calculator = new PricingCalculator(vatRate: 0.20);
// 50 × 9 × 1,20 = 540,00 ; avec remise muté : 50 × 9 × 0,90 × 1,20 = 486,00
expect($calculator->computeTotal(50.0, 9, applyDiscount: true))->toBe(540.0);
});- Toujours vérifier une valeur exacte — jamais
toBeFloat()ounot->toBeNull()sur du code métier : c'est la règle d'or pour tuer les mutationsArithmeticOperator - Tester les frontières : si la condition est
>= 10, écris un test avec 9 et un avec 10, jamais seulement avec 11 - Utiliser
covers()pour lier chaque fichier de test à la classe testée, ce qui active correctement le filtre--covered-only - Prioriser le Domain et les services : les entités Doctrine et les DTOs produisent beaucoup de mutations peu utiles — commence par la logique métier pure
- Exclure le code généré : migrations, factories de fixtures,
Kernel.php— utilise la clé->ignore()dansPest.php - Intégrer au CI progressivement : commence avec
--min=60, monte d'un palier par sprint jusqu'à 80 % sur le périmètre critique
Après ces ajouts, le MSI passe de 40 % à 100 % sur cet exemple — sans toucher au code source, sans augmenter la durée de la suite au-delà de quelques secondes. En pratique sur un projet réel, viser 75–85 % de MSI sur le Domain est atteignable en une ou deux sessions ciblées, en se concentrant sur les mutations ComparisonOperator et ArithmeticOperator qui survivent.
Intégrer le mutation testing dans le pipeline CI
# .github/workflows/tests.yml (extrait)
jobs:
mutation:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Setup PHP 8.4
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: xdebug
- name: Install dependencies
run: composer install --no-interaction --prefer-dist
- name: Run tests with coverage (requis avant --mutate --covered-only)
run: ./vendor/bin/pest --coverage --coverage-clover=coverage.xml
- name: Run mutation testing
run: ./vendor/bin/pest --mutate --covered-only --min=75 --parallelLe mutation testing est plus lent qu'une suite classique : prévois 3 à 5 minutes sur 500 mutations pour un projet moyen. L'option --parallel divise ce temps par le nombre de cœurs disponibles ; sur les runners GitHub Actions standard (2 vCPU), le gain est déjà significatif. Pour ne pas pénaliser la boucle de feedback des développeurs, réserve ce job aux Pull Requests et à la branche main, plutôt qu'à chaque push de feature branch.
Ce que le mutation testing change concrètement
Le mutation testing ne remplace pas la couverture de code : il la complète en répondant à la question qu'elle ne pose jamais — mes assertions détecteraient-elles vraiment une régression ? Un projet avec 80 % de couverture et un MSI de 80 % est bien plus robuste qu'un projet à 95 % de couverture avec un MSI de 35 %. C'est le changement de perspective que Pest 3 rend accessible sans infrastructure supplémentaire : une commande, un rapport, des tests à corriger. La prochaine fois qu'un bug en production passera entre les mailles d'une suite en vert, tu sauras exactement comment mesurer pourquoi — et comment y remédier.
Votre dette technique s'accumule ?
Audit complet en 10 jours. Recommandations priorisées et actionnables.