PHPUnit : conseils et astuces qui nous ont vraiment aidés

Tester son code est une chose essentielle pour s'assurer du bon fonctionnement et de l'absence de régressions au fil des développements : ils figent les comportements attendus et facilitent la détection des anomalies lors des évolutions.
Au fil des années et de l’utilisation de PHPUnit avec Laravel, nous accumulons des pratiques et des petits conseils pour des situations très spécifiques, voici un recueil d’une dizaine d’astuces qui pourraient vous être utiles !
assertEqualsCanonicalizing
Vérifier la forme d'un tableau peut être une chose pénible, surtout quand l'ordonnancement des valeurs est fluctuant, toutes les valeurs se trouvent dans votre tableau mais l'ordre est parfois difficile à prévoir provoquant des erreurs aléatoires.
1$this->assertSame(2 [1,2,3],3 [3,2,1],4); // ❌
Mais parfois le sens des valeurs n’a pas d’importance particulière et peut être ignoré, la méthode assertEqualsCanonicalizing
répond à ce besoin en vérifiant leur présence sans tenir compte de l’ordre !
1$this->assertEqualsCanonicalizing(2 [1,2,3],3 [3,2,1],4); // ✅
Vérifier les valeurs dans un test HTTP
Tester la route suivante sera relativement simple, mais la vérification des variables injectées pourrait s’avérer un peu plus délicate :
1 2namespace App\Http\Controllers; 3 4class HomeController extends Controller 5{ 6 public function __invoke() 7 { 8 return view('homepage', [ 9 'title' => 'Home',10 ]);11 }12}
Heureusement pour nous, l’élément response retourné par la méthode get
est une instance de TestResponse
disposant d’une méthode magique __get
permettant d’accéder directement aux variables injectées dans la vue ;
1class ExampleTest extends TestCase 2{ 3 #[\PHPUnit\Framework\Attributes\Test] 4 public function i_am_a_random_test(): void 5 { 6 $response = $this->get('/'); 7 8 $response->assertSuccessful(); 9 10 $this->assertEquals('Home', $response['title']);11 }12}
Cette fonctionnalité n’est ni usuelle ni documentée par Laravel, dans la plupart des cas nous vous conseillons d’utiliser la méthode viewData
qui offre un comportement similaire :
1$this->assertEquals('Home', $response->viewData('title'));
Lever et compter des assertions
Il arrive, rarement, que les assertions classiques de PHPUnit comme assertSame
ne suffisent pas.
Dans ce cas, vous pouvez recourir à addToAssertionCount
pour indiquer manuellement à PHPUnit qu'une assertion a eu lieu et la comptabiliser par la suite via numberOfAssertionsPerformed
:
1class ExampleTest extends TestCase 2{ 3 #[\PHPUnit\Framework\Attributes\Test] 4 public function i_am_a_random_test(): void 5 { 6 $this->addToAssertionCount(1); 7 $this->addToAssertionCount(1); 8 9 $this->assertEquals(2, $this->numberOfAssertionsPerformed());10 // ✅11 }12}
Cette situation est exceptionnelle, pour l'avoir rencontrée en production qu'une seule et unique fois, mais cela pourrait ponctuellement vous aider et débloquer un test récalcitrant !
Attention cependant, le nombre d'assertions déclenchées manuellement à l'aide de addToAssertionCount
ne s'additionnera pas aux autres méthodes d'assertions !
1class ExampleTest extends TestCase 2{ 3 #[\PHPUnit\Framework\Attributes\Test] 4 public function i_am_a_random_test(): void 5 { 6 $this->addToAssertionCount(1); 7 8 $this->assertTrue(true); 9 10 $this->assertEquals(2, $this->numberOfAssertionsPerformed());11 // ❌ Failed asserting that 1 matches expected 2.12 }13}
DoesNotPerformAssertions
Au contraire, il est parfois possible que vos tests ne vérifient aucune assertion, la seule bonne exécution du code, sans lever d'erreur, étant un signe suffisant de son bon fonctionnement.
Cependant, PHPUnit est configuré de sorte (et à raison) qu'un test ne validant aucune assertion soit considéré comme étant une anomalie et déclenchera un warning :
1class ExampleTest extends TestCase2{3 #[\PHPUnit\Framework\Attributes\Test]4 public function i_am_a_random_test(): void5 {6 // ⚠️ This test did not perform any assertions7 }8}
Si cette situation est légitime dans vos tests, vous pouvez facilement modifier ce comportement en précisant à PHPUnit qu'il est normal que le test ne léve aucune assertion à l'aide de l'attribut expectNotToPerformAssertions
:
1class ExampleTest extends TestCase2{3 #[\PHPUnit\Framework\Attributes\Test]4 #[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions]5 public function i_am_a_random_test(): void6 {7 // ✅8 }9}
RunInSeparateProcess
Par défaut, lorsque vous exécutez PHPUnit, tous les tests s'exécutent dans le même processus. Cela signifie que les données globales sont partagées entre les tests ce qui peut parfois provoquer des conflits.
Par exemple, si un premier test définit une constante, un second test ne pourra pas la redéfinir à nouveau, cela étant interdit par PHP :
1class ExampleTest extends TestCase 2{ 3 #[\PHPUnit\Framework\Attributes\Test] 4 public function first_test_define_constant(): void 5 { 6 define('TEST_CONSTANT', 'first'); 7 8 $this->assertTrue(true); 9 }10 11 #[\PHPUnit\Framework\Attributes\Test]12 public function second_test_define_constant(): void13 {14 define('TEST_CONSTANT', 'second');15 // ❌ Constant TEST_CONSTANT already defined16 17 $this->assertTrue(true);18 }19}
Pour répondre à cette problématique, il est possible de préciser à PHPUnit d'exécuter un test dans un processus dédié à l'aide de l'attribut RunInSeparateProcess
:
1class ExampleTest extends TestCase 2{ 3 #[\PHPUnit\Framework\Attributes\Test] 4 #[\PHPUnit\Framework\Attributes\RunInSeparateProcess] 5 public function first_test_define_constant(): void 6 { 7 define('TEST_CONSTANT', 'first'); 8 9 $this->assertTrue(true);10 }11 12 #[\PHPUnit\Framework\Attributes\Test]13 public function second_test_define_constant(): void14 {15 define('TEST_CONSTANT', 'second'); // ✅16 17 $this->assertTrue(true);18 }19}
Désormais les deux tests fonctionnent, car le premier, définissant la constante, se trouve dans un processus dédié qui n'impactera pas l'exécution du second test.
Les seeders
Dans certains tests, il peut être nécessaire de lancer des seeders pour inserer les données essentielles en base de données.
Si cela n’est pas configuré en amont, vous pouvez demander à Laravel d’exécuter le seeder par défaut (DatabaseSeeder) dans un test en ajoutant simplement la propriété $seed
à ce dernier :
1class ExampleTest extends TestCase2{3 public $seed = true;4}
En plus de cette propriété, vous pouvez également spécifier le seeder à utiliser en précisant la propriété $seeder
:
1class ExampleTest extends TestCase2{3 public $seed = true;4 public $seeder = UserSeeder::class;5}
Simuler une page précédente
Dans le cadre de vos tests HTTP, il peut parfois arriver que certaines actions déclenchent une redirection vers la page précédente, cependant, dans un test fonctionnel, cette page "précédente" n'existe pas réellement car le test simule un utilisateur sans historique de navigation.
Cela peut donc poser problème ... à moins d'explicitement renseigner l'origine de la requête à l’aide de la méthode from
!
1class ExampleTest extends TestCase 2{ 3 #[\PHPUnit\Framework\Attributes\Test] 4 public function i_am_a_random_test(): void 5 { 6 $response = $this 7 ->from('admin/users') 8 ->post('admin/users/store'); 9 10 $response->assertRedirect('admin/users');11 }12}
De cette facon, Laravel considèrera que l'URL renseignée dans le from
fut la page appelante lors du call vers admin/users/store
et redirigera au besoin vers cette dernière.
Marquer manuellement les tests
Vos tests peuvent parfois se comporter bizarrement et générer des faux négatifs, plutôt que de supprimer ces tests "flaky", il est possible de les ignorer temporairement en utilisant la méthode markTestSkipped
:
1class ExampleTest extends TestCase2{3 public function test_one(): void4 {5 $this->markTestSkipped('test are skipped because ...');6 7 $this->assertTrue(true);8 }9}
Ces tests apparaîtront toujours dans l’output de PHPUnit mais ne seront plus exécutés, vous permettant d’y revenir plus tard pour les corriger.
De facon similaire, vous pouvez marquer un test comme incomplet avec la méthode suivante :
1$this->markTestIncomplete('test are incomplete but not in error');
Un test incomplet est une chose peu courante dans PHPUnit, il représente un test qui ne peut être exécuté, le plus souvent pour des raisons techniques ou parce que la fonctionnalité n’est pas encore prête, mais ne devant pour autant pas être considéré comme une erreur.
Dans un tout autre registre, la méthode fail
permet d’interrompre brutalement l’exécution d’un test et de le marquer comme échoué :
1$this->fail('test are failed !');
Lancer les tests aléatoirement
Un bon ensemble de tests se doit être déterministe et ne pas subir d’effets de bord provoqués par l’exécution d’autres tests. Pour s’en assurer, vous pouvez lancer vos tests dans un ordre aléatoire à l’aide de l’option suivante :
1phpunit --order-by=random
Cela permettra de détecter les dépendances implicites entre vos tests qui seront souvent sources de comportements instables.
De plus, il est possible de spécifier un seed afin de rejouer exactement le même ordre aléatoire, notamment pour reproduire un échec :
1phpunit --order-by=random --random-order-seed=1234
Tester un middleware
Les middlewares sont des composants essentiels de vos applications Laravel, placés en amont des routes et des controllers, un bug à ce niveau pourrait fortement impacter l’expérience utilisateur.
Il est donc crucial de ne pas négliger leur test, même s’ils peuvent paraître complexes à manipuler, étant généralement instanciés par le framework, un middleware reste une classe comme une autre ... rien ne vous empêche de l’instancier manuellement dans vos tests !
1 2namespace Tests\Feature; 3 4use Tests\TestCase; 5use Illuminate\Http\Request; 6use Illuminate\Http\Response; 7use App\Http\Middleware\MyMiddleware; 8 9class ExampleTest extends TestCase10{11 #[\PHPUnit\Framework\Attributes\Test]12 public function i_am_a_random_test(): void13 {14 $middleware = new MyMiddleware();15 16 $response = $middleware->handle(new Request(), function ($request) {17 return new Response('Hello World');18 });19 20 $this->assertEquals('Hello World', $response->getContent());21 }22}
Une seconde option consistera à définir artificiellement une route dans le test à laquelle vous attribuerez votre middleware, vous pourrez alors tester cette route comme n'importe quelle autre !
1 2namespace Tests\Feature; 3 4use Tests\TestCase; 5use Illuminate\Http\Response; 6use App\Http\Middleware\MyMiddleware; 7use Illuminate\Support\Facades\Route; 8 9class ExampleTest extends TestCase10{11 #[\PHPUnit\Framework\Attributes\Test]12 public function i_am_a_random_test(): void13 {14 Route::middleware(MyMiddleware::class)->get('/', function () {15 return new Response('Hello World');16 });17 18 $this->get('/')->assertSee('Hello World');19 }20}
Corriger progressivement les tests
Inévitablement, vos tests finiront par planter un jour ou l’autre, que ce soit à la suite d’une refonte, d’un nouveau développement ou d’une mise à jour d’une dépendance.
Face à une marée d’erreurs, il peut être rassurant (et également plus rapide) de demander à PHPUnit de s’arrêter dès la première erreur rencontrée.
Cela vous permettra de vous concentrer sur les anomalies une par une, sans subir une exécution longue et parfois inutile de l’ensemble de la suite de tests.
1phpunit --stop-on-failure --stop-on-error
Les erreurs et les échecs sont deux choses différentes avec PHPUnit : une erreur représente un plantage du code, tel qu'une erreur fatale, tandis qu’un échec est une assertion qui n'a pas pu être validée.
Regrouper les tests
Par moments, il peut être intéressant de regrouper certains tests sous un même tag afin de les inclure ou de les exclure lors de l'exécution des tests.
Par exemple, si certains de vos tests échouent occasionnellement sans raison claire, vous pouvez utiliser l’attribut Group
pour les marquer comme flaky
:
1#[\PHPUnit\Framework\Attributes\Test]2#[\PHPUnit\Framework\Attributes\Group('flaky')]3public function i_am_a_random_test(): void4{5 // this test fails occasionally 🤕6}
Vous pourrez ensuite facilement les exclure lors de l’exécution de PHPUnit :
1vendor/bin/phpunit --exclude-group flaky
Ou au contraire, exécuter uniquement les tests associés à un tag donné :
1vendor/bin/phpunit --group flaky
Les groupes sont pratiques pour organiser vos tests en fonction de critères communs : les tests rapides, les tests lents, ceux qui font appel à des APIs externes, ou encore ceux associés à un module ou une couche spécifique.

A lire
Autres articles de la même catégorie

Les bases 1/6 : Création du modèle
Découverte de l'ORM Eloquent à travers la création de modèle

William Suppo

Les bases 4/6 : Validation des données
La validation des données dans Laravel permet de contrôler les valeurs d’un formulaire.

William Suppo

Encapsuler la logique métier dans des spécifications
Voyons comment le pattern specification peut nous aider à mieux encapsuler et tester la logique de nos applications

Mathieu De Gracia