Le blog

Sortie de Foundry 2 : nouveautés et migration

Publié le 24 juin 2024

Nous sommes ravis de vous annoncer la sortie de la version 2 de Foundry ! Découvrons ensemble dans cet article les nouveautés apportées par cette version et les techniques à mettre en œuvre pour migrer sereinement.

Foundry et son fonctionnement #

Foundry est un générateur de fixtures pour Symfony qui s'intègre particulièrement bien avec Doctrine (ORM et ODM). Il a été créé en 2020 par Kevin Bond.

À l'inverse de bibliothèques comme doctrine/data-fixtures ou nelmio/alice, la philosophie de Foundry est de partir d'une base de données vide à chaque test et de créer un jeu de données spécifique, avec uniquement les données nécessaires pour le test en cours. Cela évite ainsi les écueils inhérents à un jeu de données global partagé par tous les tests du projet : la moindre modification des données nécessaires à un test  pouvant impacter et casser les autres tests utilisant ces mêmes données.

Foundry permet de créer des "factories", pour chaque entités (ORM) ou documents (ODM) ou encore pour n'importe quel DTO ou Value Object dont on souhaite déléguer la création à Foundry. Voici l'exemple d'une factory :

final class BookFactory extends ModelFactory
{
   protected function getDefaults(): array
   {
       return [
           'title' => self::faker()->words(3),
           'author' => AuthorFactory::new()
       ];
   }


   protected static function getClass(): string
   {
       return Book::class;
   }
}

Voici un exemple d'utilisation de la factory dans les tests :

final class BookTest extends KernelTestCase
{
   // nécessaire pour utiliser Foundry dans les tests
   use Factories;


   // Permet de réinitialiser la base de données au début de chaque test
   use ResetDatabase;


   #[Test]
   public function can_do_something_with_one_book(): void
   {
       // créé un "Book" et le stocke en base de données
       // un "Author" sera également créé
       BookFactory::createOne();


       // utilise un titre et un auteur spécifique
       BookFactory::createOne([
           'title' => 'Foundation',
           'author' => AuthorFactory::new(['name' => 'Asimov']),
       ]);


       // va chercher en base un auteur qui répond au critère ['name' => 'Asimov'] ou va le créer en base
       BookFactory::createOne([
           'title' => 'Robots',
           'author' => AuthorFactory::findOrCreate(['name' => 'Asimov'])
       ]);


       // Test...
   }


   #[Test]
   public function can_do_something_with_several_books(): void
   {
       // créé trois "Book" et trois "Author" en base
       BookFactory::createMany(3);


       // créé trois "Book" reliés au même "Author"
       $author = AuthorFactory::createOne(['name' => 'Isaac Asimov']);
       BookFactory::createMany(3, ['author' => $author]);


       // créé trois "Book" avec des titres différents
       AuthorFactory::createSequence([
           ['name' => 'Isaac Asimov'],
           ['name' => 'Valérie Despentes'],
           ['name' => 'Amin Maalouf'],
       ]);


       // Test...
   }
}

Foundry intègre de nombreuses autres fonctionnalités. N'hésitez pas à consulter sa documentation pour en prendre connaissance.

Pourquoi une V2 ? #

Le cas des objets "proxy"

Foundry intègre un mécanisme de proxy qui permet d'interagir depuis les objets doctrine directement avec la base de données via le pattern Active Record. Les méthode de création d'objets retournent un objet Zenstruck\Foundry\Proxy qui agit comme un "wrapper" autour de l'objet créé :

/** @var Zenstruck\Foundry\Proxy<Book> */
$book = BookFactory::createOne();
dump($book::class); // "Zenstruck\Foundry\Proxy"


$realBook = $book->object();
dump($realBook::class); // "App\Entity\Book"


// appelle la "vraie" méthode "setName()"
$book->setName('New name');


// met à jour la base de données
$book->save();


// met à jour l'objet depuis la base de données
$book->refresh();


// supprime l'objet de la base
$book->remove();

Ce mécanisme, bien que très pratique (en particulier pour l'auto-refresh), présente plusieurs inconvénients :

  • Il "casse" le type-system, car on obtient une instance de Zenstruck\Foundry\Proxy, et il faut systématiquement appeler la méthode ->object() si on veut passer nos objets à des méthodes dont les paramètres sont typés (et les paramètres de nos méthodes SONT typés 😁)
function takesBook(Book $book)
{
   // ...
}


$book = BookFactory::createOne();
takesBook($book); 💥
takesBook($book->object()); ✅

et c'est encore pire lorsque l'on manipule des listes d'objets :

function takesBooks(Book ...$books)
{
   // ...
}


$books = BookFactory::createMany(3);
takesBooks(...$books); 💥
takesBooks(
   ...array_map(
       static fn(Proxy $book) => $book->object(),
       $books
   )
); ✅
Illustration de Toy Story" class="wp-image-9370
  • Si la classe que l'on souhaite créer définit par exemple une méthode save() ou une méthode object() ou n'importe quelle méthode publique de la classe Proxy il y a un conflit entre les méthodes de l'entité et celles de la classe Proxy.

Ces deux problèmes nuisent à la DX et rendent l'implémentation du mécanisme de proxy maladroite, cette implémentation devait donc évoluer.

Dans Foundry 2, le mécanisme de proxy utilise le ProxyHelper du composant symfony/var-exporter, apparu en Symfony 6. C'est le même mécanisme "battle tested" qui est utilisé dans Symfony pour créer des objets "lazy" ou encore dans Doctrine pour générer les proxies des entités.

Cette solution utilise de "vrais" proxies : le type renvoyé par BookFactory::create() est maintenant Book&Proxy<Book> (qui est l’intersection du type "Book" et du type générique "Proxy" appliqué à la class "Book"). Par conséquent, le nouveau mécanisme ne casse plus le "type-system" et il n'est plus nécessaire d'appeler la méthode ->object() de partout !

Afin de pallier le second problème, les méthodes de la classe de Proxy ont été échappées avec un underscore pour éviter toute collision, et/ou renommées avec des noms plus pertinents. Ainsi, object() devient _real(), save() devient _save() et ainsi de suite.

Foundry V1 est très (trop) lié à Doctrine

Foundry a été initialement créé dans le seul but de stocker des objets en base de données, ce qui le rend fortement dépendant de Doctrine ORM. Puis le support de l'ODM a été ajouté, grâce à doctrine/persistence, mais il serait par exemple très complexe de supporter un autre mécanisme de persistance sans avoir à faire des hacks absolument partout.

Dans Foundry 2, cette dépendance forte à Doctrine a été supprimée, et la logique a été inversée : quand Foundry V1 était "Doctrine first", Foundry 2 est "object first".

// V1


// ModelFactory est maintenant dépréciée dans la dernière version de la V1
final class BookFactory extends ModelFactory {}


// V2


// utile pour créer de simples objets
final class BookFactory extends ObjectFactory {}


// crée des objets "persistables", mais sans Proxy
final class BookFactory extends PersistentObjectFactory {}


// même fonctionnement que l’ancienne classe"ModelFactory"
final class BookFactory extends PersistentProxyObjectFactory {}

Il est maintenant possible d'hériter au choix de l'une ou l'autre des classes abstraites offertes par Foundry, afin d'obtenir des fonctionnements différents.

Cette inversion de l'architecture interne de Foundry nous a aussi permis de fournir une classe abstraite ArrayFactory afin de bénéficier de la DX de Foundry pour créer des tableaux associatifs.

Il sera également possible dans le futur d'intégrer bien plus facilement le support à d'autres mécanismes de persistance, voire même d'implémenter un mécanisme de "in-memory" afin d'améliorer les performances de nos tests.

Amélioration de la syntaxe

Le passage à une V2 a également servi à corriger quelques syntaxes hasardeuses et à supprimer les nombreuses dépréciations qui ont été ajoutées tout au long du développement et de l'évolution de la V1.

"Migration path" pour la V2 #

Toutes ces modifications impliquent forcément certaines “ruptures de compatibilité” (ou "BC breaks"), voire même beaucoup de BC breaks !

> Du coup, tous mes tests vont échouer lorsque je vais passer à la V2 ?

Pas forcément ! En prenant exemple sur la promesse de compatibilité de Symfony, toutes les modifications impliquant des BC breaks dans la V2 sont couvertes par une couche de compatibilité dans la V1 : toutes les nouvelles méthodes de la V2 sont présentes dans la dernière version de la V1 et tous les mécanismes dépréciés de la V1 déclenchent des erreurs E_USER_DEPRECATED qui permettront à PHPUnit de vous avertir des modifications à apporter dans votre code.

La marche à suivre
  • Mise à jour de Foundry sur la dernière version de la V1 : la V1.38.
$ composer update zenstruck/foundry
  • Faire tourner les tests en activant l'affichage des dépréciations.

Si vous utilisez le bridge de Symfony pour PHPUnit, il vous faudra configurer le "deprecation helper" :

# .env.test
SYMFONY_DEPRECATIONS_HELPER="max[self]=0&amp;max[direct]=0&amp;quiet[]=indirect&amp;quiet[]=other"

ou

<!-- phpunit.xml.dist -->
<phpunit>
   <php>
       <!-- ... -->
       <server name="SYMFONY_DEPRECATIONS_HELPER" value="max[self]=0&amp;max[direct]=0&amp;quiet[]=indirect&amp;quiet[]=other"/>
   </php


           <!-- ... -->
</phpunit>

Cette configuration va informer le "deprecation helper" du bridge PHPUnit de Symfony que vous voulez afficher les dépréciations dites "directes" (celles qui sont déclenchées par votre code).

Si vous utilisez la version 10 ou 11 de PHPUnit, le bridge de Symfony n'est pas compatible avec cette version ; il faudra se reposer sur le mécanisme de dépréciations de PHPUnit qui est un peu moins performant que celui du bridge de Symfony.

A partir de PHPUnit 10, il faudra ajouter l'attribut ignoreSuppressionOfDeprecations=true au noeud <source> dans le fichier phpunit.xml.dist (ou phpunit.xml) et lancer la suite de tests avec l'option --display-deprecations.

  • Certaines dépréciations sont émises lors de la phase de compilation, il peut également être utile de lancer la commande suivante :
$ bin/console debug:container --deprecations
  • Une fois que vous avez collecté toutes les dépréciations, il est temps de les corriger !

Voyons ce que cela donne sur un projet sur lequel je travaille actuellement et qui utilise intensivement Foundry :

➜ vendor/bin/phpunit

...

OK (917 tests, 3553 assertions)

Remaining direct deprecation notices (5392) # 😱

Le nombre de dépréciations à corriger dépend bien entendu du nombre de tests dans le projet, ainsi que de l'utilisation qui est faite de foundry dans le projet.

Au boulot ! 😅

> Je dois vraiment corriger des milliers de dépréciations à la main ?

C'est la question que vous devez vous poser et que je me suis posée lorsque j'ai vu le nombre de dépréciations, La réponse est bien entendu "non" ! La dernière version de Foundry V1 fournit un "set" de règles Rector, qui vont faciliter la migration. Afin d'utiliser ces règles, il vous faudra installer Rector (ainsi que phpstan/phpstan-doctrine, si ce n'est déjà fait :

$ composer require --dev rector/rector phpstan/phpstan-doctrine

Ensuite, il vous faudra créer le fichier de configuration rector.php :

use Rector\Config\RectorConfig;
use Zenstruck\Foundry\Utils\Rector\FoundrySetList;


return RectorConfig::configure()
   ->withPaths(['tests']) // ajouter tous les chemins où Foundry est utilisé
   ->withSets([FoundrySetList::UP_TO_FOUNDRY_2]);

Et finalement, vous pouvez exécuter Rector :


# vous pouvez lancer Rector en mode "dry run", afin de voir les fichiers qui vont être modifiés
$ vendor/bin/rector process --dry-run


# modifier les fichiers
$ vendor/bin/rector process

Parfois Rector ne corrige pas toutes les dépréciations possible en une seule passe et il peut être utile de faire tourner une seconde fois Rector en vidant le cache :

$ vendor/bin/rector process –clear-cache

Les principales modifications apportées par Rector sont les suivantes :

  • remplacement de la classe Zenstruck\Foundry\ModelFactory par Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory pour les entités ORM et les documents ODM, ou par \Zenstruck\Foundry\ObjectFactory pour tous les autres objets
  • suppression des appels à la méthode Proxy::object() lorsque la classe cible n'est pas "persistable"
  • changement de tous les appels à des méthodes et des fonctions dépréciées par leur méthode ou fonction de remplacement
  • remplacement de tous les "use" de classes dépréciées par leurs classes de remplacement

Notez bien que Rector ne corrige pas toutes les dépréciations (certains cas complexes ne sont pas pris en compte). Il vous faudra relancer vos tests et corriger les dépréciations restantes à la main. Une fois que vos tests n'affichent plus de dépréciations, vous pourrez migrer vers Foundry V2 sereinement.

PHPStan permet également de s’assurer que votre code n’utilise pas de code déprécié, en utilisant le plugin phpstan/phpstan-deprecation-rules. C’est un filet de sécurité supplémentaire pour ne pas avoir de mauvaise surprise lors de la mise à jour en V2, mais cela nécessite néanmoins d’avoir configuré PHPStan pour qu’il analyse vos classes de tests, ce que je suggère fortement de faire.

Vous trouverez la liste complète des modifications de syntaxe entre la V1 et la V2 dans le guide de migration sur le repository de Foundry. N’hésitez pas à créer une issue dans le GitHub de Foundry si vous observez des problèmes dans les règles Rector ou des BC breaks dans Foundry V2.

Pour finir, je tiens à remercier Les-Tilleuls.coop, ainsi que notre client ARTE de m’avoir permis de dégager du temps pour travailler sur ce projet open source !

Le blog

Pour aller plus loin