Makina Blog

Le blog Makina-corpus

Scénario complexe dans PHPUnit et ordre d'exécution des tests


J'ai récemment (re)découvert une fonctionnalité suffisamment banale et simple de PHPUnit pour qu'on oublie qu'elle existe lorsque nous n'avons plus besoin d'elle. Je voulais documenter ici la manière dont je suis tombé dessus par hasard, et pourquoi cette fonctionnalité est géniale !

Le use case

Pour commencer, lorsque vous écrivez une classe de tests, telle que celle-ci :

<?php
declare (strict_types=1);

namespace UberProject\Tests;

use PHPUnit\Framework\TestCase;

class SomeDomainFunctionalTest extends TestCase
{
    private ?SuperbeObject $superbeObject;

    public function testQueCaFaitCa(): void
    {
        $this->superbeObject = my_object_factory();
        $this->superbeObject->declencheUnTraitementMetierQuiPeutEchouer();

        self::assertTrue(true);
    }

    public function testQuEnsuiteCaFaitCa(): void
    {
        $this->superbObject->puisUnAutreQuiEnDepend();

        self::assertFalse(false);
    }
}

Vous pouvez tout simplement :

vendor/bin/phpunit --filter=SomeDomainFunctionalTest --stop-on-failure

Et apprécier le joli résultat vert de PHPUnit nous indiquant que tout a fonctionné.

Pour que nous soyons bien tous en phase, ce test est bancal et ne marche que par accident :

  • Ici nous comptons sur l'état d'un objet à la suite du premier testQueCaFaitCa() pour continuer le scénario sur le deuxième testQuEnsuiteCaFaitCa().
  • Pour que ça fonctionne, il faut que les méthodes soient exécutées dans l'ordre.
  • Pour préparer un scénario, il faudrait mieux utiliser la méthode setUp(), mais elle ne permet pas de conserver un état entre les tests.

Par le plus grand des hasards - mais rassurez-vous c'est déterminé, pas aléatoire − les tests testQueCaFaitCa() et testQuEnsuiteCaFaitCa() sont exécutés dans l'ordre dans lequel vous les avez écrits. Et ça, bien sûr, si vous n'avez pas installé de plugins étranges de PHPUnit qui permettraient, par exemple d'exécuter les tests en parallèle, ou tout simplement si vous utilisez l'une de ces options de PHPUnit : --order-by ou --process-isolation.

Que se passe-t-il si je trouve un bug dans le second test et souhaite n'exécuter que ce dernier ? Et bien nous allons lancer :

vendor/bin/phpunit --filter=SomeDomainFunctionalTest::testQuEnsuiteCaFaitCa --stop-on-failure

Vous vous attendez à des applaudissements ? Et bien, il y a peu de temps pendant une longue phase de débug dans un projet, je m'attendais à en recevoir. Seulement personne n'a applaudi, à l'exception de PHPUnit qui a explosé au vol laissant mon écran dans un état proche de celui d'une belle nuit d'été un soir de feux d'artifice.

Et bien oui, parce que ce scénario, si je lui demande d'exécuter ma seconde méthode sans la première, n'aura pas de $superbeObject et donc ne pourra pas marcher.

La solution

Après quelques heures de débug, j'ai fini par me rendre compte que cette classe de tests de quelques milliers de lignes de code était bel et bien un scénario dans lequel chaque méthode dépendait du résultat d'une autre. Et c'est là que la révélation apparaît : la personne ayant écrit ce test à l'origine avait tout simplement oublié l'existence de l'annotation @depends de PHPUnit.

Quel outil merveilleux, j'en avais complètement oublié l'existence : le seul moyen qui assure que des tests soient réellement exécutés dans le bon ordre c'est de le demander explicitement. De plus, en déclarant ce lien de dépendance explicitement, si un test dans la chaîne échoue, PHPUnit considèrera alors que les pré-conditions pour exécuter les suivants ne sont pas remplies, et agrémentera votre console d'un magnifique S comme Skipped, avec un message clair et limpide si vous activez la verbosité à l'exécution.

Mais nous allons voir que cela apporte aussi d'autres éléments assez pratiques.

Ré-écrivons donc notre test :

<?php
declare (strict_types=1);

namespace UberProject\Tests;

use PHPUnit\Framework\TestCase;

class SomeDomainFunctionalTest extends TestCase
{
    public function testQueCaFaitCa(): void
    {
        $superbeObject = my_object_factory();
        $superbeObject->declencheUnTraitementMetierQuiPeutEchouer();

        self::assertTrue(true);

        return $superbeObject;
    }

    /**
     * @depends testQueCaFaitCa()
     */
    public function testQuEnsuiteCaFaitCa(SuperbeObject $superbeObject): void
    {
        $superbeObject->puisUnAutreQuiEnDepend();

        self::assertFalse(false);
    }
}

Et pour lancer tout ça :

vendor/bin/phpunit --filter=SomeDomainFunctionalTest --stop-on-failure -vv

Oui, j'ai rajouté -vv pour le plaisir.

Vous remarquerez ce @depends testQueCaFaitCa() qui implique donc :

  • testQueCaFaitCa() et testQuEnsuiteCaFaitCa() seront exécutées dans cet ordre.
  • Si testQueCaFaitCa() échoue, testQuEnsuiteCaFaitCa() ne sera pas exécutée.
  • Le premier paramètre de testQuEnsuiteCaFaitCa() devient alors le retour de la méthode testQueCaFaitCa().

Mais par contre, ceci a un effet de bord qui parfois peut être génant :

vendor/bin/phpunit --filter=SomeDomainFunctionalTest::testQuEnsuiteCaFaitCa --stop-on-failure -vv

# ... de l'output...

This test depends on "UberProject\Tests\SomeDomainFunctionalTest::testQueCaFaitCa" to pass.

Au moins le message est clair. Pour palier à ce petit défaut, je vous conseille tout simplement de créer une classe de tests par scénario, ou d'utiliser d'autres annotations qui permettent de lancer les tests par groupes, tels que @covers, @uses ou @group.

Conclusion

N'oubliez jamais cette annotation @depends, gardez-la dans un coin de cerveau, nous ne l'utilisons finalement que rarement, mais elle est géniale !

Actualités en lien

Image
Logo Symfony Makina Corpus
09/11/2021

Comment démarrer un projet Symfony 5 en 5 minutes ?

Depuis quelques versions, le framework Symfony fournit de nombreux outils pour bâtir très rapidement une application fonctionnelle. Voyons ce qu'on peut faire en 5 minutes.

Voir l'article
Image
Logo Symfony Makina Corpus
26/02/2020

Symfony : utiliser une contrainte de type Callback dans un formulaire pour de la validation spécifique

Vous devez développer une contrainte pour un formulaire métier ? La déclarer à l'aide du composant Validation de Symfony est peut-être excessif : il est aussi possible de le faire en passant par une assertion de type Callback.

Voir l'article
01/06/2018

Retour sur le PHP Tour 2018 à Montpellier

Retour sur les conférences m'ayant le plus marqué durant cette dernière édition du PHP Tour.

Voir l'article

Inscription à la newsletter

Nous vous avons convaincus