Premiers pas avec PestPHP

tests févr. 10, 2021

Pendant que je m'amusais avec PHPUnit afin de me sentir plus à l'aise lors de l'écriture de mes tests unitaires, je suis tombé par hasard sur PestPHP qui est un framework PHPUnit développé entre autre par Nuno Maduro, un employé de Laravel.

Bien que PestPHP est légèrement plus simple à utiliser sur Laravel pour des raisons que j'expliquerais dans un autre billet, ce framework est tout de même agnostique, personnellement je l'utilise avec mon stack Symfony désormais.

Voici les quelques raisons qui me font préférer PestPHP à PHPUnit :

  • Les tests PHPUnit fonctionnent from scratch avec PestPHP
  • Écriture plus condensée des tests
  • Possibilité de créer de nouveaux assert très facilement pour son projet
  • Possibilité de faire certains tests de manière plus simple que via PHPUnit

Il y a cependant quelques points négatifs pour le moment pour être tout à fait honnête :

  • La prise en main initiale n'est pas forcément simple du fait que sa syntaxe PHP n'est pas forcément reconnue par les éditeurs, ce qui fait que les fichiers peuvent être détectés avec des fautes alors que ce n'est pas forcément le cas
  • Peut avoir tendance à faire paniquer PHPStan qui ne reconnait pas forcément la manière de fonctionner de PestPHP (particulièrement les $this et dans ce cas il peut être nécessaire d'ajouter des ignoreErrors dans sa configuration)
  • Une fois PestPHP installé dans son projet, PHPUnit n'est plus utilisable, du moins si des tests avec la syntaxe PestPHP est utilisée (il demande à passer par le bin de PestPHP, ce qui signifie que si vous avez un CI comme Github Actions ou TravisCI, il faudra probablement modifier le job)
  • Les tests en pur syntaxe PestPHP ne sont pas compatibles PHPUnit
  • Ce dernier point est plus sur le fonctionnement interne de PestPHP qui semble se rapprocher de celle de Laravel, si vous souhaitez faire des traitements avant l'ensemble des tests (à la place qu'entre chaque test), il faut savoir que la classe où vous lancez le test n'est pas encore instancié à ce moment là, ce qui signifie qu'il n'est pas possible de par exemple charger les fixtures une fois pour l'ensemble des tests mais qu'il faut les charger/décharger entre chaque test, ce qui demande un peu plus de temps d'exécution et de ressources (ou de faire des situations de tests plus complexes). Bref ça ne fonctionne pas de la même façon que le beforeClass de PHPUnit.

Premièrement pour installer PestPHP, il suffit d'entrer la commande suivante :

composer require pestphp/pest --dev --with-all-dependencies

Ensuite vous pouvez entrer la commande suivante pour initialiser PestPHP :

pest --init

Cela va créer un fichier Pest.php et un fichier ExampleTest.php dans la racine du dossier tests de votre projet.

Le fichier Pest.php permet de configurer PestPHP, et surtout de partager des classes vers les classes de tests parce qu'il n'est possible d'invoquer les classes de la façon habituelle comme on va le voir par la suite.

Donc prenons un fichier Pest.php assez basique :

<?php

declare(strict_types=1);

namespace App\Tests;

use App\Tests\Helpers\PasswordSameAssertion;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

uses(KernelTestCase::class, PasswordSameAssertion::class)->in('Unit');

La dernière ligne signifie que les classes KernelTestCase et PasswordSameAssertion seront instanciés dans toutes les classes tests de PestPHP qui seront dans le sous-dossier Unit de tests.

Maintenant regardons un test PHPUnit du fichier UserRepositoryTest.php :

<?php

declare(strict_types=1);

namespace App\Tests\Unit\Repository;

use App\Entity\User;
use Doctrine\Persistence\ManagerRegistry;
use Liip\TestFixturesBundle\Test\FixturesTrait;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class UserRepositoryTest extends KernelTestCase
{
	private ManagerRegistry $doctrine;
    private HashPassword $passwordEncoder;
    
    use FixturesTrait;
    
    // ...
    
    public function testFindAdminUser(): void
    {
        $this->loadDb();

        $adminUser = $this->doctrine->getRepository(User::class)->findOneBy(['username' => 'admin']);

        $this->assertSame('admin@noreply.local', $adminUser->getEmail());
        $this->assertEquals($this->passwordEncoder->encodePassword($adminUser, 'test1234'), $adminUser->getPassword());
        $this->assertSame('ROLE_ADMIN', $adminUser->getRole()->getCode());
        $this->assertEquals(new \DateTime() instanceof \DateTime, $adminUser->getCreatedAt() instanceof \DateTime);

        $this->tearDown();
    }
    
    // ...
    
}

Voici le même test mais écrit avec la syntaxe PestPHP :

  
<?php

declare(strict_types=1);

beforeEach(function () {
    // ...
});

test('verify admin email is valid', fn () =>
	expect($this->userAdmin->getEmail())->toBe('admin@noreply.local');

test('verify the admin user password', fn () =>
	$this->assertSamePassword($this->userAdmin, 'test1234'));

test('verify user admin role', fn () =>
	expect($this->userAdmin->getRole()->getCode())->toBe('ROLE_ADMIN'));
    
test('verify createdat field is a DateTime', fn () =>
	expect($this->userAdmin->getCreatedAt()->toBeInstanceOf(\DateTime::class)));

Déjà comme vous pouvez le voir, il n'y a pas de déclaration de classe, ce qui rends nécessaire de passer certaines classes via le fichier Pest.php.

La fonction beforeEeach permet d'exécuter des instructions avant chaque test, cela peut être utile pour recharger des fixtures par exemple ou générer un nouveau set de données (par exemple avoir un set de données valide pour un test, et un autre invalide pour un autre test).

De même la déclaration d'un test est largement plus succincte, au lieu de déclarer une fonction public function testFindAUser() par exemple, on utilise directement la fonction test() ou encore it() qui n'est qu'un alias (les sous-fonctions sont interchangeables entre les deux formulations). Voici un exemple plus neutre en version étendue :

test('Ceci est un test pour voir que PestPHP fonctionne', function() {
	assertTrue(true);
});

On peut même condenser cela en une seule ligne :

test('Ceci est un test pour voir que PestPHP fonctionne', fn () => assertTrue(true));

Personnellement cela m'a poussé à créer des tests pour chaque chose que je voulais vérifier au lieu d'éventuellement les faire par lot via une fonction. Cela permet également de détailler le test en question au lieu d'avoir un message potentiellement relativement vague d'erreur en cas d'échec.

Et comme vous pouvez le faire dans le premier exemple de PestPHP que j'ai partagé, il est possible d'utiliser la syntaxe expect au lieu d'assert pour vérifier la véracité des tests. N'étant pas un spécialiste de React, le créateur du framework indique qu'il s'est inspiré de Jest afin d'avoir un vocabulaire plus "naturel" que les assert de PHPUnit (d'ailleurs le concernant, Pest doit être vu comme l'équivalent PHP de Jest).

Si vous voulez également un jeu de données spécifique pour un test, c'est très simple à faire, je vous renvois à la documentation pour cela.

À l'usage, il suffit d'entrer la commande pest pour lancer les tests, ce qui donnera l'output suivant :

Cette suite de tests provient d'un petit projet que je fais actuellement pour me faire la main sur PHP 8, PestPHP ainsi que Behat. Cette suite comprends deux fichiers écrits avec la syntaxe standard de PHPUnit (les deux premiers de l'image) et les suivants sont écrit avec la syntaxe de Pest.

Comme je l'ai indiqué auparavant, il est possible de créer des helpers avec PestPHP, qui peuvent servir par exemple à créer de nouveaux assert. Dans le premier snippet de code que j'ai partagé, j'ai utilisé un assertSamePassword qui n'existe pas dans PHPUnit ni dans PestPHP, c'est un assert que j'ai créé moi-même afin de vérifier que le format du mot de passe est correct ainsi que le hashage est effectué correctement. Voici le code l'helper :

<?php

declare(strict_types=1);

namespace App\Tests\Helpers;

trait PasswordSameAssertion
{
    private function assertSamePassword($user, $password): void
    {
        self::bootKernel();

        $container = self::$container;

        $this->assertNotNull($user->getPassword());
        $this->assertIsString($user->getPassword());
        $this->assertTrue($container->get('security.password_encoder')->isPasswordValid($user, $password));
    }
}

J'ai donc créé un trait me permettant de vérifier plusieurs choses à la fois :

  • Que le mot de passe ne soit pas nul
  • Que le mot de passe est bien de type string
  • Que le mot de passe enregistré est bien celui qui est soumis par la fixture

Quand à savoir comment les méthodes/propriétés du kernel Symfony sont disponibles dans ce trait alors qu'il n'est pas importé, comme indiqué plus haut on l'a fait via le fichier Pest.php :

uses(KernelTestCase::class)->in('Helpers');

Là on parle d'un assert assez évolué, mais il est possible de créer de plus simples expect pour combiner plusieurs règles entre elles pour en créer une nouvelle. Voici un exemple (tiré de la documentation) d'un nouvel expect inséré dans le fichier Pest.php et qui sera accessible dans tous les tests :

expect()->extend('toBeWithinRange', function ($min, $max) {
    return $this->toBeGreaterThanOrEqual($min)
                ->toBeLessThanOrEqual($max);
});

Et donc pour l'utiliser il suffira de faire :

test('numeric ranges', function () {
    expect(100)->toBeWithinRange(90, 110);
});

Au final bien que je trouve PestPHP très intéressant, il est dommage que son beforeClass (nommé beforeAll dans PestPHP) ne s'exécute pas après l'instanciation du fichier et avant tous les tests (mais avant l'instanciation, du moins pour le moment car un changement de comportement est éventuellement envisagé).

Donc au final je ne conseillerais probablement pas pour le moment d'utiliser PestPHP dans tous les projets, surtout en milieu professionnel, par contre il peut être un bon outil pour la plupart des petits et moyens projets personnels et semi-professionnels en fonction du contexte.

Mots clés

Anthodev

Développeur PHP / Symfony chez Tekyn. Ex GameDev chez Ubisoft / Créateur de Astral Planner (actuellement en développement).