Maison >développement back-end >tutoriel php >Testez toute votre logique métier en moins d'une seconde
Cet article ne concerne pas les tests. Il s'agit d'adopter un flux de travail qui vous permet de garder le contrôle tout en développant votre fonctionnalité. Les tests ne sont que le moteur et l’heureuse conséquence de ce processus.
Ce workflow a complètement transformé ma façon de coder et il ne manque jamais de me faire sourire. J'espère que cela fera la même chose pour vous. À la fin, vous disposerez d'une fonctionnalité entièrement développée qui satisfait à toutes les règles métier et d'une suite de tests qui la valide en moins d'une seconde.
J'ai utilisé PHP pour la démonstration, mais ce workflow est entièrement adaptable à n'importe quel langage.
Lorsqu’il est temps de développer une nouvelle fonctionnalité, il est souvent difficile de savoir par où commencer. Faut-il commencer par la logique métier, le contrôleur ou le front-end ?
Il est tout aussi délicat de savoir quand s’arrêter. Sans un processus clair, la seule façon d’évaluer vos progrès consiste à effectuer des tests manuels. Une approche fastidieuse et sujette aux erreurs.
Vous connaissez celui-là. Ce dossier où des choses inexplicables se produisent. Vous devez modifier une seule ligne, mais chaque tentative semble briser l'ensemble du projet. Sans un filet de sécurité de tests, refactoriser, c'est comme marcher sur une corde raide au-dessus d'un canyon.
Pour éviter ce chaos, vous décidez de tester chaque fonctionnalité avec des tests de bout en bout. Excellente idée ! Jusqu'à ce que vous réalisiez que vous avez suffisamment de temps pour boire cinq tasses de café en attendant la fin de la suite de tests. Productivité ? Par la fenêtre.
Dans le but de créer un feedback instantané, vous décidez d'écrire des tests précis pour tous vos cours. Cependant, désormais, chaque modification que vous apportez entraîne une cascade de tests défectueux, et vous finissez par passer plus de temps à les corriger qu'à travailler sur la modification réelle. C’est frustrant, inefficace et vous fait redouter chaque refactor.
Les commentaires sont essentiels à chaque étape du développement logiciel. Lors du développement du cœur du projet, j'ai besoin d'un retour instantané pour savoir immédiatement si l'une de mes règles métier n'est pas respectée. Lors du refactoring, avoir un assistant qui m'informe si le code est cassé ou si je peux procéder en toute sécurité est un avantage inestimable.
Ce que le projet est capable de faire, les comportements du projet, est l'aspect le plus important. Ces comportements doivent être centrés sur l'utilisateur. Même si l'implémentation (le code créé pour faire fonctionner une fonctionnalité) change, l'intention de l'utilisateur restera la même.
Par exemple, lorsqu'un utilisateur passe une commande de produit, il souhaite être averti. Il existe de nombreuses façons de notifier un utilisateur : e-mail, SMS, courrier, etc. Même si l'intention de l'utilisateur ne change pas, la mise en œuvre le fera souvent.
Si un test représente l'intention de l'utilisateur et que le code ne remplit plus cette intention, le test devrait échouer. Cependant, lors du refactoring, si le code répond toujours à l'intention de l'utilisateur, le test ne devrait pas échouer.
Imaginez être guidé étape par étape dans la création d'une nouvelle fonctionnalité. En écrivant d'abord le test, il devient votre guide pour coder la fonctionnalité correcte. Ce n’est peut-être pas encore tout à fait clair, mais je veux que mon test fonctionne comme mon GPS pendant que je code. Avec cette approche, je n’ai pas besoin de penser à l’étape suivante, je suis simplement les instructions du GPS. Si ce concept vous semble encore flou, ne vous inquiétez pas ! Je vais l'expliquer en détail dans la section workflow. ?
Comme je l'ai mentionné dans l'introduction, cet article ne concerne pas les tests, mais la création d'une fonctionnalité. Et pour ce faire, j'ai besoin d'une fonctionnalité. Plus précisément, j'ai besoin d'une fonctionnalité accompagnée de tests d'acceptation définis avec des exemples. Pour chaque fonctionnalité, l'équipe doit établir des critères ou des règles d'acceptation, et pour chaque règle, créer un ou plusieurs exemples.
⚠️ Ces exemples doivent être centrés sur l'utilisateur/le domaine, sans aucun aspect technique décrit. Un moyen simple de vérifier si vos exemples sont bien définis est de vous demander : « Mon exemple fonctionnerait-il avec n'importe quelle implémentation (front-end Web, API HTTP, Terminal CLI, vie réelle, etc.) ? »
Par exemple, si je souhaite développer la fonctionnalité "Ajouter un produit au panier", mes exemples pourraient ressembler à ceci :
Ces exemples serviront de base aux tests. Pour les scénarios plus complexes, j'utilise le modèle Given/When/Then pour plus de détails, mais ce n'est pas nécessaire pour les plus simples.
Des retours instantanés et une focalisation sur les comportements, n'est-ce pas tout un défi ? Avec l'architecture Symfony MVC Services, y parvenir peut être difficile. C'est pourquoi j'utiliserai une architecture centrée sur le domaine, que j'ai détaillée dans cet article : Une autre façon de structurer votre projet Symfony.
Pour me concentrer sur les comportements, je dois structurer les tests de la bonne manière. Tout d’abord, je décris l’état du système avant l’action de l’utilisateur. Ensuite, j'exécute l'action de l'utilisateur, qui modifie l'état du système. Enfin, j'affirme que l'état du système correspond à mes attentes.
En testant de cette façon, le test ne se soucie pas de comment l'action de l'utilisateur est gérée par le système ; il vérifie uniquement si l'action réussit ou non. Cela signifie que si nous modifions l’implémentation de l’action utilisateur, le test ne sera pas interrompu. Cependant, si l’implémentation ne parvient pas à mettre à jour l’état du système comme prévu, le test sera interrompu — et c’est exactement ce que nous voulons !
Pour obtenir un retour instantané, il est nécessaire de se moquer de certaines parties du système. Une architecture centrée sur le domaine rend cela possible car le domaine s'appuie uniquement sur des interfaces pour interagir avec des bibliothèques externes. Cela rend incroyablement facile la falsification de ces dépendances, permettant aux tests fonctionnels de s'exécuter très rapidement. Bien entendu, les implémentations réelles seront également testées (mais pas dans cet article) à l'aide de tests d'intégration.
Dans ce workflow, le test est mon GPS ! Je fixe une destination, je la laisse me guider et me prévient de mon arrivée. Le test prendra forme à partir de l'exemple fourni par l'équipe.
Pour tester la logique métier et l'intention utilisateur, j'utilise un test fonctionnel :
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
Ce fichier contiendra tous les tests pour cette fonctionnalité, mais commençons par le premier.
Dans la première partie, je décris l'état de l'application. Ici, il y a un panier vide pour le client avec l'identifiant "1".
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
Le gestionnaire et la commande représentent l'intention de l'utilisateur, comme ça c'est explicite et tout le monde comprend.
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
? : J'ai décidé de séparer les commandes et les requêtes dans ce projet, mais nous pourrions avoir un AddProductToBasketUseCase.
Enfin, il est temps de décrire à quoi devrait ressembler le résultat final. J'espère avoir le bon produit dans mon panier.
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
Voici le test qui traduit le premier exemple en code.
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket(): void { // Arrange $customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]); // Act $commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId)); // Assert $expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]); } }
À ce stade, mon IDE affiche des erreurs partout car rien de ce que j'ai utilisé dans ce test n'existe encore. Mais s’agit-il réellement d’erreurs, ou s’agit-il simplement des prochaines étapes vers notre destination ? Je traite ces erreurs comme des instructions, chaque fois que j'exécute le test, il nous dira quoi faire ensuite. De cette façon, je n’ai pas à trop réfléchir ; Je suis juste le GPS.
? Astuce : Pour améliorer l'expérience du développeur, vous pouvez activer un observateur de fichiers qui exécute automatiquement la suite de tests chaque fois que vous apportez une modification. La suite de tests étant extrêmement rapide, vous recevrez un retour instantané sur chaque mise à jour.
Dans cet exemple, je vais traiter chaque erreur une par une. Bien sûr, si vous vous sentez en confiance, vous pouvez prendre des raccourcis en cours de route. ?
Alors, faisons le test ! Pour chaque erreur, je ferai le strict minimum nécessaire pour passer à l’étape suivante.
❌ ? : "Erreur : Classe "AppTestsFunctionalBasket" introuvable"
Comme il s'agit du premier test de cette fonctionnalité, la plupart des classes n'existent pas encore. Alors première étape, je crée un objet Basket :
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
❌ ? : "Erreur : Classe "AppTestsFunctionalInMemoryBasketRepository" introuvable"
Je crée le InMemoryBasketRepository :
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
❌ ? : "Erreur : Classe "AppTestsFunctionalAddProductToBasketCommandHandler" introuvable"
Je crée le AddProductToBasketCommandHandler dans le dossier Catalogue/Application, ce gestionnaire sera le point d'entrée de la fonctionnalité "Ajouter un produit au panier".
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
Pour respecter l'architecture centrée sur le domaine, je crée une interface pour le référentiel, comme ceci on inverse la dépendance.
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
❌ ? : "TypeError : AppCatalogApplicationCommandAddProductToBasketAddProductToBasketCommandHandler::__construct() : L'argument n°1 ($basketRepository) doit être de type AppCatalogDomainBasketRepository, AppCatalogInfrastructurePersistenceInMemoryInMemoryBasketRepository donné"
Maintenant, l'implémentation InMemory doit implémenter l'interface pour pouvoir être injectée dans le gestionnaire de commandes.
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket(): void { // Arrange $customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]); // Act $commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId)); // Assert $expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]); } }
❌ ? : _"Erreur : Classe "AppTestsFunctionalAddProductToBasketCommand" introuvable"
_
Je crée une commande, puisque la commande a est toujours un DTO, je l'ai marquée en lecture seule et j'ai mis toutes les propriétés publiques.
// src/Catalog/Domain/Basket.php namespace App\Catalog\Domain; class Basket { public function __construct( public readonly string $id, public readonly string $customerId, public array $products = [], ) { } }
❌ ? : "Échec de l'affirmation que deux objets sont égaux."
Le test est en cours de compilation ! Il fait encore rouge, donc nous n'avons pas encore fini, continuons ! ?
Il est temps de coder la logique métier. Tout comme lorsque j'écrivais le test, même si rien n'existe encore, j'écris comment j'attends de mon gestionnaire qu'il traite la commande. Il ne compilera pas, mais encore une fois, le test me guidera vers l'étape suivante.
// src/Catalog/Infrastructure/Persistence/InMemory/InMemoryBasketRepository.php namespace App\Catalog\Infrastructure\Persistence\InMemory; class InMemoryBasketRepository { public function __construct( public array $baskets ) { } }
❌ ? : "Erreur : Appel à la méthode non définie AppCatalogInfrastructurePersistenceInMemoryInMemoryBasketRepository::get()"
Je crée la méthode get dans l'interface du dépôt :
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
❌ ? : "Erreur fatale PHP : la classe AppCatalogInfrastructurePersistenceInMemoryInMemoryBasketRepository contient 1 méthode abstraite et doit donc être déclarée abstraite ou implémenter les méthodes restantes (AppCatalogDomainBasketRepository::get)"
Et maintenant je l'implémente dans le référentiel InMemory. Comme c'est uniquement pour tester, je crée une implémentation vraiment simple :
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
❌ ? : "Erreur : Appel à la méthode non définie AppCatalogDomainBasket::add()"
Je crée la méthode add et je l'implémente sur l'objet panier
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
❌ ? : Erreur : Appel à la méthode non définie AppCatalogInfrastructurePersistenceInMemoryInMemoryBasketRepository::save()
Idem ici, je crée la méthode dans l'interface et je l'implémente dans le dépôt en mémoire :
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket(): void { // Arrange $customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]); // Act $commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId)); // Assert $expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]); } }
✅ ? :
Destination atteinte ! ? ? ?
Avec ce workflow, j'ai gamifié l'expérience du développeur. Après le défi de relever le test rouge, le voir passer au vert vous donne un shot de dopamine ??
Pour réussir le test, je suis allé volontairement un peu vite. Maintenant, je peux prendre le temps d'affiner le test et de mieux implémenter le code.
Le test traduit l'exemple mais a perdu l'intention de l'utilisateur lors de la traduction. Avec peu de fonctions, je peux le rendre beaucoup plus proche de l'exemple original.
Exemple :
Test :
// src/Catalog/Domain/Basket.php namespace App\Catalog\Domain; class Basket { public function __construct( public readonly string $id, public readonly string $customerId, public array $products = [], ) { } }
Ce n’est pas l’objet de cet article, je n’entrerai donc pas dans les détails, mais je pourrais exprimer le modèle de domaine plus précisément en utilisant un modèle de domaine riche.
Puisque le test est vert, je peux maintenant refactoriser autant que je veux, le test me soutient. ?
Comme vous l'avez vu au début de l'article, une fonctionnalité contient de nombreux exemples pour la décrire. Il est donc temps de tous les mettre en œuvre :
// src/Catalog/Infrastructure/Persistence/InMemory/InMemoryBasketRepository.php namespace App\Catalog\Infrastructure\Persistence\InMemory; class InMemoryBasketRepository { public function __construct( public array $baskets ) { } }
J'ai suivi ce workflow plusieurs fois avec différents tests, et diverses parties de mon code ont évolué pour s'aligner sur les règles métier. Comme vous l'avez vu lors du premier test, mes objets étaient très simples et parfois même basiques. Cependant, à mesure que de nouvelles règles métier ont été introduites, elles m'ont poussé à développer un modèle de domaine beaucoup plus intelligent. Voici la structure de mes dossiers :
Pour simplifier les tests tout en préservant l'encapsulation des modèles de domaine, j'ai introduit les modèles de générateur et d'instantané.
// tests/Functional/AddProductToBasketTest.php namespace App\Tests\Functional; use PHPUnit\Framework\TestCase; class AddProductToBasketTest extends TestCase { public function testAddProductToBasket() { } }
$customerId = 1; $productId = 1; $basketId = 1; $basket = new Basket($basketId, customerId: $customerId); $inMemoryBasketRepository = new InMemoryBasketRepository(baskets: [$basket]);
$commandHandler = new AddProductToBasketCommandHandler( $inMemoryBasketRepository, ); $commandHandler(new AddProductToBasketCommand(customerId: $customerId, basketId: $basketId, productId: $productId));
Comment je l'utilise :
$expectedBasket = new Basket($basketId, $customerId, array($productId)); $this->assertEquals($expectedBasket, $inMemoryBasketRepository->baskets[0]);
Comme je suis entièrement concentré sur la logique métier, mon test me guide à chaque étape, me disant quoi faire à chaque fois que j'ajoute un nouveau code. De plus, je n'ai plus besoin d'effectuer des tests manuels pour m'assurer que tout fonctionne, ce qui améliore considérablement mon efficacité.
Dans cet article, j'ai démontré un exemple très simple où j'ai principalement créé des classes. Cependant, avec une logique métier plus complexe, chaque nouvelle règle ajoutera de la complexité au modèle de domaine. Lors du refactoring, mon objectif est de simplifier le modèle de domaine tout en gardant les tests verts. C'est un véritable défi, mais incroyablement satisfaisant quand cela fonctionne enfin.
Ce que j'ai montré ici n'est que la première partie du flux de travail complet. Comme je l'ai mentionné, pour disposer d'une fonctionnalité entièrement fonctionnelle, je dois encore implémenter les vrais adaptateurs. Dans ce cas, cela impliquerait la création des référentiels (Panier, Client et Produit) en utilisant une approche de test d'abord avec une vraie base de données. Une fois cela terminé, je configurerais simplement les implémentations réelles dans la configuration de l'injection de dépendances.
J'ai utilisé du PHP pur sans m'appuyer sur aucun framework, en me concentrant uniquement sur l'encapsulation de toute la logique métier. Avec cette stratégie, je n'ai plus à me soucier des performances des tests. Qu'il y ait des centaines, voire des milliers de tests, ils s'exécuteraient toujours en quelques secondes seulement, fournissant un retour instantané lors du codage.
Pour vous donner un aperçu des performances, voici la suite de tests répétée 10 000 fois :
Ce workflow se situe à l'intersection de plusieurs concepts. Permettez-moi de vous en présenter quelques-uns afin que vous puissiez les explorer davantage si nécessaire.
Pour décrire une fonctionnalité, la meilleure approche est de fournir autant d'exemples que nécessaire. Une réunion d'équipe peut vous aider à acquérir une compréhension approfondie de la fonctionnalité en identifiant des exemples concrets de la façon dont elle devrait fonctionner.
Le framework Given-When-Then est un excellent outil pour formaliser ces exemples.
Pour traduire ces exemples en code, le langage Gherkin (avec Behat en PHP) peut être utile. Cependant, je préfère personnellement travailler directement dans les tests PHPUnit et nommer mes fonctions à l'aide de ces mots-clés.
Le "qu'est-ce qu'on veut ?" une partie pourrait se résumer avec l'acronyme F.I.R.S.T :
Vous disposez désormais d'une checklist pour savoir si vos tests sont bien faits.
Toutes ces architectures visent à isoler la logique métier des détails de mise en œuvre. Que vous utilisiez des ports et des adaptateurs, une architecture hexagonale ou une architecture propre, l'idée principale est de rendre le cadre de logique métier indépendant et facile à tester.
Une fois que vous avez cela à l’esprit, il existe un spectre complet d’implémentations, et la meilleure dépend de votre contexte et de vos préférences. Un avantage majeur de cette architecture est qu'en isolant la logique métier, elle permet des tests beaucoup plus efficaces.
Je suppose que vous êtes déjà familier avec la pyramide de test, je préfère plutôt utiliser la représentation en diamant. Cette stratégie, décrite par Thomas Pierrain, est axée sur des cas d'utilisation et se concentre sur les comportements de notre application.
Cette stratégie encourage également les tests en boîte noire de l'application, en se concentrant sur les résultats qu'elle produit plutôt que sur la manière dont elle les produit. Cette approche rend la refactorisation considérablement plus facile.
Comme vous l'avez vu tout au long du workflow, dans le test et dans le gestionnaire de commandes, j'ai toujours écrit ce que je voulais avant même que cela existe. J'ai fait quelques "voeux". Cette approche est connue sous le nom de programmation de vœux pieux. Il vous permet d'imaginer la structure de votre système et la manière idéale d'interagir avec lui avant de décider comment le mettre en œuvre. Lorsqu'il est combiné avec des tests, cela devient une méthode puissante pour guider votre programmation.
Ce modèle permet de structurer efficacement les tests. Tout d’abord, le système est réglé dans l’état initial correct. Ensuite, un modificateur d'état, tel qu'une commande ou une action utilisateur, est exécuté. Enfin, une assertion est faite pour garantir que le système est dans l'état attendu après l'action.
Nous avons expliqué comment une architecture axée sur le domaine combinée à un développement basé sur les tests d'acceptation peut transformer votre expérience de développement. Un retour instantané, des tests robustes et une concentration sur les intentions des utilisateurs rendent le codage non seulement plus efficace, mais aussi plus agréable.
Essayez ce workflow, et vous pourriez vous retrouver souriant à chaque fois qu'un test passe au vert ✅ ➡️ ?
Faites-moi savoir dans les commentaires quel serait votre flux de travail idéal ou qu'amélioreriez-vous dans celui-ci !
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!