Makina Blog
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.
Le use case
Pour commencer, n'oubliez pas que 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 ensuite tout simplement la lancer comme ceci :
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èmetestQuEnsuiteCaFaitCa()
. - 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éterministe, 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 des feux d'artifices ? Et bien, il y a peu de temps pendant une longue phase de débug dans un projet, j'y ai cru. Seulement la seule ayant explosé au vol est PHPUnit, 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()
ettestQuEnsuiteCaFaitCa()
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éthodetestQueCaFaitCa()
.
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
DbToolsBundle : sortie de la version 1.2
Découvrez les nouveautés de cette nouvelle version ainsi que les fonctionnalités à venir de la prochaine version majeure.
Access Control, bibliothèque PHP pour gérer des droits d’accès
Suite à un projet de gestion métier opérationnel dont la durée de vie et la maintenance sont à long termes, nous avons expérimenté un passage de celui-ci sur l’architecture hexagonale et la clean architecture.
L’anonymisation sous stéroïdes avec le DBToolsBundle
Le DbToolsBundle permet d’anonymiser des tables d’un million de lignes en seulement quelques secondes. Cet article vous présente la méthodologie mise en place pour arriver à ce résultat.