Couverture de l'article Testez vos tests PHP avec Infection
Retour aux articles

L'agence

WanadevStudio

Testez vos tests PHP avec Infection

Il y a quelques semaines, nous vous parlions des tests fonctionnels avec Behat et Symfony. Parallèlement à ça, nous avons renforcé la qualité de nos tests unitaires grâce à ce que l'on appelle des tests de mutation. Voyons comment Infection à apporter la confiance en nos tests qu'il nous manquait !

Avez-vous confiance ?

Une question à laquelle il est souvent très complexe de répondre est : "avez-vous confiance en vos tests ?". C'est vrai ça, nous testons notre code, mais qu'est-ce qui montre que nos tests testent réellement quelque chose ? C'est quelque chose de très dur à évaluer.

Nous ne parlerons pas de la pertinence de la couverture de tests (ou code coverage). Si vous ne savez pas ce que c'est, il s'agit du pourcentage de lignes de code exécutées pendant la suite de tests par rapport à toute la code-base. Quelque soit son utilité et son interprétation, il est un fait irréfutable : il est très facile d'obtenir une couverture de 100%. Ce n'est pas parce que vous passez de partout que vous testez que tout fonctionne. Si une instruction est exécutée, mais que le résultat de cette instruction n'est pas vérifié, difficile de se dire que l'instruction est testée. Pourtant, elle est "couverte" par les tests.

Vous commencez à comprendre pourquoi il est si compliqué d'évaluer la confiance que l'on a en nos tests, quelque soit notre code coverage. Nous allons voir comment améliorer ça.

Juste avant de passer à la suite, j'aimerais faire une rapide aparté pour parler du type coverage, qui est un excellent indicateur sur votre code PHP. Le typage fort dans les langages de programmation amène indéniablement une rigueur accrue et un code plus robuste. Tomas Votruba, le créateur de l'outil de refactoring automatique RectorPHP, a créé une extension à PHPStan qui vous indique le pourcentage de propriétés typées dans votre code, et fait de même pour les arguments et les types de retours de vos fonctions. Nous ne détaillerons pas cela ici, mais je vous invite (très fortement) à mettre en place cet indicateur dans votre CI. Son article de blog vous explique tout cela : https://tomasvotruba.com/blog/how-to-measure-your-type-coverage/.

Les tests de mutation à la rescousse

Les tests de mutations ont un principe simple : pour une suite de tests passe avec succès, que l'on modifie votre code de manière imprévisible et que la suite de tests est toujours au vert, alors vos tests ont un souci. En effet, si votre code est modifié, les tests devraient casser et être au rouge. Si ce n'est pas le cas, cela indique que, généralement, vos tests ne testent pas assez de choses.

C'est exactement le rôle d'Infection. Infection est une sorte de surcouche à PHPUnit. Voici les étapes d'exécution d'Infection :

  • Exécute la suite de test sans apporter de modifications pour s'assurer que celle-ci est au vert avant toute chose ;
  • Apporter des modifications à votre code (changer un opérateur, retirer une ligne, supprimer l'argument d'une fonction, etc.). On appelle cela un mutant ;
  • Vérifier qu'au moins un test unitaire est en échec après cette modification.

Si tous les tests sont en succès après l'introduction d'un mutant, on dit alors que le mutant s'est échappé. Si un test est en échec après la modification, cela signifie que le mutant a été tué (et dans notre cas, c'est une bonne chose).

Et concrètement ?

Parce qu'un exemple vaut mieux que mille mots, prenons un exemple simple d'une classe qui fait une requête HTTP à une API pour créer un utilisateur :

namespace App\Service;

use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\Uid\Uuid;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final class ApiClient implements LoggerAwareInterface
{
    use LoggerAwareTrait;

    public function __construct(private readonly HttpClientInterface $client)
    {
    }

    public function createUser(Uuid $uuid): void
    {
        try {
            $this->client->request('POST', '/users', [
                'json' => $uuid->toRfc4122(),
            ]);
        } catch (\Exception $exception) {
            $this->logger->error(\sprintf('User cannot be created: %s.', $exception->getMessage()));

            throw $exception;
        }
    }
}

Voici un exemple de test pour cette classe :

namespace App\Tests\Service;

use App\Service\ApiClient;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Uid\Uuid;
use Symfony\Contracts\HttpClient\HttpClientInterface;

class ApiClientTest extends TestCase
{
    public function testCreateUser(): void
    {
        $uuid = Uuid::v7();
        $client = $this->createMock(HttpClientInterface::class);
        $client
            ->expects($this->once())
            ->method('request')
        ;

        (new ApiClient($client))->createUser($uuid);
    }

    public function testCreateUserThrows(): void
    {
        $client = $this->createMock(HttpClientInterface::class);
        $client
            ->expects($this->once())
            ->method('request')
            ->willThrowException(new \Exception())
        ;

        $apiClient = new ApiClient($client);
        $apiClient->setLogger($this->createMock(LoggerInterface::class));

        $this->expectException(\Exception::class);
        $apiClient->createUser(Uuid::v7());
    }
}

Nous testons le cas où la création de l'utilisateur fonctionne, et le cas où un problème survient. Nous avons donc 100% de code coverage. Pourtant, si on exécute Infection :

Generate mutants...

Processing source code files: 2/2
.: killed, M: escaped, U: uncovered, E: fatal error, X: syntax error, T: timed out, S: skipped, I: ignored

MM.M..                                               (6 / 6)
Escaped mutants:
================

1) src/Service/ApiClient.php:21    [M] ArrayItemRemoval

--- Original
+++ New
@@ @@
     public function createUser(Uuid $uuid) : void
     {
         try {
-            $this->client->request('POST', '/users', ['json' => $uuid->toRfc4122()]);
+            $this->client->request('POST', '/users', []);
         } catch (\Exception $exception) {
             $this->logger->error(\sprintf('User cannot be created: %s.', $exception->getMessage()));
             throw $exception;

2) src/Service/ApiClient.php:25    [M] MethodCallRemoval

--- Original
+++ New
@@ @@
         try {
             $this->client->request('POST', '/users', ['json' => $uuid->toRfc4122()]);
         } catch (\Exception $exception) {
-            $this->logger->error(\sprintf('User cannot be created: %s.', $exception->getMessage()));
+            
             throw $exception;
         }
     }
 }

Deux mutants se sont échappés. Dans le premier cas, un argument a été modifié dans la requête passée au client HTTP. Pire, dans le second cas, la ligne de log d'erreur a été complètement retirée et les tests étaient toujours en succès !

Cela soulève donc deux points à améliorer dans nos tests. Tout d'abord, dans le scénario de succès (méthode testCreateUser), nous pouvons apporter la modification suivante pour s'assurer que les bons arguments sont passé au client HTTP :

  $client
      ->expects($this->once())
      ->method('request')
+     ->with('POST', '/users', ['json' => $uuid])
  ;

Pour le deuxième mutant, nous pouvons aller plus loin dans le mocking du logger pour s'assurer que la méthode error() de celui-ci est bien appelé au moins une fois :

+ $logger = $this->createMock(LoggerInterface::class);
+ $logger
+     ->expects($this->once())
+     ->method('error')
+ ;

  $apiClient = new ApiClient($client);
- $apiClient->setLogger($this->createMock(LoggerInterface::class));
+ $apiClient->setLogger($logger);

Nous pouvons alors relancer Infection :

Generate mutants...

Processing source code files: 2/2
.: killed, M: escaped, U: uncovered, E: fatal error, X: syntax error, T: timed out, S: skipped, I: ignored

......                                               (6 / 6)

6 mutations were generated:
       6 mutants were killed

Victoire, tous les mutants ont été tués : nos tests testent bien notre code !

C'est bon, ayez confiance !

Après plusieurs mois avec ce système mis en place sur notre CI, la confiance que l'équipe porte en ses tests a été décuplée. Mieux : Infection joue le rôle d'un véritable coach quotidien dans l'écriture de bons tests. L'écriture des tests est un exercice très difficile pour tout le monde et s'apprend sur plusieurs années. Grâce à Infection, les développeurs de l'équipe ont un feedback rapide et précis sur les lacunes des tests écrits. Cela leur permet de s'améliorer de jour en jour, d'avoir confiance en eux et d'apporter une belle robustesse au projet.

Nous n'avons pas mis en place les tests de mutation sur notre code legacy, car cela prendrait beaucoup trop de temps de tout réécrire et améliorer. À la place, nous avons activé l'option --git-diff-lines d'Infection. Grâce à cette option, Infection ne génèrera des mutations que sur les lignes touchées dans la pull-request en cours. Nous vous conseillons de faire de même... Sauf si vous êtes prêt à reprendre l'intégralité de votre legacy pour tuer tous les mutants. Si c'est le cas, bravo et bon courage !

Commentaires

Il n'y a actuellement aucun commentaire. Soyez le premier !

  • Couverture de l'article Retour sur le Meet-up Python du 30 juin 2025
    Retour sur le Meet-up Python du 30 juin 2025

    Il y a 2 mois

    Ce lundi 30 juin 2025 nous accueillions la branche lyonnaise de l'AFPy dans nos locaux pour un meetup autour du langage Python. Malgré les fortes températures, une trentaine de personnes ont répondu présentes pour ce moment de convivialité et d'échange.

  • Couverture de l'article Figma Make : enfin une passerelle prometteuse entre design et code grâce à l'IA
    Figma Make : enfin une passerelle prometteuse entre design et code grâce à l'IA

    Il y a 2 mois

    Depuis quelques années, les outils d'IA pour générer des intégrations d'interfaces à partir de maquettes fleurissent. On en a testé plusieurs chez WanadevDigital : de Locofy à Uizard, en passant par Framer AI. Tous ont leurs qualités, mais jusqu’ici, il manquait un vrai pont stable entre les intentions du designer et la réalité du code front.

    L’arrivée de Figma Make change la donne. Et si je devais résumer son impact en une phrase : ça fonctionne, et ça fonctionne pour tout le monde, designers, développeurs et intégrateurs !

  • Couverture de l'article Maîtriser la traduction (i18n) dans un projet web - Partie 2 : Conseils pour une localisation gérable et évolutive
    Maîtriser la traduction (i18n) dans un projet web - Partie 2 : Conseils pour une localisation gérable et évolutive

    Il y a 5 mois

    Dans la partie 1, nous nous sommes concentrés sur la mise en place d'une base solide pour la gestion des traductions dans un projet Vue. Maintenant que votre système de traduction est opérationnel, il est temps d'examiner de plus près comment structurer, gérer et faire évoluer vos fichiers de traduction de manière efficace.

    Cette partie couvrira les bonnes pratiques que nous utilisons chez Wanadev pour créer des clés de traduction maintenables, éviter les pièges courants et garantir que vos fichiers de traduction restent propres et évolutifs au fur et à mesure que votre projet grandit.

  • Couverture de l'article Maîtriser la traduction (i18n) dans un projet web - Partie 1 : Configurer proprement
    Maîtriser la traduction (i18n) dans un projet web - Partie 1 : Configurer proprement

    Il y a 5 mois

    Mettre en place l'internationalisation (i18n) dans un projet web peut sembler simple. Cependant, de nombreux projets se retrouvent avec des configurations de traduction mal gérées, difficiles à maintenir ou à faire évoluer à mesure que l'application grandit. Une stratégie i18n robuste est essentielle pour offrir une expérience utilisateur fluide dans plusieurs langues.

    Je vous décris ici, les pratiques que nous avons établies chez Wanadev au fil des années d'expérience pour mettre en œuvre et gérer les traductions dans les projets Vue. Bien que les exemples soient spécifiques à Vue, la plupart de ces pratiques peuvent être appliquées à n'importe quel framework.

  • Couverture de l'article Bien choisir sa typographie : quelques bases pour un message clair
    Bien choisir sa typographie : quelques bases pour un message clair
    Méthodologie

    Il y a 10 mois

    On n'écrit pas "Je t'aime" comme "Je te hais" ! Cette petite phrase résume bien ma problématique : quand on doit délivrer un message, la compréhension de ce dernier ne se fait pas uniquement par la lecture simple du texte, mais aussi par sa mise en forme. Et de cette mise en forme dépend la bonne compréhension du message. Dans cet article, nous allons nous pencher sur l’histoire et les familles de typographies dans le but de sensibiliser sur l’importance des choix de typographies dans la communication. Nous verrons ensuite quelques astuces pour bien sélectionner sa typographie et mettre en forme son message.

  • Couverture de l'article Les solutions CPQ sont-elles accessibles à toutes les entreprises ?
    Les solutions CPQ sont-elles accessibles à toutes les entreprises ?
    Méthodologie

    Il y a 11 mois

    Le CPQ (Configure, Price, Quote) est un outil essentiel pour les entreprises cherchant à optimiser leurs processus de vente. Il permet aux équipes commerciales de configurer rapidement et facilement des produits ou services complexes en fonction des besoins spécifiques des clients, tout en garantissant la cohérence des prix. Grâce au CPQ, les vendeurs peuvent établir des devis précis et personnalisés en temps réel, tout en tenant compte des remises, des promotions ou des ajustements spécifiques. Aujourd'hui les CPQ tirent majoritairement parti de la 3D pour proposer une visualisation de produit plus réaliste et complète.