Améliorer la DX de vos Fixtures PHP

Les fixtures sont utilisées pour charger des données définies par les développeurs dans une base de données. Elles sont très utiles en environnement de développement car elles permettent d’avoir une application avec plusieurs jeux de données qui correspondent à ce qu’il se passe en production. Ainsi, les tests d’intégration, fonctionnels ou d’acceptation se basent sur de la donnée concrète et réaliste.

De plus, nous devenons moins frileux lorsqu’il s’agit de modifier nos données, car nous savons qu’en une commande et quelques secondes d’attente, nous revenons à un état stable de notre donnée.

Dans l’écosystème PHP, il existe plusieurs packages pour nous aider à construire et à charger nos fixtures. Dans cet article, nous allons voir comment nous gérons nos fixtures.

Qu’attendre d’un gestionnaire de fixtures ?

Il y a certaines fonctionnalités qui sont importantes à nos yeux :

  • Vider la ou les bases de données avant insertion ;
  • Faire des groupes de fixtures (test, dev, load_test, prod, …) ;
  • Être ouvert sur le moteur de base de données (PostgreSQL, MySQL, Elasticsearch, Rabbitmq, Redis, …) ;
  • Être rapide à exécuter ;
  • Avoir une écriture, maintenance, et lecture des fichiers de fixtures simple et fluide.

Quelle solution retenir ?

Avec symfony 1, le gestionnaire de fixtures était natif au framework (I’m this old 😂).

Avec Symfony 2 et la séparation plus nette entre Symfony et Doctrine, nous avons vu émerger différents systèmes.

doctrine/data-fixtures est le plus connu, et le plus utilisé. À l’époque, comme tout le monde je pense, nous avons commencé à l’utiliser et c’était exactement l’outil qu’il nous fallait. Et depuis tout ce temps, rien n’a changé ! C’est toujours l’outil que nous préférons.

Cependant, sur certains projets, nous avons testé nelmio/alice. Sur le papier, cette solution peut sembler avantageuse. Mais nous avons eu, bien souvent, plus de problèmes que de solutions. Par exemple il est compliqué de mettre du HTML ou du JSON dans les fixtures, car il faut « échapper » le contenu a la main (raw: \<script src='main.js' type='text/javascript'\>\</script\>). C’est pour cela que nous ne recommandons pas cette solution.

Comment écrire ses fixtures ?

Nous allons ici vous présenter une utilisation possible de Doctrine Data Fixture, qui nous a donné beaucoup de satisfaction sur nos projets.

Un builder pour les construire tous

Avant même d’insérer des fixtures dans notre base de données préférée (PostgreSQL 😜), nous devons construire des objets PHP. Pour ce faire, nous utilisons une classe FixtureBuilder. Cette classe n’a que des méthodes statiques et elle ressemble à cela :

class FixtureBuilder
{
    private static $faker;

    public static function createOrganization(array $description = []): Organization
    {
        $description = array_replace([
            'id' => uuid_create(),
            'name' => self::getFaker()->company,
        ], $description);

        $organization = new Organization();

        ReflectionHelper::setProperty($organization, 'id', $description['id']);
        $organization->setName($description['name']);

        return $organization;
    }

    private static function getFaker(): \Faker\Generator
    {
        if (null === self::$faker) {
            self::$faker = \Faker\Factory::create();
            // self::$faker = \Faker\Factory::create('fr_FR');
        }

        return self::$faker;
    }
}

Toutes les méthodes acceptent un tableau $description qui permet de forcer certaines valeurs. Si les valeurs ne sont pas spécifiées, alors on génère des données aléatoirement. Pour nous faciliter cette tâche, nous utilisons fzaninotto/Faker.

Enfin, il n’existe pas forcément de setter pour toutes les propriétés. Dans ce cas, nous utilisons une classe que nous copions / collons de projet en projet :

class ReflectionHelper
{
    public static function setProperty(object $object, string $property, $value)
    {
        $reflectionProperty = new \ReflectionProperty(\get_class($object), $property);
        $reflectionProperty->setAccessible(true);
        $reflectionProperty->setValue($object, $value);
    }

    public static function getProperty(object $object, string $property, ?string $class = null)
    {
        $reflectionProperty = new \ReflectionProperty($class ?: \get_class($object), $property);
        $reflectionProperty->setAccessible(true);

        return $reflectionProperty->getValue($object);
    }

    public static function callMethod(object $object, string $method, ...$args)
    {
        $r = new \ReflectionObject($object);
        $m = $r->getMethod($method);
        $m->setAccessible(true);

        return $m->invoke($object, ...$args);
    }

    public static function createWithoutConstructor(string $class): object
    {
        $r = new \ReflectionClass($class);

        return $r->newInstanceWithoutConstructor();
    }
}

Dans certains cas, nous pouvons aussi injecter certains services dans les méthodes. C’est le cas de la méthode createUser pour encoder automatiquement le mot de passe :

    public static function createUser(array $description = [], ?UserPasswordEncoderInterface $passwordEncoder = null): User
    {
        $description = array_replace([
            'email' => self::getFaker()->email,
            'name' => self::getFaker()->name,
            'password' => 'password',
        ], $description);

        $user = new User();
        $user->setEmail($description['email']);
        $user->setName($description['name']);
        if ($passwordEncoder) {
            $user->setPassword($passwordEncoder->encodePassword($user, $description['password']));
        } else {
            $user->setPassword($description['password']);
        }

        return $user;
    }

Enfin, les objets construits doivent tout le temps être valides. Par exemple, si un projet est attaché à une organisation, le builder va la construire automatiquement :

    public static function createProject(array $description = []): Project
    {
        $description = array_replace([
            // ...
        ], $description);

        if (!\array_key_exists('organization', $description)) {
            $description['organization'] = self::createOrganization();
        }

        $project = new Project();
        $project->setOrganization($description['organization']);
        // ...

Et voilà ! Nous avons maintenant une classe qui peut construire n’importe quel type d’objet data (entité, DTO, etc) de notre application. Cette classe va grossir au fur et à mesure que l’application grandit. Elle peut devenir très grosse, 1000 lignes dans redirection.io par exemple. Ce n’est cependant pas un problème toutes les méthodes sont courtes et avec une complexité extrêmement faible.

Nous utilisons beaucoup ce builder dans les tests unitaires, pour avoir des objets déjà valides et fonctionnels.

Persistance des objets

Avoir des objets dans la mémoire de PHP, c’est très pratique, mais pour les tests d’intégration, fonctionnels ou d’acceptance, il faut persister ces objets dans une base.

Nous allons utiliser doctrine/data-fixture et son bundle pour nous aider. Nous allons créer plusieurs fichiers qui correspondent à des groupes :

  • src/Fixtures/BaseFixtures.php ;
  • src/Fixtures/DevelopmentFixtures.php ;
  • src/Fixtures/TestFixtures.php ;

Dans la classe BaseFixtures qui est étendue par toutes les autres classes, nous allons écrire des méthodes pour nous simplifier la vie. Bien entendu, cette classe va utiliser massivement FixtureBuilder :

abstract class BaseFixtures extends Fixture implements ContainerAwareInterface
{
    use ContainerAwareTrait;

    protected $manager;

    public function load(ObjectManager $manager)
    {
        $this->manager = $manager;
    }

    protected function addUser(array $description = []): User
    {
        $user = FixtureBuilder::createUser($description, $this->container->get('security.password_encoder'));

        $this->manager->persist($user);

        return $user;
    }
}

Dans le cas où nous forçons un UUID (ce qui est très pratique pour tester une API par exemple), il faut désactiver la gestion de l’ID par Doctrine :

    protected function addOrganization(array $description): Organization
    {
        $organization = FixtureBuilder::createOrganization($description);

        $metadata = $this->manager->getClassMetadata(Organization::class);
        $metadata->setIdGenerator(new \Doctrine\ORM\Id\AssignedGenerator());
        $metadata->setIdGeneratorType(\Doctrine\ORM\Mapping\ClassMetadata::GENERATOR_TYPE_NONE);

        $this->manager->persist($organization);

        return $organization;
    }

Ensuite, il ne reste plus qu’à utiliser cette classe :

class DevelopmentFixtures extends BaseFixtures implements FixtureGroupInterface
{
    public function load(ObjectManager $manager)
    {
        parent::load($manager);

        $greg = $this->addUser([
            'email' => 'gpineau@redirection.io',
            'name' => 'Grégoire Pineau',
            'super_admin' => true,
        ]);
        $demo = $this->addOrganization([
            'name' => 'demo',
        ]);
        $this->addUserInOrganization($greg, $demo);

        $demoProject = $this->addProject($demo, [
            'id' => '56143eda-bfe7-11e8-8468-0242ac120005',
            'name' => 'demo.redirection.test',
        ]);

        $manager->flush();

        // ...
    }
}

Ici aussi, ces classes vont grossir avec le temps. Pour certains projets, nous préférons avoir toutes les fixtures dans le même fichier car il est alors plus simple de référencer des objets : il suffit d’utiliser une variable ! Dans d’autres cas nous pouvons utiliser le système de référence de Doctrine Data Fixtures.

Organiser ses fixtures

FIRST est un ensemble de règles que nous nous forçons de respecter lorsque nous écrivons des tests :

  • Fast : les tests doivent être rapides à exécuter ;
  • Isolated : on doit pouvoir lancer le test A puis B ou B puis A sans que ça ne change le résultat ;
  • Repeatable : on doit pouvoir lancer le test A 10 fois de suite sans que ça ne change le résultat ;
  • Self-validating : les tests doivent être simples à lire, ainsi que leurs résultats ;
  • Timely : les tests doivent être écrits au bon moment.

Ce qui nous intéresse ici, est le caractère indépendant et répétable des tests.

Prenons un exemple simple pour tester une API. Considérons les tests suivants :

  1. Un projet est ajouté ;
  2. La liste des projets est récupérée et nous comptons les projets.

On se rend vite compte que, si nous exécutons le test 1. plusieurs fois, alors le résultat du test 2. sera invalide. Pire même : si on exécute 2. puis 1. alors la suite de tests échoue !

Vous pouvez tester ces cas avec les options suivantes de PHPUnit : --repeat=42 et --order-by=random.

Une solution serait de recharger ces fixtures avant chaque test. Même si le chargement est assez rapide (moins de quelques secondes), c’est une perte de temps énorme à l’échelle d’un projet ! C’est une solution que nous ne recommandons pas.

À la place, il est plus simple d’implémenter deux types de fixtures :

  1. Des fixtures en « read only » ;
  2. Des fixtures en « read / write ».

Dans le premier cas, les tests utilisant ces fixtures ne doivent pas changer l’état de la base de données. Nous utilisons ces fixtures pour tester par exemple :

  • les endpoints GET:
    • /projects retourne bien une collection de projet, et il y a bien N projets ;
    • /project/123 retourne bien un projet ;
  • la sécurité : que l’utilisateur B ne peut pas GET, UPDATE, POST, DELETE les projets de l’utilisateur A.

Dans le deuxième cas, les tests peuvent changer l’état de la base de données. Nous utilisons ces fixtures pour tester :

  1. POST avec des données invalides, pour tester la validation ;
  2. POST avec des données valides, pour tester la persistance ;
  3. PUT avec des données invalides, pour tester la validation ;
  4. PUT avec des données valides, pour tester la persistance ;
  5. DELETE pour tester la suppression.

Ici, nos tests ne sont plus indépendants, mais nous pouvons régler ce problème avec l’annotation @depends() de PHPUnit ou asynit.

Un point sur les IDs

Si vous testez une API, il y a de fortes chances pour qu’un ID se retrouve dans l’URL. Et si vous utilisez un système de test externe à votre application comme asynit, Behat, Panther, ou Puppeteer, obtenir un ID va vite être compliqué. C’est pourquoi nous faisons le choix de coder en dur des ID dans les fixtures. Tout redevient simple ! Et si vous avez la « chance » de travailler avec une base de données qui ne gère pas les UUIDs (comme MySQL), vous pouvez même mettre des « slugs » à la place des UUIDs. La lecture de vos tests n’en sera qu’améliorée.

Quid des autres bases de données ?

Dans cet article, nous n’avons parlé que de fixtures « In Memory », et persistées via Doctrine. Chez JoliCode, nous utilisons aussi beaucoup Elasticsearch. Et ce n’est pas un problème pour doctrine/data-fixture. Cependant, nous devons vider les index à la main au début du chargement des fixtures :

class TestFixtures extends BaseFixtures implements FixtureGroupInterface
{
    use FixtureBuilderTrait;

    public function load(ObjectManager $manager)
    {
        $this->resetElasticsearch();
    }

    protected function resetElasticsearch()
    {
        /** @var HttpClientInterface $httpClient */
        $httpClient = $this->container->get('redirectionio.elasticsearch.client');

        $httpClient->request('DELETE', '/log*');
        $httpClient->request('DELETE', '/rules*');
        // ...
        $httpClient->request('POST', '/_refresh');
    }
}

Il en va de même pour les autres types de bases de données (Redis, RabbitMQ, etc).

En conclusion

La mise en place d’un système de fixtures et de tests est quelque chose de lent, et bien souvent peu gratifiant. C’est pour cela qu’il faut mettre un place un système simple et facile dès le début du projet. En faisant évoluer les fixtures au fur et à mesure que le projet avance, l’effort demandé par aux développeurs est minimisé et la DX améliorée.

Nos formations sur le sujet

  • Logo Symfony

    Symfony

    Formez-vous à Symfony, l’un des frameworks web PHP les plus connus au monde

  • Logo Symfony avancée

    Symfony avancée

    Décou­vrez les fonc­tion­na­li­tés et concepts avan­cés de Symfo­ny

blog comments powered by Disqus