Des fixtures au top avec Foundry

tests 13 févr. 2021

L'une des choses habituelles qu'on fait quand on développe nos applications, c'est de créer des fixtures afin de créer des jeux d'essai pour tester le backend. La solution "habituelle" pour les applications Symfony est d'utiliser nelmio/alice pour cela (et tout ce qui l'améliore comme hautelook/alice (pour PHP < 8.0) et fakerphp/faker) ou encore liip/LiipTestFixturesBundle.

Cependant j'ai découvert il y a peu zenstruck/foundry qui est très puissant et surtout (à mes yeux), permet d'utiliser de manière plus simple PestPHP (dont j'ai parlé auparavant) lorsqu'on a besoin de la base de données pour effectuer des tests.

Commençons par installer Foundry :

composer require zenstruck/foundry --dev

Alors comment fonctionne Foundry ? À la différence d'alice qui demande/encourage de créer un fichier YAML pour créer les données des fixtures, tout se passe en PHP pour Foundry et tout passe par un système de Factory (que l'on peut créer via la console Symfony avec la commande bin/console make:factory) qui permet de créer de manière assez fines les données dans les tests (et pas forcément en les persistant, ça peut se configurer).

Voici par exemple comment je peux créer un utilisateur admin avec une Foundry Factory :

<?php

declare(strict_types=1);

use App\Factory\RoleFactory;
use App\Factory\UserFactory;

// ...

UserFactory::new()->create([
    'username' => 'admin',
    'email' => 'admin@noreply.local',
    'plainPassword' => 'test1234',
    'role' => RoleFactory::new()->create([
        'name' => 'admin',
        'code' => 'ROLE_ADMIN'
    ])->object()
]);

Pour créer un utilisateur standard c'est encore plus succinct :

<?php

declare(strict_types=1);

use App\Factory\RoleFactory;
use App\Factory\UserFactory;

// ...

UserFactory::createOne([
    'role' => RoleFactory::findOrCreate([
    		'name' => 'user',
        'code' => 'ROLE_USER'
    ])->object();
]);

Et là vous allez me dire : "mais tu as oublié de définir des propriétés !". Oui mais en fait non, comme avec alice, il est possible de définir les propriétés par défaut de la factory, pour cela il suffit de regarder dans sa classe, par exemple pour le UserFactory :

<?php

namespace App\Factory;

use App\Entity\User;
use App\Repository\UserRepository;
use Zenstruck\Foundry\ModelFactory;
use Zenstruck\Foundry\Proxy;
use Zenstruck\Foundry\RepositoryProxy;

/**
 * @method static User|Proxy createOne(array $attributes = [])
 * @method static User[]|Proxy[] createMany(int $number, $attributes = [])
 * @method static User|Proxy findOrCreate(array $attributes)
 * @method static User|Proxy random(array $attributes = [])
 * @method static User|Proxy randomOrCreate(array $attributes = [])
 * @method static User[]|Proxy[] randomSet(int $number, array $attributes = [])
 * @method static User[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])
 * @method static UserRepository|RepositoryProxy repository()
 * @method User|Proxy create($attributes = [])
 */
final class UserFactory extends ModelFactory
{
    public function __construct()
    {
        parent::__construct();

        // TODO inject services if required (https://github.com/zenstruck/foundry#factories-as-services)
    }
    
    protected function getDefaults(): array
    {
        return [
            // TODO add your default values here (https://github.com/zenstruck/foundry#model-factories)
            'username' => self::faker()->unique()->userName,
            'plainPassword' => self::faker()->password,
            'email' => self::faker()->unique()->email,
            'createdAt' => self::faker()->dateTime,
            'updatedAt' => self::faker()->optional(0.3)->dateTime
        ];
    }
    
    // ...

Comme vous pouvez le voir, on utilise faker (qui est bundle avec ce package) pour donner des valeurs par défaut pour les propriétés qu'on souhaite quand un objet est généré.

Et le plus beau dans l'histoire est que lorsque le create() est traité, dans son comportement par défaut, il est immédiatement sauvé en base de données, pas besoin de faire un flush() derrière, Foundry se relie automatiquement à la base de données configuré dans le framework (Symfony dans notre cas).

Autre fonctionnalité sympathique de Foundry, il est possible de réinitialiser la base de données à chaque fois qu'une classe de test est instanciée . Pour cela il suffit d'ajouter le trait ResetDatabase (qui ressemble au RefreshDatabasede Laravel) dans la classe de test. Personnellement j'utilise PestPHP pour les tests, donc la méthode est légèrement différente, c'est à dire que je crée un nouveau KernelTestCase dans la racine du dossier tests qui est l'enfant de la classe en question et qui contient le code suivant :

<?php

declare(strict_types=1);

namespace App\Tests;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase as TestKernelTestCase;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;

class KernelTestCase extends TestKernelTestCase
{
    use ResetDatabase, Factories;
}

Ensuite il suffit simplement de modifier l'import dans Pest.php pour cibler le KernelTestCase présent dans testset non celui de Symfony.

À noter également que les données insérés dans la base de données par Foundry passent toujours par les events de Doctrine, donc par exemple si vous effectuez des instructions pendant le prePersist ou le preUpdate (via des listeners, comme par exemple l'affectation automatique de valeur comme les dates pour des champs createdAt ou updatedAt ou encore le hashage automatique des mots de passe), ils seront exécutés.

Au final c'est une alternative très sympathique à alice et assurément un moyen que je vais privilégier personnellement.

Mots clés

Anthodev

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

Super ! Vous vous êtes inscrit avec succès.
Super ! Effectuez le paiement pour obtenir l'accès complet.
Bon retour parmi nous ! Vous vous êtes connecté avec succès.
Parfait ! Votre compte est entièrement activé, vous avez désormais accès à tout le contenu.