À notre époque, les avantages de l’écriture de tests unitaires sont énormes. Je pense que la plupart des projets récemment lancés contiennent des tests unitaires. Dans les applications d'entreprise comportant beaucoup de logique métier, les tests unitaires sont les tests les plus importants, car ils sont rapides et peuvent nous garantir instantanément que notre implémentation est correcte. Cependant, je vois souvent un problème avec de bons tests dans les projets, même si les avantages de ces tests ne sont énormes que lorsque vous disposez de bons tests unitaires. Donc, dans ces exemples, je vais essayer de partager quelques conseils sur ce qu'il faut faire pour écrire de bons tests unitaires.
Version facile à lire : https://testing-tips.sarvendev.com/
Kamil Ruczyński
Blog : https://sarvendev.com/
LinkedIn : https://www.linkedin.com/in/kamilruczynski/
Votre soutien compte pour moi ! Si vous avez apprécié ce guide et que vous trouvez de la valeur dans les connaissances partagées, pensez à me soutenir sur BuyMeCoffee :
ou simplement laisser une étoile sur le référentiel et me suivre sur Twitter et Github pour être au courant de toutes les mises à jour. Votre générosité alimente ma passion pour la création de contenu plus perspicace pour vous.
Si vous avez des idées d'amélioration ou un sujet sur lequel écrire, n'hésitez pas à préparer une pull request ou faites-le-moi savoir.
Abonnez-vous et maîtrisez les tests unitaires avec mon eBook GRATUIT !
Détails
J'ai encore une assez longue liste TODO d'améliorations à ce guide sur les tests unitaires et je les présenterai dans un avenir proche.
Introduction
Auteur
Essais en double
Appellation
Modèle AAA
Objet mère
Constructeur
Affirmer un objet
Test paramétré
Deux écoles de tests unitaires
Classique
Moqueur
Dépendances
Maquette ou stub
Trois styles de tests unitaires
Sortir
État
Communication
Architecture fonctionnelle et tests
Comportement observable par rapport aux détails de mise en œuvre
Unité de comportement
Modèle humble
Test trivial
Essai fragile
Appareils d'essai
Tests généraux anti-modèles
Exposer l’État privé
Fuite des détails du domaine
Classes concrètes moqueuses
Tester les méthodes privées
Le temps comme dépendance volatile
Une couverture de test à 100 % ne devrait pas être l'objectif
Livres recommandés
Les doubles de tests sont de fausses dépendances utilisées dans les tests.
Un mannequin est une simple implémentation qui ne fait rien.
la classe finale Mailer implémente MailerInterface {fonction publique envoyer(Message $message) : void{ } }
Un faux est une implémentation simplifiée pour simuler le comportement original.
la classe finale InMemoryCustomerRepository implémente CustomerRepositoryInterface {/** * @var Customer[] */private array $customers;public function __construct() {$this->clients = []; }public function store(Client $client): void{$this->clients[(string) $client->id()->id()] = $client; }public function get(CustomerId $id): Customer{if (!isset($this->customers[(string) $id->id()])) {throw new CustomerNotFoundException(); }return $this->clients[(string) $id->id()]; }fonction publique findByEmail(Email $email) : Client{foreach ($this->customers as $customer) {if ($customer->getEmail()->isEqual($email)) {return $customer ; } }lancer une nouvelle CustomerNotFoundException(); } }
Un stub est l’implémentation la plus simple avec un comportement codé en dur.
la classe finale UniqueEmailSpecificationStub implémente UniqueEmailSpecificationInterface {public function isUnique(Email $email) : bool{return true ; } }
$spécificationStub = $this->createStub(UniqueEmailSpecificationInterface::class);$spécificationStub->method('isUnique')->willReturn(true);
Un espion est une implémentation pour vérifier un comportement spécifique.
la classe finale Mailer implémente MailerInterface {/** * @var Message[] */private array $messages; fonction publique __construct() {$this->messages = []; }fonction publique send(Message $message) : void{$this->messages[] = $message; }fonction publique getCountOfSentMessages() : int{return count($this->messages); } }
Une simulation est une imitation configurée pour vérifier les appels sur un collaborateur.
$message = nouveau Message('[email protected]', 'Test', 'Test test test');$mailer = $this->createMock(MailerInterface::class);$mailer->expects($this-> une fois()) ->méthode('envoyer') ->with($this->equalTo($message));
[!ATTENTION] Pour vérifier les interactions entrantes, utilisez un stub, mais pour vérifier les interactions sortantes, utilisez une simulation.
Plus : Mock vs Stub
[!AVERTISSEMENT|style:plat|étiquette:PAS BON]
la classe finale TestExample étend TestCase {/** * @test */public function sends_all_notifications() : void{$message1 = new Message();$message2 = new Message();$messageRepository = $this->createMock(MessageRepositoryInterface::class);$messageRepository ->method('getAll')->willReturn([$message1, $message2]);$mailer = $this->createMock(MailerInterface::class);$sut = new NotificationService($mailer, $messageRepository);$mailer->expects(self::exactly(2))->method('send') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);$sut->send(); } }
[!TIP|style:flat|étiquette:MEILLEUR]
Meilleure résistance au refactoring
L'utilisation de Refactor->Rename sur une méthode particulière n'interrompt pas le test
Meilleure lisibilité
Coût de maintenance réduit
Pas besoin d'apprendre ces frameworks de simulation sophistiqués
Juste du code PHP simple et clair
la classe finale TestExample étend TestCase {/** * @test */public function sends_all_notifications() : void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($message2);$mailer = nouveau SpyMailer();$sut = nouveau NotificationService($mailer, $messageRepository);$sut->send(); $mailer->assertThatMessagesHaveBeenSent([$message1, $message2]); } }
[!AVERTISSEMENT|style:plat|étiquette:PAS BON]
public function test() : void{$subscription = SubscriptionMother::new();$subscription->activate();self::assertSame(Status::activated(), $subscription->status()); }
[!TIP|style:flat|label:Spécifiez explicitement ce que vous testez]
public function sut() : void{// sut = Système sous test$sut = SubscriptionMother::new();$sut->activate();self::assertSame(Status::activated(), $sut->status ()); }
[!AVERTISSEMENT|style:plat|étiquette:PAS BON]
fonction publique it_throws_invalid_credentials_exception_when_sign_in_with_invalid_credentials() : void{ }fonction publique testCreatingWithATooShortPasswordIsNotPossible() : void{ }fonction publique testDeactivateASubscription() : void{ }
[!TIP|style:flat|étiquette:MEILLEUR]
L'utilisation du trait de soulignement améliore la lisibilité
Le nom doit décrire le comportement, pas l'implémentation
Utilisez des noms sans mots-clés techniques. Il doit être lisible pour une personne non-programmeur.
fonction publique sign_in_with_invalid_credentials_is_not_possible() : void{ }fonction publique create_with_a_too_short_password_is_not_possible() : void{ }fonction publique deactivating_an_activated_subscription_is_valid() : void{ }fonction publique deactivating_an_inactive_subscription_is_invalid() : void{ }
Note
La description du comportement est importante pour tester les scénarios de domaine. Si votre code n’est qu’un utilitaire, il est moins important.
Pourquoi serait-il utile pour un non-programmeur de lire les tests unitaires ?
S'il existe un projet avec une logique de domaine complexe, cette logique doit être très claire pour tout le monde. Ainsi, les tests décrivent les détails du domaine sans mots-clés techniques et vous pouvez parler avec une entreprise dans une langue comme dans ces tests. Tout le code lié au domaine doit être exempt de détails techniques. Un non-programmeur ne lira pas ces tests. Si vous souhaitez parler du domaine ces tests vous seront utiles pour savoir ce que fait ce domaine. Il y aura une description sans détails techniques, par exemple, renvoie null, lève une exception, etc. Ce type d'informations n'a rien à voir avec le domaine, nous ne devrions donc pas utiliser ces mots-clés.
C'est également courant Étant donné, quand, alors.
Séparez trois sections du test :
Organiser : amener le système testé dans l'état souhaité. Préparez les dépendances, les arguments et enfin construisez le SUT.
Act : Invoque un élément testé.
Assert : Vérifier le résultat, l'état final, ou la communication avec les collaborateurs.
[!TIP|style:flat|étiquette:BON]
public function aaa_pattern_example_test() : void{//Arrange|Given$sut = SubscriptionMother::new();//Act|When$sut->activate();//Assert|Thenself::assertSame(Status::activated( ), $sut->statut()); }
Le modèle permet de créer des objets spécifiques qui peuvent être réutilisés dans quelques tests. Pour cette raison, la section d'organisation est concise et le test dans son ensemble est plus lisible.
Classe finale AbonnementMère {fonction statique publique new() : abonnement{return new Subscription(); }fonction statique publique activée() : Abonnement{$abonnement = new Abonnement();$abonnement->activate();return $abonnement; }fonction statique publique désactivée() : Abonnement{$abonnement = self::activated();$abonnement->deactivate();return $abonnement; } }
classe finaleExempleTest {public function example_test_with_activated_subscription() : void{$activatedSubscription = SubscriptionMother::activated();// faire quelque chose// vérifier quelque chose}public function example_test_with_deactivated_subscription() : void{$deactivatedSubscription = SubscriptionMother::deactivated();// faire quelque chose // vérifie quelque chose} }
Builder est un autre modèle qui nous aide à créer des objets dans les tests. Comparé à Object Mother Pattern Builder, il est préférable de créer des objets plus complexes.
Classe finale OrderBuilder {private DateTimeImmutable|null $createdAt = null;/** * @var OrderItem[] */private array $items = [];fonction publique crééeAt(DateTimeImmutable $createdAt) : self{$this->createdAt = $createdAt;return $ ceci ; }fonction publique withItem(string $name, int $price) : self{$this->items[] = new OrderItem($name, $price);return $this; }fonction publique build() : commande{ Assert::notEmpty($this->items);return new Order($this->createdAt ?? new DateTimeImmutable(),$this->items, ); } }
la classe finale SampleTest étend TestCase {/** * @test */public function example_test_with_order_builder() : void{$order = (new OrderBuilder()) ->createdAt(new DateTimeImmutable('2022-11-10 20:00:00')) ->withItem('Article 1', 1000) ->withItem('Article 2', 2000) ->withItem('Article 3', 3000) ->build();//faire quelque chose//vérifier quelque chose} }
Le modèle d'objet d'assertion permet d'écrire des sections d'assertion plus lisibles. Au lieu d'utiliser quelques assertions, nous pouvons simplement préparer une abstraction et utiliser le langage naturel pour décrire le résultat attendu.
la classe finale SampleTest étend TestCase {/** * @test */public function example_test_with_asserter() : void{$currentTime = new DateTimeImmutable('2022-11-10 20:00:00');$sut = new OrderService();$order = $sut ->créer($currentTime); OrderAsserter::assertThat($commande) ->wasCreatedAt($currentTime) ->hasTotal(6000); } }
utiliser PHPUnitFrameworkAssert ; classe finale OrderAsserter {public function __construct(private readonly Order $order) {}public static function assertThat(Order $order): self{return new OrderAsserter($order); }fonction publique wasCreatedAt(DateTimeImmutable $createdAt) : self{ Assert::assertEquals($createdAt, $this->order->createdAt);return $this; }fonction publique hasTotal(int $total) : self{ Assert::assertSame($total, $this->order->getTotal());return $this; } }
Le test paramétré est une bonne option pour tester le SUT avec de nombreux paramètres sans répéter le code.
Avertissement
Ce genre de test est moins lisible. Pour augmenter un peu la lisibilité, les exemples négatifs et positifs doivent être répartis en différents tests.
la classe finale SampleTest étend TestCase {/** * @test * @dataProvider getInvalidEmails */public function detectors_an_invalid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($email);self::assertFalse( $résultat); }/** * @test * @dataProvider getValidEmails */public function detectors_an_valid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($email);self::assertTrue( $résultat); }public function getInvalidEmails(): iterable{yield 'Un email invalide sans @' => ['test'];yield 'Un email invalide sans le domaine après @' => ['test@'];yield 'Un email invalide sans TLD' => ['test@test'];//...}public function getValidEmails(): iterable{yield 'Un email valide avec des lettres minuscules' => ['[email protected]'];yield 'Un email valide avec des lettres minuscules et des chiffres' => ['[email protected]'];yield 'Un email valide avec des lettres et des chiffres majuscules' => ['Test123@ test.com'];//...} }
Note
Utilisez yield
et ajoutez une description textuelle aux cas pour améliorer la lisibilité.
L'unité est une unité unique de comportement, il peut s'agir de plusieurs classes liées.
Chaque test doit être isolé des autres. Il doit donc être possible de les invoquer en parallèle ou dans n'importe quel ordre.
la classe finale TestExample étend TestCase {/** * @test */public function suspending_an_subscription_with_can_always_suspend_policy_is_always_possible() : void{$canAlwaysSuspendPolicy = new CanAlwaysSuspendPolicy();$sut = new Subscription();$result = $sut->suspend($canAlwaysSuspendPolicy);self::assertTrue($result);self::assertSame(Status::suspend(), $sut->status()); } }
L'unité est une classe unique.
L'unité doit être isolée de tous les collaborateurs.
la classe finale TestExample étend TestCase {/** * @test */public function suspending_an_subscription_with_can_always_suspend_policy_is_always_possible() : void{$canAlwaysSuspendPolicy = $this->createStub(SusendingPolicyInterface::class);$canAlwaysSuspendPolicy->method('suspend')->willReturn(true);$ sut = nouvel abonnement ();$result = $sut->suspend($canAlwaysSuspendPolicy);self::assertTrue($result);self::assertSame(Status::suspend(), $sut->status()); } }
Note
L'approche classique est préférable pour éviter les tests fragiles.
[FAIRE]
Exemple:
Classe finale NotificationService {public function __construct(private readonly MailerInterface $mailer,private readonly MessageRepositoryInterface $messageRepository) {}public function send(): void{$messages = $this->messageRepository->getAll();foreach ($messages as $message) { $this->mailer->send($message); } } }
[!AVERTISSEMENT|style:plat|étiquette:MAUVAIS]
L'affirmation d'interactions avec des stubs conduit à des tests fragiles
la classe finale TestExample étend TestCase {/** * @test */public function sends_all_notifications() : void{$message1 = new Message();$message2 = new Message();$messageRepository = $this->createMock(MessageRepositoryInterface::class);$messageRepository ->method('getAll')->willReturn([$message1, $message2]);$mailer = $this->createMock(MailerInterface::class);$sut = new NotificationService($mailer, $messageRepository);$messageRepository->expects(self::once())->method('getAll');$mailer- >attend(self::exactly(2))->method('send') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);$sut->send(); } }
[!TIP|style:flat|étiquette:BON]
la classe finale TestExample étend TestCase {/** * @test */public function sends_all_notifications() : void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($message2);$mailer = $this->createMock(MailerInterface::class);$sut = new NotificationService($mailer, $messageRepository);// Suppression des interactions d'assertion avec le stub$mailer->expects(self::exactly(2))->method('send') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);$sut->send(); } }
[!TIP|style:flat|label:ENCORE MIEUX UTILISER SPY]
la classe finale TestExample étend TestCase {/** * @test */public function sends_all_notifications() : void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($message2);$mailer = nouveau SpyMailer();$sut = nouveau NotificationService($mailer, $messageRepository);$sut->send(); $mailer->assertThatMessagesHaveBeenSent([$message1, $message2]); } }
[!TIP|style:flat|label:La meilleure option]
La meilleure résistance au refactoring
La meilleure précision
Le coût de maintenabilité le plus bas
Si c'est possible, préférez ce genre de test
la classe finale SampleTest étend TestCase {/** * @test * @dataProvider getInvalidEmails */public function detectors_an_invalid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($email);self::assertFalse( $résultat); }/** * @test * @dataProvider getValidEmails */public function detectors_an_valid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($email);self::assertTrue( $résultat); }fonction publique getInvalidEmails() : array{return [ ['test'], ['test@'], ['test@test'],//...]; }fonction publique getValidEmails() : array{return [ ['[email protected]'], ['[email protected]'], ['[email protected]'],//...]; } }
[!WARNING|style:flat|label:Pire option]
Pire résistance au refactoring
Pire précision
Coût de maintenabilité plus élevé
la classe finale SampleTest étend TestCase {/** * @test */public function add_an_item_to_cart() : void{$item = new CartItem('Product');$sut = new Cart();$sut->addItem($item);self::assertSame (1, $sut->getCount());self::assertSame($item, $sut->getItems()[0]); } }
[!ATTENTION|style:flat|label:La pire option]
La pire résistance au refactoring
La pire précision
Le coût de maintenabilité le plus élevé
la classe finale SampleTest étend TestCase {/** * @test */public function sends_all_notifications() : void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($message2);$mailer = $this->createMock(MailerInterface::class);$sut = nouveau NotificationService ($mailer, $messageRepository);$mailer->expects(self::exactly(2))->method('send') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);$sut->send(); } }
[!AVERTISSEMENT|style:plat|étiquette:MAUVAIS]
classe finale NameService {fonction publique __construct(cacheStorageInterface privée en lecture seule $cacheStorage) {}fonction publique loadAll() : void{$namesCsv = array_map('str_getcsv', file(__DIR__.'/../names.csv'));$names = [ ];foreach ($namesCsv comme $nameData) {if (!isset($nameData[0], $nameData[1])) {continuer ; }$names[] = nouveau Nom($nameData[0], nouveau Sexe($nameData[1])); }$this->cacheStorage->store('names', $names); } }
Comment tester un code comme celui-ci ? Cela n'est possible qu'avec un test d'intégration car il utilise directement un code d'infrastructure lié à un système de fichiers.
[!TIP|style:flat|étiquette:BON]
Comme dans l’architecture fonctionnelle, nous devons séparer un code avec des effets secondaires et un code qui ne contient que de la logique.
classe finale NameParser {/** * @param array<string[]> $namesData * @return Name[] */public function parse(array $namesData): array{$names = [];foreach ($namesData as $nameData) {if (!isset($nameData[0], $nameData[1])) {continuer ; }$names[] = nouveau Nom($nameData[0], nouveau Sexe($nameData[1])); }retourne $noms ; } }
classe finale CsvNamesFileLoader {public function load() : array{return array_map('str_getcsv', file(__DIR__.'/../names.csv')); } }
Classe finale ApplicationService {fonction publique __construct (privé en lecture seule CsvNamesFileLoader $fileLoader, privé en lecture seule NameParser $parser, privé en lecture seule CacheStorageInterface $cacheStorage) {}fonction publique loadNames() : void{$namesData = $this->fileLoader->load();$names = $this->parser->parse($namesData);$this->cacheStorage->store('names', $names); } }
la classe finale ValidUnitExampleTest étend TestCase {/** * @test */public function parse_all_names() : void{$namesData = [ ['Jean', 'M'], ['Lennon', 'U'], ['Sarah', 'W'] ];$sut = new NameParser();$result = $sut->parse($namesData); self::assertSame( [nouveau nom('John', nouveau sexe('M')),nouveau nom('Lennon', nouveau sexe('U')),nouveau nom('Sarah', nouveau sexe('W')) ],$résultat); } }
[!AVERTISSEMENT|style:plat|étiquette:MAUVAIS]
Classe finale ApplicationService {fonction publique __construct (privé en lecture seule SubscriptionRepositoryInterface $subscriptionRepository) {}fonction publique renouvelerSubscription(int $subscriptionId): bool{$subscription = $this->subscriptionRepository->findById($subscriptionId);if (!$subscription->getStatus() ->isEqual(Status::expired())) {return false; }$abonnement->setStatus(Status::active());$abonnement->setModifiedAt(new DateTimeImmutable());return true; } }
Abonnement cours final {fonction publique __construct(private Status $status, private DateTimeImmutable $modifiedAt) {}fonction publique getStatus() : Status{return $this->status ; }fonction publique setStatus(Status $status) : void{$this->status = $status; }fonction publique getModifiedAt() : DateTimeImmutable{return $this->modifiedAt ; }fonction publique setModifiedAt(DateTimeImmutable $modifiedAt) : void{$this->modifiedAt = $modifiedAt; } }
la classe finale InvalidTestExample étend TestCase {/** * @test */public function renouveler_an_expired_subscription_is_possible() : void{$modifiedAt = new DateTimeImmutable();$expiredSubscription = new Subscription(Status::expired(), $modifiedAt);$sut = new ApplicationService($this ->createRepository($expiredSubscription));$result = $sut->renewSubscription(1);self::assertSame(Status::active(), $expiredSubscription->getStatus());self::assertGreaterThan($modifiedAt, $expiredSubscription->getModifiedAt());self:: assertTrue ($ résultat); }/** * @test */public function renouveler_an_active_subscription_is_not_possible() : void{$modifiedAt = new DateTimeImmutable();$activeSubscription = new Subscription(Status::active(), $modifiedAt);$sut = new ApplicationService($this ->createRepository($activeSubscription));$result = $sut->renewSubscription(1);self::assertSame($modifiedAt, $activeSubscription->getModifiedAt());self::assertFalse($result); } fonction privée createRepository(Subscription $subscription) : SubscriptionRepositoryInterface{retourne une nouvelle classe ($expiredSubscription) implémente SubscriptionRepositoryInterface {fonction publique __construct(private readonly Subscription $subscription) {} fonction publique findById(int $id) : Abonnement{return $this->subscription ; } } ; } }
[!TIP|style:flat|étiquette:BON]
Classe finale ApplicationService {fonction publique __construct (privé en lecture seule SubscriptionRepositoryInterface $subscriptionRepository) {}fonction publique renouvelerSubscription(int $subscriptionId): bool{$subscription = $this->subscriptionRepository->findById($subscriptionId);return $subscription->renew(new DateTimeImmutable( )); } }
Abonnement cours final {statut privé $status;private DateTimeImmutable $modifiedAt;fonction publique __construct(DateTimeImmutable $modifiedAt) {$this->status = Statut::new();$this->modifiedAt = $modifiedAt; }fonction publique renouveler(DateTimeImmutable $modifiedAt) : bool{if (!$this->status->isEqual(Status::expired())) {return false; }$this->status = Status::active();$this->modifiedAt = $modifiedAt;return true; }fonction publique active(DateTimeImmutable $modifiedAt) : void{//simplified$this->status = Status::active();$this->modifiedAt = $modifiedAt; }fonction publique expire(DateTimeImmutable $modifiedAt) : void{//simplified$this->status = Status::expired();$this->modifiedAt = $modifiedAt; }fonction publique isActive() : bool{return $this->status->isEqual(Status::active()); } }
la classe finale ValidTestExample étend TestCase {/** * @test */public function renouveler_an_expired_subscription_is_possible() : void{$expiredSubscription = SubscriptionMother::expired();$sut = new ApplicationService($this->createRepository($expiredSubscription));$result = $sut- >renewSubscription(1);// ignorer la vérification de selectedAt car cela ne fait pas partie du comportement observable. Pour vérifier cette valeur, nous devrons ajouter un getter pour approvedAt, probablement uniquement à des fins de test.self::assertTrue($expiredSubscription->isActive());self::assertTrue($result); }/** * @test */public function renouveler_an_active_subscription_is_not_possible() : void{$activeSubscription = SubscriptionMother::active();$sut = new ApplicationService($this->createRepository($activeSubscription));$result = $sut->renewSubscription(1);self::assertTrue($activeSubscription->isActive());self::assertFalse($result); } fonction privée createRepository(Subscription $subscription) : SubscriptionRepositoryInterface{retourne une nouvelle classe ($expiredSubscription) implémente SubscriptionRepositoryInterface {fonction publique __construct(private readonly Subscription $subscription) {} fonction publique findById(int $id) : Abonnement{return $this->subscription ; } } ; } }
Note
Le premier modèle d’abonnement a une mauvaise conception. Pour appeler une opération commerciale, vous devez appeler trois méthodes. Utiliser également des getters pour vérifier le fonctionnement n’est pas une bonne pratique. Dans ce cas, la vérification d'un changement de modifiedAt
est ignorée. La définition d' modifiedAt
spécifique lors d'une opération de renouvellement peut probablement être testée avec une opération commerciale d'expiration. Le getter pour modifiedAt
n’est pas requis. Bien sûr, il y a des cas où il sera très difficile de trouver la possibilité d'éviter les getters fournis uniquement pour les tests, mais nous devons toujours essayer de ne pas les introduire.
[!AVERTISSEMENT|style:plat|étiquette:MAUVAIS]
la classe CannotSuspendExpiredSubscriptionPolicy implémente SuspendingPolicyInterface {public function suspend(Subscription $subscription, DateTimeImmutable $at) : bool{if ($subscription->isExpired()) {return false ; }retourne vrai ; } }
la classe CannotSuspendExpiredSubscriptionPolicyTest étend TestCase {/** * @test */public function it_returns_false_when_a_subscription_is_expired() : void{$policy = new CannotSuspendExpiredSubscriptionPolicy();$subscription = $this->createStub(Subscription::class);$subscription->method('isExpired')->willReturn(true);self::assertFalse($policy->suspend($subscription, new DateTimeImmutable())); }/** * @test */public function it_returns_true_when_a_subscription_is_not_expired() : void{$policy = new CannotSuspendExpiredSubscriptionPolicy();$subscription = $this->createStub(Subscription::class);$subscription->method('isExpired')->willReturn(false);self::assertTrue($policy->suspend($subscription, new DateTimeImmutable())); } }
la classe CannotSuspendNewSubscriptionPolicy implémente SuspendingPolicyInterface {public function suspend(Subscription $subscription, DateTimeImmutable $at) : bool{if ($subscription->isNew()) {return false ; }retourne vrai ; } }
la classe CannotSuspendNewSubscriptionPolicyTest étend TestCase {/** * @test */public function it_returns_false_when_a_subscription_is_new() : void{$policy = new CannotSuspendNewSubscriptionPolicy();$subscription = $this->createStub(Subscription::class);$subscription->method('isNew') ->willReturn(true);self::assertFalse($policy->suspend($abonnement, new DateTimeImmutable())); }/** * @test */public function it_returns_true_when_a_subscription_is_not_new() : void{$policy = new CannotSuspendNewSubscriptionPolicy();$subscription = $this->createStub(Subscription::class);$subscription->method('isNew')->willReturn(false);self::assertTrue($policy->suspend($subscription, new DateTimeImmutable())); } }
la classe CanSuspendAfterOneMonthPolicy implémente SuspendingPolicyInterface {public function suspend(Subscription $subscription, DateTimeImmutable $at) : bool{$oneMonthEarlierDate = DateTime::createFromImmutable($at)->sub(new DateInterval('P1M'));return $subscription->isOlderThan(DateTimeImmutable :: createFromMutable($oneMonthEarlierDate)); } }
la classe CanSuspendAfterOneMonthPolicyTest étend TestCase {/** * @test */public function it_returns_true_when_a_subscription_is_older_than_one_month() : void{$date = new DateTimeImmutable('2021-01-29');$policy = new CanSuspendAfterOneMonthPolicy();$subscription = new Abonnement(new DateTimeImmutable('2020-12-28'));self::assertTrue($policy->suspend($abonnement, $date)); }/** * @test */public function it_returns_false_when_a_subscription_is_not_older_than_one_month() : void{$date = new DateTimeImmutable('2021-01-29');$policy = new CanSuspendAfterOneMonthPolicy();$subscription = new Abonnement(new DateTimeImmutable('2020-01-01'));self::assertTrue($policy->suspend($abonnement, $date)); } }
Statut de classe {const privé EXPIRED = 'expiré'; const privé ACTIVE = 'actif'; const privé NEW = 'nouveau'; const privé SUSPENDED = 'suspendu'; fonction privée __construct (chaîne privée en lecture seule $status) {$this->statut = $statut ; }fonction statique publique expirée() : self{return new self(self::EXPIRED); }fonction statique publique active() : self{return new self(self::ACTIVE); }fonction statique publique new() : self{return new self(self::NEW); }fonction statique publique suspendue() : self{return new self(self::SUSPENDED); }fonction publique isEqual(self $status) : bool{return $this->status === $status->status; } }
la classe StatusTest étend TestCase {fonction publique testEquals() : void{$status1 = Status::active();$status2 = Status::active();self::assertTrue($status1->isEqual($status2)); }fonction publique testNotEquals() : void{$status1 = Status::active();$status2 = Status::expired();self::assertFalse($status1->isEqual($status2)); } }
la classe SubscriptionTest étend TestCase {/** * @test */public function suspending_a_subscription_is_possible_when_a_policy_returns_true() : void{$policy = $this->createMock(SusendingPolicyInterface::class);$policy->expects($this->once())->method( 'suspend') ->willReturn (true);$sut = nouvel abonnement (nouveau DateTimeImmutable());$result = $sut->suspend($policy, new DateTimeImmutable());self::assertTrue($result);self::assertTrue($sut->isSuspended()); }/** * @test */public function suspending_a_subscription_is_not_possible_when_a_policy_returns_false() : void{$policy = $this->createMock(SusendingPolicyInterface::class);$policy->expects($this->once())->method( 'suspend') ->willReturn (false);$sut = nouvel abonnement (nouveau DateTimeImmutable());$result = $sut->suspend($policy, new DateTimeImmutable());self::assertFalse($result);self::assertFalse($sut->isSuspended()); }/** * @test */public function it_returns_true_when_a_subscription_is_older_than_one_month() : void{$date = new DateTimeImmutable();$futureDate = $date->add(new DateInterval('P1M'));$sut = nouvel abonnement($ date);self::assertTrue($sut->isOlderThan($futureDate)); }/** * @test */public function it_returns_false_when_a_subscription_is_not_older_than_one_month() : void{$date = new DateTimeImmutable();$futureDate = $date->add(new DateInterval('P1D'));$sut = new Subscription($ date);self::assertTrue($sut->isOlderThan($futureDate)); } }
[!ATTENTION] N'écrivez pas de code 1:1, 1 classe : 1 test. Cela conduit à des tests fragiles qui rendent le refactoring difficile.
[!TIP|style:flat|étiquette:BON]
la classe finale CannotSuspendExpiredSubscriptionPolicy implémente SuspendingPolicyInterface {public function suspend(Subscription $subscription, DateTimeImmutable $at) : bool{if ($subscription->isExpired()) {return false ; }retourne vrai ; } }
la classe finale CannotSuspendNewSubscriptionPolicy implémente SuspendingPolicyInterface {public function suspend(Subscription $subscription, DateTimeImmutable $at) : bool{if ($subscription->isNew()) {return false ; }retourne vrai ; } }
la classe finale CanSuspendAfterOneMonthPolicy implémente SuspendingPolicyInterface {public function suspend(Subscription $subscription, DateTimeImmutable $at) : bool{$oneMonthEarlierDate = DateTime::createFromImmutable($at)->sub(new DateInterval('P1M'));return $subscription->isOlderThan(DateTimeImmutable :: createFromMutable($oneMonthEarlierDate)); } }
Statut du cours final {const privé EXPIRED = 'expiré'; const privé ACTIVE = 'actif'; const privé NEW = 'nouveau'; const privé SUSPENDED = 'suspendu'; fonction privée __construct (chaîne privée en lecture seule $status) {$this->statut = $statut ; }fonction statique publique expirée() : self{return new self(self::EXPIRED); }fonction statique publique active() : self{return new self(self::ACTIVE); }fonction statique publique new() : self{return new self(self::NEW); }fonction statique publique suspendue() : self{return new self(self::SUSPENDED); }fonction publique isEqual(self $status) : bool{return $this->status === $status->status; } }
Abonnement cours final {statut privé $status;private DateTimeImmutable $createdAt;fonction publique __construct(DateTimeImmutable $createdAt) {$this->status = Statut::new();$this->createdAt = $createdAt; }fonction publique suspend(SusendingPolicyInterface $susendingPolicy, DateTimeImmutable $at) : bool{$result = $susendingPolicy->suspend($this, $at);if ($result) {$this->status = Status::suspended() ; }retourne $résultat ; }fonction publique isOlderThan(DateTimeImmutable $date) : bool{return $this->createdAt < $date; }fonction publique activate() : void{$this->status = Status::active(); }fonction publique expire() : void{$this->status = Status::expired(); }fonction publique isExpired() : bool{return $this->status->isEqual(Status::expired()); }fonction publique isActive() : bool{return $this->status->isEqual(Status::active()); }fonction publique isNew() : bool{return $this->status->isEqual(Status::new()); }fonction publique isSuspended() : bool{return $this->status->isEqual(Status::suspended()); } }
la classe finale SubscriptionSusendingTest étend TestCase {/** * @test */public function suspending_an_expired_subscription_with_cannot_suspend_expired_policy_is_not_possible() : void{$sut = new Subscription(new DateTimeImmutable());$sut->activate();$sut->expire();$result = $sut ->suspend (nouveau CannotSuspendExpiredSubscriptionPolicy(), nouveau DateTimeImmutable());self::assertFalse($result); }/** * @test */public function suspending_a_new_subscription_with_cannot_suspend_new_policy_is_not_possible() : void{$sut = new Subscription(new DateTimeImmutable());$result = $sut->suspend(new CannotSuspendNewSubscriptionPolicy(), new DateTimeImmutable());self ::assertFalse($result); }/** * @test */public function suspending_an_active_subscription_with_cannot_suspend_new_policy_is_possible() : void{$sut = new Subscription(new DateTimeImmutable());$sut->activate();$result = $sut->suspend(new CannotSuspendNewSubscriptionPolicy() , new DateTimeImmutable());self::assertTrue($result); }/** * @test */public function suspending_an_active_subscription_with_cannot_suspend_expired_policy_is_possible() : void{$sut = new Subscription(new DateTimeImmutable());$sut->activate();$result = $sut->suspend(new CannotSuspendExpiredSubscriptionPolicy() , nouveau DateTimeImmutable());self::assertTrue($result); }/** * @test */public function suspending_an_subscription_before_a_one_month_is_not_possible() : void{$sut = new Subscription(new DateTimeImmutable('2020-01-01'));$result = $sut->suspend(new CanSuspendAfterOneMonthPolicy(), nouveau DateTimeImmutable('2020-01-10'));self::assertFalse($result); }/** * @test */public function suspending_an_subscription_after_a_one_month_is_possible() : void{$sut = new Subscription(new DateTimeImmutable('2020-01-01'));$result = $sut->suspend(new CanSuspendAfterOneMonthPolicy(), nouveau DateTimeImmutable('2020-02-02'));self::assertTrue($result); } }
Comment tester correctement unitairement une classe comme celle-ci ?
classeApplicationService {public function __construct(private readonly OrderRepository $orderRepository,private readonly FormRepository $formRepository) {}public function changeFormStatus(int $orderId): void{$order = $this->orderRepository->getById($orderId);$soapResponse = $ this->getSoapClient()->getStatusByOrderId($orderId);$form = $this->formRepository->getByOrderId($orderId);$form->setStatus($soapResponse['status']);$form->setModifiedAt(new DateTimeImmutable());if ($soapResponse['status'] = == 'accepté') {$order->setStatus('paid'); }$this->formRepository->save($form);$this->orderRepository->save($order); }fonction privée getSoapClient() : SoapClient{return new SoapClient('https://legacy_system.pl/Soap/WebService', []); } }
[!TIP|style:flat|étiquette:BON]
Il est nécessaire de diviser un code trop compliqué en classes séparées.
Classe finale ApplicationService {fonction publique __construct (privé en lecture seule OrderRepositoryInterface $orderRepository, privé en lecture seule FormRepositoryInterface $formRepository, privé en lecture seule FormApiInterface $formApi, privé en lecture seule ChangeFormStatusService $changeFormStatusService) {}fonction publique changeFormStatus(int $orderId): void{$order = $this->orderRepository ->getById($orderId);$form = $this->formRepository->getByOrderId($orderId);$status = $this->formApi->getStatusByOrderId($orderId);$this->changeFormStatusService->changeStatus($order, $form, $status);$this ->formRepository->save($form);$this->orderRepository->save($order); } }
classe finale ChangeFormStatusService {public function changeStatus(Order $order, Form $form, string $formStatus) : void{$status = FormStatus::createFromString($formStatus);$form->changeStatus($status);if ($form->isAccepted( )) {$order->changeStatus(OrderStatus::paid()); } } }
la classe finale ChangingFormStatusTest étend TestCase {/** * @test */public function changesing_a_form_status_to_accepted_changes_an_order_status_to_paid() : void{$order = new Order();$form = new Form();$status = 'accepté';$sut = new ChangeFormStatusService();$sut ->changeStatus($commande, $form, $status);self::assertTrue($form->isAccepted());self::assertTrue($order->isPaid()); }/** * @test */public function changesing_a_form_status_to_refused_not_changes_an_order_status() : void{$order = new Order();$form = new Form();$status = 'new';$sut = new ChangeFormStatusService();$sut ->changeStatus($commande, $form, $status);self::assertFalse($form->isAccepted());self::assertFalse($order->isPaid()); } }
Cependant, ApplicationService devrait probablement être testé par un test d'intégration avec uniquement FormApiInterface simulé.
[!AVERTISSEMENT|style:plat|étiquette:MAUVAIS]
Client de dernière classe {fonction publique __construct(chaîne privée $nom) {}fonction publique getName() : string{return $this->name ; }fonction publique setName(string $name) : void{$this->name = $name; } }
la classe finale CustomerTest étend TestCase {public function testSetName() : void{$customer = new Customer('Jack');$customer->setName('John');self::assertSame('John', $customer->getName()); } }
Abonné à l'événement de la classe finale {fonction statique publique getSubscribeEvents() : array{return ['event' => 'onEvent']; }fonction publique onEvent() : void{ } }
la classe finale EventSubscriberTest étend TestCase {fonction publique testGetSubscribeEvents() : void{$result = EventSubscriber::getSubscribeEvents();self::assertSame(['event' => 'onEvent'], $result); } }
[!ATTENTION] Tester le code sans aucune logique compliquée n’a aucun sens, mais conduit également à des tests fragiles.
[!AVERTISSEMENT|style:plat|étiquette:MAUVAIS]
Classe finale UserRepository {fonction publique __construct (connexion privée en lecture seule $ connexion) {} fonction publique getUserNameByEmail (string $ email): ?array {return $this->connection->createQueryBuilder() ->from('utilisateur', 'u') ->où('u.email = :email') ->setParameter('email', $email) ->exécuter() ->récupérer(); } }
la classe finale TestUserRepository étend TestCase {public function testGetUserNameByEmail() : void{$email = '[email protected]';$connection = $this->createMock(Connection::class);$queryBuilder = $this->createMock(QueryBuilder::class); $result = $this->createMock(ResultStatement::class);$userRepository = nouveau UserRepository($connection);$connection->expects($this->once()) ->méthode('createQueryBuilder') ->willReturn($queryBuilder);$queryBuilder->expects($this->once()) ->méthode('de') ->avec('utilisateur', 'u') ->willReturn($queryBuilder);$queryBuilder->expects($this->once()) ->méthode('où') ->avec('u.email = :email') ->willReturn($queryBuilder);$queryBuilder->expects($this->once()) ->méthode('setParameter') ->avec('email', $email) -> WillReturn ($ queryBuilder); $ queryBuilder-> attend ($ this-> une fois ()) -> Méthode ('Execute') -> WillReturn ($ result); $ result-> attend ($ this-> une fois ()) -> Méthode ('fetch') -> WillReturn (['e-mail' => $ e-mail]); $ result = $ userRepository-> getUserNameByEmail ($ e-mail); self :: AssertSame (['e-mail' => $ email], $ result); } }
[!ATTENTION] Tester les référentiels de cette manière conduit à des tests fragiles et la refactorisation est alors difficile. Pour tester les référentiels, écrivez des tests d'intégration.
[!TIP|style:flat|étiquette:BON]
La classe finale Goodtest étend le testcase {private abonnementfactory $ sut; public function setup (): void {$ this-> sut = new abonnementfactory (); } / ** * @Test * / public Fonction Creates_A_SubScription_For_A_Given_Date_Range (): void {$ result = $ this-> sut-> create (new DateTimeImmutable (), new DateTimeImtable ('Now +1 Year')); self :: assertanceofs (Abonnement :: Classe, $ Result); } / ** * @test * / fonction publique throws_an_exception_on_invalid_date_range (): void {$ this-> attendException (createSubScriptionException :: class); $ result = $ this-> sut-> create (new DateTimeMutable ('maintenant -1 an'), new DateTimeMutable ()); } }
Note
Le meilleur cas d’utilisation de la méthode setUp consistera à tester des objets sans état.
Toute configuration effectuée dans setUp
couple les tests ensemble et a un impact sur tous les tests.
Il est préférable d'éviter un état partagé entre les tests et de configurer l'état initial en fonction de la méthode de test.
La lisibilité est pire par rapport à la configuration effectuée selon la méthode de test appropriée.
[!TIP|style:flat|étiquette:MEILLEUR]
La classe finale Bettertest étend le testcase {/ ** * @Test * / Fonction publique Suspende_an_Active_SubScription_With_Cannot_SuspenSh_New_Policy_is_Possble (): Void {$ SUT = $ This-> CreateanActiveSubScription (); $ result = $ SUT-> SUSPEND (new CannotSpenSewSubScript : AssertTrue ($ Result); } / ** * @test * / fonction publique suspendu_an_active_subscription_with_cannot_uspennd_expired_policy_is_posible (): void {$ sut = $ this-> createAr : AssertTrue ($ Result); } / ** * @test * / fonction publique suspendu_a_new_subscription_with_cannot_uspennd_new_policy_is_not_posible (): void {$ sut = $ this-> createenewSubscription (); $ result = $ sut-> suspendre (new cannotuspendewsubsubs : ASSERTFALSE ($ Result); } fonction privée createEnewSubScription (): abonnement {return nouvel abonnement (new DateTimeMutable ()); } Fonction privée CreateArActiveSubscription (): abonnement {$ abonnement = nouveau abonnement (new DateTimeMutable ()); $ abonnement-> activate (); Retour Abonnement $; } }
Note
Cette approche améliore la lisibilité et clarifie la séparation (le code est plus lu qu'écrit).
Les aides privées peuvent être fastidieuses à utiliser dans chaque méthode de test, même si elles fournissent des intentions explicites.
Pour partager des objets de test similaires entre plusieurs classes de test, utilisez :
Objet mère
Constructeur
[!AVERTISSEMENT|style:plat|étiquette:MAUVAIS]
Client de classe finale {Private CustomerType $ Type; private recountcalcalculationPolicyInterface $ DiscountCalculationPolicy; Fonction publique __Construct () {$ this-> type = CustomerType :: Normal (); $ this-> recountcalculationPolicy = new NormalDiscountPolicy (); } fonction publique makevip (): void {$ this-> type = clientType :: vip (); $ this-> recountcalculationPolicy = new vipDiscountPolicy (); } fonction publique getCustomerType (): CustomerType {return $ this-> type; } Fonction publique GetperCentagediscount (): int {return $ this-> recountcalculationPolicy-> getPecentageDiscount (); } }
La classe finale invalidtest étend le testcase {Fonction publique TestMakevip (): void {$ sut = new Customer (); $ sut-> makevip (); self :: assertSame (CustomerType :: vip (), $ sut-> getCustomerType ()); } }
[!TIP|style:flat|étiquette:BON]
Client de classe finale {Private CustomerType $ Type; private recountcalcalculationPolicyInterface $ DiscountCalculationPolicy; Fonction publique __Construct () {$ this-> type = CustomerType :: Normal (); $ this-> recountcalculationPolicy = new NormalDiscountPolicy (); } fonction publique makevip (): void {$ this-> type = clientType :: vip (); $ this-> recountcalculationPolicy = new vipDiscountPolicy (); } Fonction publique GetperCentagediscount (): int {return $ this-> recountcalculationPolicy-> getPecentageDiscount (); } }
La classe finale Validtest étend le testcase {/ ** * @Test * / public Fonction a_vip_customer_has_a_25_percentage_discount (): void {$ sut = new client (); $ sut-> makevip (); self :: ASSERTSAME (25, $ sut-> getPeperagediscount ()); } }
[!ATTENTION] L’ajout de code de production supplémentaire (par exemple getter getCustomerType()) uniquement pour vérifier l’état dans les tests est une mauvaise pratique. Il doit être vérifié par une autre valeur significative de domaine (dans ce cas getPercentageDiscount()). Bien sûr, il peut parfois être difficile de trouver un autre moyen de vérifier le fonctionnement, et nous pouvons être obligés d'ajouter du code de production supplémentaire pour vérifier l'exactitude des tests, mais nous devrions essayer d'éviter cela.
Classe finale Discountcalculator {Fonction publique Calcule (int $ isvipfromyears): int { Assert :: GreaterThaneq ($ isvipfromyears, 0); retourne min (($ isvipfromyears * 10) + 3, 80); } }
[!AVERTISSEMENT|style:plat|étiquette:MAUVAIS]
La classe finale invalidtest étend le testcase ze ); } Fonction publique DiscountDataprovider (): Array {return [ [0, 0 * 10 + 3], // Détails du domaine qui fuient [1, 1 * 10 + 3], [5, 5 * 10 + 3], [8, 80] ]; } }
[!TIP|style:flat|étiquette:BON]
La classe finale Validtest étend le testcase ze ); } Fonction publique DiscountDataprovider (): Array {return [ [0, 3], [1, 13], [5, 53], [8, 80] ]; } }
Note
Ne dupliquez pas la logique de production dans les tests. Vérifiez simplement les résultats par des valeurs codées en dur.
[!AVERTISSEMENT|style:plat|étiquette:MAUVAIS]
Classe Discountcalculator {Fonction publique CalculateInternaldiscount (int $ isvipfromyears): int { Assert :: GreaterThaneq ($ isvipfromyears, 0); retourne min (($ isvipfromyears * 10) + 3, 80); } Fonction publique CalculateAdDitionaldiscountFromExternalSystem (): int {// Obtenez des données d'un système externe pour calculer un discountreturn 5; } }
Commandon de classe {Fonction publique __Construct (private ReadOnly DiscountCalculator $ DiscountCalculator) {} Fonction publique GetTotalpriceWithDiscount (int $ totalPrice, int $ vipFromdays): int {$ interaldiscount = $ this-> dowtcalcalculater-> calculInternaldScount ($ vipfromdays); $ externaldaldiscount = $ this-> DiscountCalculator-> CalculateAdDitionaldiscountFromExternalSystem (); $ recounsum = $ interaldiscount + $ externaldiscount; return $ totalprice - (int) ceil (($ totalprice * $ recounsum) / 100); } }
La classe finale invalidtest étend le testcase {/ ** * @Dataprovider OrderDataprovider * / Fonction publique TestTetTotalPriceWithDiscount (int $ totalPrice, int $ vipdaysfrom, int $ attendu): void {$ discoulcalcalculater = $ this-> createpartialMock (DowtcalCulator: Class, class, ['CalculateAdDitionaldiscountFromexternalSystem']); $ discountcalculator-> Method ('CalculateAdDitionalSccountFromExternalSystem') -> WillReturn (5); $ sut = new commandervice ($ recountcalCulator); self :: assertsame ($ attend , $ vipdaysfrom)); } Fonction publique OrderDataprovider (): Array {return [ [1000, 0, 920], [500, 1, 410], [644, 5, 270], ]; } }
[!TIP|style:flat|étiquette:BON]
interface externaldiscountcalculatorInterface {Fonction publique Calcule (): int; }
classe finale InternalScountcalculator {Fonction publique Calcule (int $ isvipfromyears): int { Assert :: GreaterThaneq ($ isvipfromyears, 0); retourne min (($ isvipfromyears * 10) + 3, 80); } }
Commandage de classe finale {Fonction publique __CONSTRUCT (Private ReadOnly InternalSccountcalCulator $ DiscountCalculator, Private ReadOnly ExternalSccountcalCulatorInterface $ externaldalScountcalCulator) {} Fonction publique GetTotalPriceWithDiscount (int $ totalPrice, int $ vipfromdays): int {$ interalscount = $ this-> dowtcalculator-> calcul ($ vipfrom $ externaldiscount = $ this-> externaldiscountcalculator-> calcul (); $ recounsum = $ interaldiscount + $ externaldiscount; return $ totalprice - (int) plail (($ totalPrice * $ recounsum) / 100); } }
La classe finale Validtest étend le testcase ? } }; $ sut = new OrderService (new InternalDiscountcalCulator (), $ externaldalScountCalculator); self :: AssertSame ($ attendu, $ sut-> getTotalprice withDiscount ($ totalprice, $ vipdaysfrom)); } Fonction publique OrderDataprovider (): Array {return [ [1000, 0, 920], [500, 1, 410], [644, 5, 270], ]; } }
Note
La nécessité de se moquer d'une classe concrète pour remplacer une partie de son comportement signifie que cette classe est probablement trop compliquée et viole le principe de responsabilité unique.
OrderItem de classe finale {Fonction publique __construct (public ReadOnly int $ total) {} }
Ordre de classe finale {/ ** * @param orderItem [] $ items * @param int $ TransportCost * / public function __construct (Private Array $ items, private int $ TransportCost) {} public function getTotal (): int {return $ this-> getItemStotal () + $ this-> TransportCost; } fonction privée getItemStotal (): int {return array_reduce (array_map (fn (ordonnance $ item) => $ item-> total, $ this-> items), fn (int $ sum, int $ total) => $ sum + = $ total, 0); } }
[!AVERTISSEMENT|style:plat|étiquette:MAUVAIS]
La classe finale invalidtest étend le testcase {/ ** * @Test * @dataprovider OrdersDataprovider * / Public Fonction get_total_returns_a_total_cost_of_a_whole_order (ordre $ ordonnance, int $ attendTotal): void {self :: Assertsame ($ attendTotal, $ order-> getTotal ()); } / ** * @test * @dataprovider OrderItemsDataprovider * / public Fonction get_items_total_returns_a_total_cost_of_all_items (ordre $ ordonnance, int $ } la fonction publique ordersDataprovider (): array {return [ [New Order ([New OrderItem (20), New OrderItem (20), New OrderItem (20)], 15), 75], [New Order ([New OrderItem (20), New OrderItem (30), New OrderItem (40)], 0), 90], [New Order ([New OrderItem (99), New OrderItem (99), New OrderItem (99)], 9), 306] ]; } Fonction publique ordonnierItemsDataprovider (): array {return [ [New Order ([New OrderItem (20), New OrderItem (20), New OrderItem (20)], 15), 60], [New Order ([New OrderItem (20), New OrderItem (30), New OrderItem (40)], 0), 90], [New Order ([New OrderItem (99), New OrderItem (99), New OrderItem (99)], 9), 297] ]; } fonction privée invokePrivateMethodGetItemStotal (Order & $ order): int {$ réflexion = new ReflectionClass (get_class ($ order)); $ method = $ réflexion-> getMethod ('getItemStotal'); $ method-> setAccessable (true); $ Method-> invokeargs ($ ordonnance, []); } }
[!TIP|style:flat|étiquette:BON]
La classe finale Validtest étend le testcase {/ ** * @Test * @dataprovider OrdersDataprovider * / Public Fonction get_total_returns_a_total_cost_of_a_whole_order (ordre $ ordonnance, int $ attendTotal): void {self :: Assertsame ($ attendTotal, $ order-> getTotal ()); } la fonction publique ordersDataprovider (): array {return [ [New Order ([New OrderItem (20), New OrderItem (20), New OrderItem (20)], 15), 75], [New Order ([New OrderItem (20), New OrderItem (30), New OrderItem (40)], 0), 90], [New Order ([New OrderItem (99), New OrderItem (99), New OrderItem (99)], 9), 306] ]; } }
[!ATTENTION] Les tests doivent uniquement vérifier l’API publique.
Le temps est une dépendance volatile car non déterministe. Chaque appel renvoie un résultat différent.
[!AVERTISSEMENT|style:plat|étiquette:MAUVAIS]
Horloge de classe finale {public static DateTime | null $ currentDateTime = null; public static function getCurrentDateTime (): dateTime {if (null === self :: $ currentDateTime) {self :: $ currentDateTime = new DateTime (); } return self :: $ currentDateTime; } set de fonctions statiques publiques (datetime $ dateTime): void {self :: $ currentDateTime = $ dateTime; } public static function reset (): void {self :: $ currentDateTime = null; } }
Client de classe finale {private DateTime $ CreateDat; Fonction publique __Construct () {$ this-> CreateDat = Clock :: getCurrentDateTime (); } fonction publique isvip (): bool {return $ this-> createdat-> diff (horloge :: getCurrentDateTime ()) -> y> = 1; } }
La classe finale invalidtest étend le testcase {/ ** * @Test * / Fonction publique A_Customer_Registered_More_Than_A_One_year_AGO_IS_A_VIP (): void { Clock :: set (new DateTime ('2019-01-01')); $ sut = new client (); Horloge :: reset (); // Vous devez vous souvenir de la réinitialisation des états partagés :: assertTrue ($ sut-> isvip ()); } / ** * @Test * / Fonction publique A_Customer_Registered_less_than_a_one_year_ago_is_not_a_vip (): void { Clock :: set ((new DateTime ()) -> sub (new DateInterval ('p2m'))); $ sut = new client (); Horloge :: reset (); // Vous devez vous souvenir de la réinitialisation des états partagés :: assertFalse ($ sut-> isvip ()); } }
[!TIP|style:flat|étiquette:BON]
Interface ClockInterface {Fonction publique GetCurrentTime (): DateTimeMutable; }
L'horloge de classe finale implémente Clockinterface {Fonction privée __construct () { } fonction statique publique create (): self {return new self (); } fonction publique getCurrentTime (): dateTimeMutable {return new DateTimeMutable (); } }
Final Class FixedClock implémente Clockinterface {Fonction privée __CONSTURT (Private ReadOnly DateTimeMutable $ fixedDate) {} Fonction statique publique Create (datetimeMutable $ fixedDate): self {return new self ($ fixedDate); } fonction publique getCurrentTime (): dateTimeMutable {return $ this-> fixedDate; } }
Client de classe finale {Fonction publique __Construct (Private ReadOnly DateTimeMutable $ CreateDat) {} Fonction publique ISVIP (DateTimeMutable $ CurrentDate): bool {return $ this-> créatedAt-> diff ($ currentDate) -> y> = 1; } }
La classe finale Validtest étend le testcase {/ ** * @Test * / Fonction publique A_Customer_Registerred_More_Than_A_One_year_AGO_IS_A_VIP (): void {$ sut = nouveau client (FixedClock :: Create (new DateTimeImtable ('2019-01-01')) -> GetCurrentTime ()); Self :: 2019-01-01 ')) -> GetCurrentTime ()); ASSERTTRUE ($ SUT-> ISVIP (FIXTCLOCK :: CREATE (NOUVEAU DateTimeMutable ('2020-01-02')) -> GetCurrentTime ())); } / ** * @Test * / Fonction publique A_Customer_Registered_less_than_a_one_year_ago_is_not_a_vip (): void {$ sut = nouveau client (fixedClock :: Create (new DateTimeMutable ('2019-01-01')) -> GetCurrentTime ()); self :: ASSERTFALSE ($ SUT-> ISVIP (FIXTCLOCK :: CREATE (NOUVEAU DateTimeMutable ('2019-05-02')) -> GetCurrentTime ( ))); } }
Note
L'heure et les nombres aléatoires ne doivent pas être générés directement dans le code du domaine. Pour tester le comportement, nous devons avoir des résultats déterministes, nous devons donc injecter ces valeurs dans un objet de domaine comme dans l'exemple ci-dessus.
Une couverture à 100 % n'est pas l'objectif, voire n'est pas souhaitable, car s'il y a une couverture à 100 %, les tests seront probablement très fragiles, ce qui signifie que la refactorisation sera très difficile. Les tests de mutation donnent un meilleur retour sur la qualité des tests. En savoir plus
Développement du test: par exemple / Kent Beck - le classique
Principes, pratiques et modèles des tests unitaires / Vladimir Khorikov - le meilleur livre sur les tests que j'ai jamais lu
Kamil Ruczyński
Twitter : https://twitter.com/Sarvendev
Blog : https://sarvendev.com/
LinkedIn : https://www.linkedin.com/in/kamilruczynski/