├── .gitignore ├── .nojekyll ├── CNAME ├── LICENSE ├── README.md ├── assets ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── bmc-button.png ├── ebook-unit-testing-tips.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── site.webmanifest └── test-doubles.jpg └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | .idea -------------------------------------------------------------------------------- /.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarven/unit-testing-tips/4c6cf0c2049f911b5657e305a0522884b7f743d1/.nojekyll -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | testing-tips.sarvendev.com -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 sarven 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GitHub stars](https://img.shields.io/github/stars/sarven/unit-testing-tips.svg?style=social&label=Star)](https://github.com/sarven/unit-testing-tips/) 2 | [![GitHub watchers](https://img.shields.io/github/watchers/sarven/unit-testing-tips.svg?style=social&label=Watch)](https://github.com/sarven/unit-testing-tips/watchers/) 3 | [![GitHub forks](https://img.shields.io/github/forks/sarven/unit-testing-tips.svg?style=social&label=Fork)](https://github.com/sarven/unit-testing-tips/forks/) 4 | [![GitHub contributors](https://img.shields.io/github/contributors/sarven/unit-testing-tips.svg)](https://github.com/sarven/unit-testing-tips/graphs/contributors/) 5 | 6 | # Testing tips 7 | 8 | In these times, the benefits of writing unit tests are huge. 9 | I think that most of the recently started projects contain any unit tests. 10 | In enterprise applications with a lot of business logic, unit tests are the most important tests, 11 | because they are fast and can us instantly assure that our implementation is correct. 12 | However, I often see a problem with good tests in projects, though these tests' benefits are only huge when you have good unit tests. 13 | So in these examples, I will try to share some tips on what to do to write good unit tests. 14 | 15 | **Easy-to-read version:** [https://testing-tips.sarvendev.com/](https://testing-tips.sarvendev.com/) 16 | 17 | ## Author 18 | 19 | :construction_worker: **Kamil Ruczyński** 20 | 21 | [![Twitter](https://img.shields.io/twitter/follow/Sarvendev?label=Follow&style=social)](https://twitter.com/Sarvendev) 22 | [![Github](https://img.shields.io/github/followers/sarven?label=Follow&style=social)](https://github.com/sarven) 23 | 24 | **Blog:** [https://sarvendev.com/](https://sarvendev.com/) 25 | **LinkedIn:** [https://www.linkedin.com/in/kamilruczynski/](https://www.linkedin.com/in/kamilruczynski/) 26 | 27 | ### Support 28 | Your support means the world to me! 29 | If you've enjoyed this guide and find value in the knowledge shared, 30 | consider supporting me on BuyMeCoffee: 31 | 32 | [![BuyMeCoffee](./assets/bmc-button.png ':size=150')](https://www.buymeacoffee.com/sarvendev) 33 | 34 | or simply leaving a star on the repository and 35 | following me on Twitter and Github to be up-to-date with all updates. 36 | Your generosity fuels my passion for creating more insightful content for you. 37 | 38 | If you have any improvement ideas or a topic to write about, feel free to prepare a pull request or just let me know. 39 | 40 | ## Free ebook – Unit testing tips 41 | 42 | [![FreeEbookUnitTestingTips](./assets/ebook-unit-testing-tips.png ':size=300')](https://sarvendev.com/free-ebook-unit-testing-tips/) 43 | 44 | Subscribe and master unit testing with my FREE eBook! 🚀 45 | :point_right: [Details](https://sarvendev.com/free-ebook-unit-testing-tips/) 46 | 47 | I still have a pretty long TODO list of improvements to this guide about Unit Testing and I will introduce them in the near future. 48 | 49 | ## Table of Contents 50 | 51 | 1. [Introduction](#introduction) 52 | 2. [Author](#author) 53 | 3. [Test doubles](#test-doubles) 54 | 4. [Naming](#naming) 55 | 5. [AAA pattern](#aaa-pattern) 56 | 6. [Object mother](#object-mother) 57 | 7. [Builder](#builder) 58 | 8. [Assert object](#assert-object) 59 | 9. [Parameterized test](#parameterized-test) 60 | 10. [Two schools of unit testing](#two-schools-of-unit-testing) 61 | * [Classical](#classical) 62 | * [Mockist](#mockist) 63 | * [Dependencies](#dependencies) 64 | 11. [Mock vs Stub](#mock-vs-stub) 65 | 12. [Three styles of unit testing](#three-styles-of-unit-testing) 66 | * [Output](#output) 67 | * [State](#state) 68 | * [Communication](#communication) 69 | 13. [Functional architecture and tests](#functional-architecture-and-tests) 70 | 14. [Observable behavior vs implementation details](#observable-behavior-vs-implementation-details) 71 | 15. [Unit of behavior](#unit-of-behavior) 72 | 16. [Humble pattern](#humble-pattern) 73 | 17. [Trivial test](#trivial-test) 74 | 18. [Fragile test](#fragile-test) 75 | 19. [Test fixtures](#test-fixtures) 76 | 20. [General testing anti-patterns](#general-testing-anti-patterns) 77 | * [Exposing private state](#exposing-private-state) 78 | * [Leaking domain details](#leaking-domain-details) 79 | * [Mocking concrete classes](#mocking-concrete-classes) 80 | * [Testing private methods](#testing-private-methods) 81 | * [Time as a volatile dependency](#time-as-a-volatile-dependency) 82 | 21. [100% Test Coverage shouldn't be the goal](#100-test-coverage-shouldnt-be-the-goal) 83 | 22. [Recommended books](#recommended-books) 84 | 85 | 86 | ## Test doubles 87 | 88 | Test doubles are fake dependencies used in tests. 89 | 90 | ![Test doubles](./assets/test-doubles.jpg ':size=800') 91 | 92 | ### Stubs 93 | 94 | #### Dummy 95 | 96 | A dummy is a just simple implementation that does nothing. 97 | 98 | ```php 99 | final class Mailer implements MailerInterface 100 | { 101 | public function send(Message $message): void 102 | { 103 | } 104 | } 105 | ``` 106 | 107 | #### Fake 108 | 109 | A fake is a simplified implementation to simulate the original behavior. 110 | 111 | ```php 112 | final class InMemoryCustomerRepository implements CustomerRepositoryInterface 113 | { 114 | /** 115 | * @var Customer[] 116 | */ 117 | private array $customers; 118 | 119 | public function __construct() 120 | { 121 | $this->customers = []; 122 | } 123 | 124 | public function store(Customer $customer): void 125 | { 126 | $this->customers[(string) $customer->id()->id()] = $customer; 127 | } 128 | 129 | public function get(CustomerId $id): Customer 130 | { 131 | if (!isset($this->customers[(string) $id->id()])) { 132 | throw new CustomerNotFoundException(); 133 | } 134 | 135 | return $this->customers[(string) $id->id()]; 136 | } 137 | 138 | public function findByEmail(Email $email): Customer 139 | { 140 | foreach ($this->customers as $customer) { 141 | if ($customer->getEmail()->isEqual($email)) { 142 | return $customer; 143 | } 144 | } 145 | 146 | throw new CustomerNotFoundException(); 147 | } 148 | } 149 | ``` 150 | 151 | #### Stub 152 | 153 | A stub is the simplest implementation with a hardcoded behavior. 154 | 155 | ```php 156 | final class UniqueEmailSpecificationStub implements UniqueEmailSpecificationInterface 157 | { 158 | public function isUnique(Email $email): bool 159 | { 160 | return true; 161 | } 162 | } 163 | ``` 164 | 165 | ```php 166 | $specificationStub = $this->createStub(UniqueEmailSpecificationInterface::class); 167 | $specificationStub->method('isUnique')->willReturn(true); 168 | ``` 169 | 170 | ### Mocks 171 | 172 | #### Spy 173 | 174 | A spy is an implementation to verify a specific behavior. 175 | 176 | ```php 177 | final class Mailer implements MailerInterface 178 | { 179 | /** 180 | * @var Message[] 181 | */ 182 | private array $messages; 183 | 184 | public function __construct() 185 | { 186 | $this->messages = []; 187 | } 188 | 189 | public function send(Message $message): void 190 | { 191 | $this->messages[] = $message; 192 | } 193 | 194 | public function getCountOfSentMessages(): int 195 | { 196 | return count($this->messages); 197 | } 198 | } 199 | ``` 200 | 201 | #### Mock 202 | 203 | A mock is a configured imitation to verify calls on a collaborator. 204 | 205 | ```php 206 | $message = new Message('test@test.com', 'Test', 'Test test test'); 207 | $mailer = $this->createMock(MailerInterface::class); 208 | $mailer 209 | ->expects($this->once()) 210 | ->method('send') 211 | ->with($this->equalTo($message)); 212 | ``` 213 | 214 | > [!ATTENTION] 215 | > To verify incoming interactions, use a stub, but to verify outcoming interactions, use a mock. 216 | > More: [Mock vs Stub](#mock-vs-stub) 217 | 218 | ### Always prefer own test double classes than those provided by a framework 219 | 220 | > [!WARNING|style:flat|label:NOT GOOD] 221 | 222 | ```php 223 | final class TestExample extends TestCase 224 | { 225 | /** 226 | * @test 227 | */ 228 | public function sends_all_notifications(): void 229 | { 230 | $message1 = new Message(); 231 | $message2 = new Message(); 232 | $messageRepository = $this->createMock(MessageRepositoryInterface::class); 233 | $messageRepository->method('getAll')->willReturn([$message1, $message2]); 234 | $mailer = $this->createMock(MailerInterface::class); 235 | $sut = new NotificationService($mailer, $messageRepository); 236 | 237 | $mailer->expects(self::exactly(2))->method('send') 238 | ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]); 239 | 240 | $sut->send(); 241 | } 242 | } 243 | ``` 244 | 245 | > [!TIP|style:flat|label:BETTER] 246 | 247 | - **Better resistance to refactoring** 248 | - Using Refactor->Rename on the particular method doesn't break the test 249 | - **Better readability** 250 | - **Lower cost of maintainability** 251 | - Not required to learn those sophisticated mocks frameworks 252 | - Just simple plain PHP code 253 | 254 | ```php 255 | final class TestExample extends TestCase 256 | { 257 | /** 258 | * @test 259 | */ 260 | public function sends_all_notifications(): void 261 | { 262 | $message1 = new Message(); 263 | $message2 = new Message(); 264 | $messageRepository = new InMemoryMessageRepository(); 265 | $messageRepository->save($message1); 266 | $messageRepository->save($message2); 267 | $mailer = new SpyMailer(); 268 | $sut = new NotificationService($mailer, $messageRepository); 269 | 270 | $sut->send(); 271 | 272 | $mailer->assertThatMessagesHaveBeenSent([$message1, $message2]); 273 | } 274 | } 275 | ``` 276 | 277 | ## Naming 278 | 279 | > [!WARNING|style:flat|label:NOT GOOD] 280 | 281 | ```php 282 | public function test(): void 283 | { 284 | $subscription = SubscriptionMother::new(); 285 | 286 | $subscription->activate(); 287 | 288 | self::assertSame(Status::activated(), $subscription->status()); 289 | } 290 | ``` 291 | 292 | > [!TIP|style:flat|label:Specify explicitly what you are testing] 293 | 294 | ```php 295 | public function sut(): void 296 | { 297 | // sut = System under test 298 | $sut = SubscriptionMother::new(); 299 | 300 | $sut->activate(); 301 | 302 | self::assertSame(Status::activated(), $sut->status()); 303 | } 304 | ``` 305 | 306 | > [!WARNING|style:flat|label:NOT GOOD] 307 | 308 | ```php 309 | public function it_throws_invalid_credentials_exception_when_sign_in_with_invalid_credentials(): void 310 | { 311 | 312 | } 313 | 314 | public function testCreatingWithATooShortPasswordIsNotPossible(): void 315 | { 316 | 317 | } 318 | 319 | public function testDeactivateASubscription(): void 320 | { 321 | 322 | } 323 | ``` 324 | 325 | > [!TIP|style:flat|label:BETTER] 326 | 327 | - **Using underscore improves readability** 328 | - **The name should describe the behavior, not the implementation** 329 | - **Use names without technical keywords. It should be readable for a non-programmer person.** 330 | 331 | ```php 332 | public function sign_in_with_invalid_credentials_is_not_possible(): void 333 | { 334 | 335 | } 336 | 337 | public function creating_with_a_too_short_password_is_not_possible(): void 338 | { 339 | 340 | } 341 | 342 | public function deactivating_an_activated_subscription_is_valid(): void 343 | { 344 | 345 | } 346 | 347 | public function deactivating_an_inactive_subscription_is_invalid(): void 348 | { 349 | 350 | } 351 | ``` 352 | 353 | > [!NOTE] 354 | > Describing the behavior is important in testing the domain scenarios. 355 | > If your code is just a utility one it's less important. 356 | > 357 | > **Why would it be useful for a non-programmer to read unit tests?** 358 | > 359 | > If there is a project with complex domain logic, this logic must be very clear for everyone, so then tests describe domain details without technical keywords, and you can talk with a business in a language like in these tests. 360 | > All code that is related to the domain should be free from technical details. A non-programmer won't be read these tests. If you want to talk about the domain these tests will be useful to know what this domain does. There will be a description without technical details e.g., returns null, throws an exception, etc. This kind of information has nothing to do with the domain, so we shouldn't use these keywords. 361 | 362 | 363 | ## AAA pattern 364 | 365 | It's also common Given, When, Then. 366 | 367 | Separate three sections of the test: 368 | 369 | - **Arrange**: Bring the system under test in the desired state. Prepare dependencies, arguments and finally construct 370 | the SUT. 371 | - **Act**: Invoke a tested element. 372 | - **Assert**: Verify the result, the final state, or the communication with collaborators. 373 | 374 | > [!TIP|style:flat|label:GOOD] 375 | 376 | ```php 377 | public function aaa_pattern_example_test(): void 378 | { 379 | //Arrange|Given 380 | $sut = SubscriptionMother::new(); 381 | 382 | //Act|When 383 | $sut->activate(); 384 | 385 | //Assert|Then 386 | self::assertSame(Status::activated(), $sut->status()); 387 | } 388 | ``` 389 | 390 | ## Object mother 391 | 392 | The pattern helps to create specific objects which can be reused in a few tests. Because of that the arrange section 393 | is concise and the test as a whole is more readable. 394 | 395 | ```php 396 | final class SubscriptionMother 397 | { 398 | public static function new(): Subscription 399 | { 400 | return new Subscription(); 401 | } 402 | 403 | public static function activated(): Subscription 404 | { 405 | $subscription = new Subscription(); 406 | $subscription->activate(); 407 | return $subscription; 408 | } 409 | 410 | public static function deactivated(): Subscription 411 | { 412 | $subscription = self::activated(); 413 | $subscription->deactivate(); 414 | return $subscription; 415 | } 416 | } 417 | ``` 418 | 419 | ```php 420 | final class ExampleTest 421 | { 422 | public function example_test_with_activated_subscription(): void 423 | { 424 | $activatedSubscription = SubscriptionMother::activated(); 425 | 426 | // do something 427 | 428 | // check something 429 | } 430 | 431 | public function example_test_with_deactivated_subscription(): void 432 | { 433 | $deactivatedSubscription = SubscriptionMother::deactivated(); 434 | 435 | // do something 436 | 437 | // check something 438 | } 439 | } 440 | ``` 441 | 442 | ## Builder 443 | 444 | Builder is another pattern that helps us to create objects in tests. Compared to Object Mother pattern Builder is better for creating 445 | more complex objects. 446 | 447 | ```php 448 | final class OrderBuilder 449 | { 450 | private DateTimeImmutable|null $createdAt = null; 451 | 452 | /** 453 | * @var OrderItem[] 454 | */ 455 | private array $items = []; 456 | 457 | public function createdAt(DateTimeImmutable $createdAt): self 458 | { 459 | $this->createdAt = $createdAt; 460 | return $this; 461 | } 462 | 463 | public function withItem(string $name, int $price): self 464 | { 465 | $this->items[] = new OrderItem($name, $price); 466 | return $this; 467 | } 468 | 469 | public function build(): Order 470 | { 471 | Assert::notEmpty($this->items); 472 | 473 | return new Order( 474 | $this->createdAt ?? new DateTimeImmutable(), 475 | $this->items, 476 | ); 477 | } 478 | } 479 | ``` 480 | 481 | ```php 482 | final class ExampleTest extends TestCase 483 | { 484 | /** 485 | * @test 486 | */ 487 | public function example_test_with_order_builder(): void 488 | { 489 | $order = (new OrderBuilder()) 490 | ->createdAt(new DateTimeImmutable('2022-11-10 20:00:00')) 491 | ->withItem('Item 1', 1000) 492 | ->withItem('Item 2', 2000) 493 | ->withItem('Item 3', 3000) 494 | ->build(); 495 | 496 | // do something 497 | 498 | // check something 499 | } 500 | } 501 | ``` 502 | 503 | ## Assert object 504 | 505 | Assert object pattern helps write more readable assert sections. Instead of using a few asserts, we can just prepare an abstraction, 506 | and use natural language to describe what result is expected. 507 | 508 | ```php 509 | final class ExampleTest extends TestCase 510 | { 511 | /** 512 | * @test 513 | */ 514 | public function example_test_with_asserter(): void 515 | { 516 | $currentTime = new DateTimeImmutable('2022-11-10 20:00:00'); 517 | $sut = new OrderService(); 518 | 519 | $order = $sut->create($currentTime); 520 | 521 | OrderAsserter::assertThat($order) 522 | ->wasCreatedAt($currentTime) 523 | ->hasTotal(6000); 524 | } 525 | } 526 | ``` 527 | 528 | ```php 529 | use PHPUnit\Framework\Assert; 530 | 531 | final class OrderAsserter 532 | { 533 | public function __construct(private readonly Order $order) {} 534 | 535 | public static function assertThat(Order $order): self 536 | { 537 | return new OrderAsserter($order); 538 | } 539 | 540 | public function wasCreatedAt(DateTimeImmutable $createdAt): self 541 | { 542 | Assert::assertEquals($createdAt, $this->order->createdAt); 543 | return $this; 544 | } 545 | 546 | public function hasTotal(int $total): self 547 | { 548 | Assert::assertSame($total, $this->order->getTotal()); 549 | return $this; 550 | } 551 | } 552 | ``` 553 | 554 | ## Parameterized test 555 | 556 | The parameterized test is a good option to test the SUT with many parameters without repeating the code. 557 | 558 | > [!WARNING] 559 | > :thumbsdown: This kind of test is less readable. To increase the readability a little, negative and positive examples should be split up to different tests. 560 | 561 | ```php 562 | final class ExampleTest extends TestCase 563 | { 564 | /** 565 | * @test 566 | * @dataProvider getInvalidEmails 567 | */ 568 | public function detects_an_invalid_email_address(string $email): void 569 | { 570 | $sut = new EmailValidator(); 571 | 572 | $result = $sut->isValid($email); 573 | 574 | self::assertFalse($result); 575 | } 576 | 577 | /** 578 | * @test 579 | * @dataProvider getValidEmails 580 | */ 581 | public function detects_an_valid_email_address(string $email): void 582 | { 583 | $sut = new EmailValidator(); 584 | 585 | $result = $sut->isValid($email); 586 | 587 | self::assertTrue($result); 588 | } 589 | 590 | public function getInvalidEmails(): iterable 591 | { 592 | yield 'An invalid email without @' => ['test']; 593 | yield 'An invalid email without the domain after @' => ['test@']; 594 | yield 'An invalid email without TLD' => ['test@test']; 595 | //... 596 | } 597 | 598 | public function getValidEmails(): iterable 599 | { 600 | yield 'A valid email with lowercase letters' => ['test@test.com']; 601 | yield 'A valid email with lowercase letters and digits' => ['test123@test.com']; 602 | yield 'A valid email with uppercase letters and digits' => ['Test123@test.com']; 603 | //... 604 | } 605 | } 606 | ``` 607 | 608 | > [!NOTE] 609 | > Use `yield` and add a text description to cases to improve the readability. 610 | 611 | ## Two schools of unit testing 612 | 613 | ### Classical (Detroit school) 614 | 615 | - The unit is a single unit of behavior, it can be a few related classes. 616 | 617 | ```php 618 | final class TestExample extends TestCase 619 | { 620 | /** 621 | * @test 622 | */ 623 | public function suspending_an_subscription_with_can_always_suspend_policy_is_always_possible(): void 624 | { 625 | $canAlwaysSuspendPolicy = new CanAlwaysSuspendPolicy(); 626 | $sut = new Subscription(); 627 | 628 | $result = $sut->suspend($canAlwaysSuspendPolicy); 629 | 630 | self::assertTrue($result); 631 | self::assertSame(Status::suspend(), $sut->status()); 632 | } 633 | } 634 | ``` 635 | 636 | ### Mockist (London school) 637 | 638 | - The unit is a single class. 639 | - The unit should be isolated from all collaborators. 640 | 641 | ```php 642 | final class TestExample extends TestCase 643 | { 644 | /** 645 | * @test 646 | */ 647 | public function suspending_an_subscription_with_can_always_suspend_policy_is_always_possible(): void 648 | { 649 | $canAlwaysSuspendPolicy = $this->createStub(SuspendingPolicyInterface::class); 650 | $canAlwaysSuspendPolicy->method('suspend')->willReturn(true); 651 | $sut = new Subscription(); 652 | 653 | $result = $sut->suspend($canAlwaysSuspendPolicy); 654 | 655 | self::assertTrue($result); 656 | self::assertSame(Status::suspend(), $sut->status()); 657 | } 658 | } 659 | ``` 660 | 661 | > [!NOTE] 662 | > **The classical approach is better to avoid fragile tests.** 663 | 664 | ### Dependencies 665 | 666 | [TODO] 667 | 668 | ## Mock vs. Stub 669 | 670 | Example: 671 | ```php 672 | final class NotificationService 673 | { 674 | public function __construct( 675 | private readonly MailerInterface $mailer, 676 | private readonly MessageRepositoryInterface $messageRepository 677 | ) {} 678 | 679 | public function send(): void 680 | { 681 | $messages = $this->messageRepository->getAll(); 682 | foreach ($messages as $message) { 683 | $this->mailer->send($message); 684 | } 685 | } 686 | } 687 | ``` 688 | 689 | > [!WARNING|style:flat|label:BAD] 690 | 691 | - **Asserting interactions with stubs leads to fragile tests** 692 | 693 | ```php 694 | final class TestExample extends TestCase 695 | { 696 | /** 697 | * @test 698 | */ 699 | public function sends_all_notifications(): void 700 | { 701 | $message1 = new Message(); 702 | $message2 = new Message(); 703 | $messageRepository = $this->createMock(MessageRepositoryInterface::class); 704 | $messageRepository->method('getAll')->willReturn([$message1, $message2]); 705 | $mailer = $this->createMock(MailerInterface::class); 706 | $sut = new NotificationService($mailer, $messageRepository); 707 | 708 | $messageRepository->expects(self::once())->method('getAll'); 709 | $mailer->expects(self::exactly(2))->method('send') 710 | ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]); 711 | 712 | $sut->send(); 713 | } 714 | } 715 | ``` 716 | 717 | > [!TIP|style:flat|label:GOOD] 718 | 719 | ```php 720 | final class TestExample extends TestCase 721 | { 722 | /** 723 | * @test 724 | */ 725 | public function sends_all_notifications(): void 726 | { 727 | $message1 = new Message(); 728 | $message2 = new Message(); 729 | $messageRepository = new InMemoryMessageRepository(); 730 | $messageRepository->save($message1); 731 | $messageRepository->save($message2); 732 | $mailer = $this->createMock(MailerInterface::class); 733 | $sut = new NotificationService($mailer, $messageRepository); 734 | 735 | // Removed asserting interactions with the stub 736 | $mailer->expects(self::exactly(2))->method('send') 737 | ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]); 738 | 739 | $sut->send(); 740 | } 741 | } 742 | ``` 743 | 744 | > [!TIP|style:flat|label:EVEN BETTER USING SPY] 745 | 746 | 747 | ```php 748 | final class TestExample extends TestCase 749 | { 750 | /** 751 | * @test 752 | */ 753 | public function sends_all_notifications(): void 754 | { 755 | $message1 = new Message(); 756 | $message2 = new Message(); 757 | $messageRepository = new InMemoryMessageRepository(); 758 | $messageRepository->save($message1); 759 | $messageRepository->save($message2); 760 | $mailer = new SpyMailer(); 761 | $sut = new NotificationService($mailer, $messageRepository); 762 | 763 | $sut->send(); 764 | 765 | $mailer->assertThatMessagesHaveBeenSent([$message1, $message2]); 766 | } 767 | } 768 | ``` 769 | 770 | ## Three styles of unit testing 771 | 772 | ### Output 773 | 774 | > [!TIP|style:flat|label:The best option] 775 | 776 | 777 | - **The best resistance to refactoring** 778 | - **The best accuracy** 779 | - **The lowest cost of maintainability** 780 | - **If it is possible, you should prefer this kind of test** 781 | 782 | ```php 783 | final class ExampleTest extends TestCase 784 | { 785 | /** 786 | * @test 787 | * @dataProvider getInvalidEmails 788 | */ 789 | public function detects_an_invalid_email_address(string $email): void 790 | { 791 | $sut = new EmailValidator(); 792 | 793 | $result = $sut->isValid($email); 794 | 795 | self::assertFalse($result); 796 | } 797 | 798 | /** 799 | * @test 800 | * @dataProvider getValidEmails 801 | */ 802 | public function detects_an_valid_email_address(string $email): void 803 | { 804 | $sut = new EmailValidator(); 805 | 806 | $result = $sut->isValid($email); 807 | 808 | self::assertTrue($result); 809 | } 810 | 811 | public function getInvalidEmails(): array 812 | { 813 | return [ 814 | ['test'], 815 | ['test@'], 816 | ['test@test'], 817 | //... 818 | ]; 819 | } 820 | 821 | public function getValidEmails(): array 822 | { 823 | return [ 824 | ['test@test.com'], 825 | ['test123@test.com'], 826 | ['Test123@test.com'], 827 | //... 828 | ]; 829 | } 830 | } 831 | ``` 832 | 833 | ### State 834 | 835 | > [!WARNING|style:flat|label:Worse option] 836 | 837 | - **Worse resistance to refactoring** 838 | - **Worse accuracy** 839 | - **Higher cost of maintainability** 840 | 841 | ```php 842 | final class ExampleTest extends TestCase 843 | { 844 | /** 845 | * @test 846 | */ 847 | public function adding_an_item_to_cart(): void 848 | { 849 | $item = new CartItem('Product'); 850 | $sut = new Cart(); 851 | 852 | $sut->addItem($item); 853 | 854 | self::assertSame(1, $sut->getCount()); 855 | self::assertSame($item, $sut->getItems()[0]); 856 | } 857 | } 858 | ``` 859 | 860 | ### Communication 861 | 862 | > [!ATTENTION|style:flat|label:The worst option] 863 | 864 | - **The worst resistance to refactoring** 865 | - **The worst accuracy** 866 | - **The highest cost of maintainability** 867 | 868 | ```php 869 | final class ExampleTest extends TestCase 870 | { 871 | /** 872 | * @test 873 | */ 874 | public function sends_all_notifications(): void 875 | { 876 | $message1 = new Message(); 877 | $message2 = new Message(); 878 | $messageRepository = new InMemoryMessageRepository(); 879 | $messageRepository->save($message1); 880 | $messageRepository->save($message2); 881 | $mailer = $this->createMock(MailerInterface::class); 882 | $sut = new NotificationService($mailer, $messageRepository); 883 | 884 | $mailer->expects(self::exactly(2))->method('send') 885 | ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]); 886 | 887 | $sut->send(); 888 | } 889 | } 890 | ``` 891 | 892 | ## Functional architecture and tests 893 | 894 | > [!WARNING|style:flat|label:BAD] 895 | 896 | ```php 897 | final class NameService 898 | { 899 | public function __construct(private readonly CacheStorageInterface $cacheStorage) {} 900 | 901 | public function loadAll(): void 902 | { 903 | $namesCsv = array_map('str_getcsv', file(__DIR__.'/../names.csv')); 904 | $names = []; 905 | 906 | foreach ($namesCsv as $nameData) { 907 | if (!isset($nameData[0], $nameData[1])) { 908 | continue; 909 | } 910 | 911 | $names[] = new Name($nameData[0], new Gender($nameData[1])); 912 | } 913 | 914 | $this->cacheStorage->store('names', $names); 915 | } 916 | } 917 | ``` 918 | 919 | **How to test a code like this? It is possible only with an integration test because it directly uses 920 | an infrastructure code related to a file system.** 921 | 922 | > [!TIP|style:flat|label:GOOD] 923 | 924 | Like in functional architecture, we need to separate a code with side effects and code that contains only logic. 925 | 926 | ```php 927 | final class NameParser 928 | { 929 | /** 930 | * @param array $namesData 931 | * @return Name[] 932 | */ 933 | public function parse(array $namesData): array 934 | { 935 | $names = []; 936 | 937 | foreach ($namesData as $nameData) { 938 | if (!isset($nameData[0], $nameData[1])) { 939 | continue; 940 | } 941 | 942 | $names[] = new Name($nameData[0], new Gender($nameData[1])); 943 | } 944 | 945 | return $names; 946 | } 947 | } 948 | ``` 949 | 950 | ```php 951 | final class CsvNamesFileLoader 952 | { 953 | public function load(): array 954 | { 955 | return array_map('str_getcsv', file(__DIR__.'/../names.csv')); 956 | } 957 | } 958 | ``` 959 | 960 | ```php 961 | final class ApplicationService 962 | { 963 | public function __construct( 964 | private readonly CsvNamesFileLoader $fileLoader, 965 | private readonly NameParser $parser, 966 | private readonly CacheStorageInterface $cacheStorage 967 | ) {} 968 | 969 | public function loadNames(): void 970 | { 971 | $namesData = $this->fileLoader->load(); 972 | $names = $this->parser->parse($namesData); 973 | $this->cacheStorage->store('names', $names); 974 | } 975 | } 976 | ``` 977 | 978 | ```php 979 | final class ValidUnitExampleTest extends TestCase 980 | { 981 | /** 982 | * @test 983 | */ 984 | public function parse_all_names(): void 985 | { 986 | $namesData = [ 987 | ['John', 'M'], 988 | ['Lennon', 'U'], 989 | ['Sarah', 'W'] 990 | ]; 991 | $sut = new NameParser(); 992 | 993 | $result = $sut->parse($namesData); 994 | 995 | self::assertSame( 996 | [ 997 | new Name('John', new Gender('M')), 998 | new Name('Lennon', new Gender('U')), 999 | new Name('Sarah', new Gender('W')) 1000 | ], 1001 | $result 1002 | ); 1003 | } 1004 | } 1005 | ``` 1006 | 1007 | ## Observable behavior vs. implementation details 1008 | 1009 | > [!WARNING|style:flat|label:BAD] 1010 | 1011 | ```php 1012 | final class ApplicationService 1013 | { 1014 | public function __construct(private readonly SubscriptionRepositoryInterface $subscriptionRepository) {} 1015 | 1016 | public function renewSubscription(int $subscriptionId): bool 1017 | { 1018 | $subscription = $this->subscriptionRepository->findById($subscriptionId); 1019 | 1020 | if (!$subscription->getStatus()->isEqual(Status::expired())) { 1021 | return false; 1022 | } 1023 | 1024 | $subscription->setStatus(Status::active()); 1025 | $subscription->setModifiedAt(new \DateTimeImmutable()); 1026 | return true; 1027 | } 1028 | } 1029 | ``` 1030 | 1031 | ```php 1032 | final class Subscription 1033 | { 1034 | public function __construct(private Status $status, private \DateTimeImmutable $modifiedAt) {} 1035 | 1036 | public function getStatus(): Status 1037 | { 1038 | return $this->status; 1039 | } 1040 | 1041 | public function setStatus(Status $status): void 1042 | { 1043 | $this->status = $status; 1044 | } 1045 | 1046 | public function getModifiedAt(): \DateTimeImmutable 1047 | { 1048 | return $this->modifiedAt; 1049 | } 1050 | 1051 | public function setModifiedAt(\DateTimeImmutable $modifiedAt): void 1052 | { 1053 | $this->modifiedAt = $modifiedAt; 1054 | } 1055 | } 1056 | ``` 1057 | 1058 | ```php 1059 | final class InvalidTestExample extends TestCase 1060 | { 1061 | /** 1062 | * @test 1063 | */ 1064 | public function renew_an_expired_subscription_is_possible(): void 1065 | { 1066 | $modifiedAt = new \DateTimeImmutable(); 1067 | $expiredSubscription = new Subscription(Status::expired(), $modifiedAt); 1068 | $sut = new ApplicationService($this->createRepository($expiredSubscription)); 1069 | 1070 | $result = $sut->renewSubscription(1); 1071 | 1072 | self::assertSame(Status::active(), $expiredSubscription->getStatus()); 1073 | self::assertGreaterThan($modifiedAt, $expiredSubscription->getModifiedAt()); 1074 | self::assertTrue($result); 1075 | } 1076 | 1077 | /** 1078 | * @test 1079 | */ 1080 | public function renew_an_active_subscription_is_not_possible(): void 1081 | { 1082 | $modifiedAt = new \DateTimeImmutable(); 1083 | $activeSubscription = new Subscription(Status::active(), $modifiedAt); 1084 | $sut = new ApplicationService($this->createRepository($activeSubscription)); 1085 | 1086 | $result = $sut->renewSubscription(1); 1087 | 1088 | self::assertSame($modifiedAt, $activeSubscription->getModifiedAt()); 1089 | self::assertFalse($result); 1090 | } 1091 | 1092 | private function createRepository(Subscription $subscription): SubscriptionRepositoryInterface 1093 | { 1094 | return new class ($expiredSubscription) implements SubscriptionRepositoryInterface { 1095 | public function __construct(private readonly Subscription $subscription) {} 1096 | 1097 | public function findById(int $id): Subscription 1098 | { 1099 | return $this->subscription; 1100 | } 1101 | }; 1102 | } 1103 | } 1104 | ``` 1105 | 1106 | > [!TIP|style:flat|label:GOOD] 1107 | 1108 | ```php 1109 | final class ApplicationService 1110 | { 1111 | public function __construct( 1112 | private readonly SubscriptionRepositoryInterface $subscriptionRepository 1113 | ) {} 1114 | 1115 | public function renewSubscription(int $subscriptionId): bool 1116 | { 1117 | $subscription = $this->subscriptionRepository->findById($subscriptionId); 1118 | return $subscription->renew(new \DateTimeImmutable()); 1119 | } 1120 | } 1121 | ``` 1122 | 1123 | ```php 1124 | final class Subscription 1125 | { 1126 | private Status $status; 1127 | private \DateTimeImmutable $modifiedAt; 1128 | 1129 | public function __construct(\DateTimeImmutable $modifiedAt) 1130 | { 1131 | $this->status = Status::new(); 1132 | $this->modifiedAt = $modifiedAt; 1133 | } 1134 | 1135 | public function renew(\DateTimeImmutable $modifiedAt): bool 1136 | { 1137 | if (!$this->status->isEqual(Status::expired())) { 1138 | return false; 1139 | } 1140 | 1141 | $this->status = Status::active(); 1142 | $this->modifiedAt = $modifiedAt; 1143 | return true; 1144 | } 1145 | 1146 | public function active(\DateTimeImmutable $modifiedAt): void 1147 | { 1148 | //simplified 1149 | $this->status = Status::active(); 1150 | $this->modifiedAt = $modifiedAt; 1151 | } 1152 | 1153 | public function expire(\DateTimeImmutable $modifiedAt): void 1154 | { 1155 | //simplified 1156 | $this->status = Status::expired(); 1157 | $this->modifiedAt = $modifiedAt; 1158 | } 1159 | 1160 | public function isActive(): bool 1161 | { 1162 | return $this->status->isEqual(Status::active()); 1163 | } 1164 | } 1165 | ``` 1166 | 1167 | ```php 1168 | final class ValidTestExample extends TestCase 1169 | { 1170 | /** 1171 | * @test 1172 | */ 1173 | public function renew_an_expired_subscription_is_possible(): void 1174 | { 1175 | $expiredSubscription = SubscriptionMother::expired(); 1176 | $sut = new ApplicationService($this->createRepository($expiredSubscription)); 1177 | 1178 | $result = $sut->renewSubscription(1); 1179 | 1180 | // skip checking modifiedAt as it's not a part of observable behavior. To check this value we 1181 | // would have to add a getter for modifiedAt, probably only for test purposes. 1182 | self::assertTrue($expiredSubscription->isActive()); 1183 | self::assertTrue($result); 1184 | } 1185 | 1186 | /** 1187 | * @test 1188 | */ 1189 | public function renew_an_active_subscription_is_not_possible(): void 1190 | { 1191 | $activeSubscription = SubscriptionMother::active(); 1192 | $sut = new ApplicationService($this->createRepository($activeSubscription)); 1193 | 1194 | $result = $sut->renewSubscription(1); 1195 | 1196 | self::assertTrue($activeSubscription->isActive()); 1197 | self::assertFalse($result); 1198 | } 1199 | 1200 | private function createRepository(Subscription $subscription): SubscriptionRepositoryInterface 1201 | { 1202 | return new class ($expiredSubscription) implements SubscriptionRepositoryInterface { 1203 | public function __construct(private readonly Subscription $subscription) {} 1204 | 1205 | public function findById(int $id): Subscription 1206 | { 1207 | return $this->subscription; 1208 | } 1209 | }; 1210 | } 1211 | } 1212 | ``` 1213 | 1214 | > [!NOTE] 1215 | > The first subscription model has a bad design. To invoke one business operation you need to call three methods. Also using getters to verify operation is not a good practice. 1216 | > In this case, it's skipped checking a change of `modifiedAt`, probably setting specific `modifiedAt` during a renew operation can be tested with an expiration business operation. The getter for `modifiedAt` is not required. 1217 | > Of course, there are cases where finding the possibility to avoid getters provided only for tests will be very hard, but always we should try not to introduce them. 1218 | 1219 | ## Unit of behavior 1220 | 1221 | > [!WARNING|style:flat|label:BAD] 1222 | 1223 | ```php 1224 | class CannotSuspendExpiredSubscriptionPolicy implements SuspendingPolicyInterface 1225 | { 1226 | public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool 1227 | { 1228 | if ($subscription->isExpired()) { 1229 | return false; 1230 | } 1231 | 1232 | return true; 1233 | } 1234 | } 1235 | ``` 1236 | 1237 | ```php 1238 | class CannotSuspendExpiredSubscriptionPolicyTest extends TestCase 1239 | { 1240 | /** 1241 | * @test 1242 | */ 1243 | public function it_returns_false_when_a_subscription_is_expired(): void 1244 | { 1245 | $policy = new CannotSuspendExpiredSubscriptionPolicy(); 1246 | $subscription = $this->createStub(Subscription::class); 1247 | $subscription->method('isExpired')->willReturn(true); 1248 | 1249 | self::assertFalse($policy->suspend($subscription, new \DateTimeImmutable())); 1250 | } 1251 | 1252 | /** 1253 | * @test 1254 | */ 1255 | public function it_returns_true_when_a_subscription_is_not_expired(): void 1256 | { 1257 | $policy = new CannotSuspendExpiredSubscriptionPolicy(); 1258 | $subscription = $this->createStub(Subscription::class); 1259 | $subscription->method('isExpired')->willReturn(false); 1260 | 1261 | self::assertTrue($policy->suspend($subscription, new \DateTimeImmutable())); 1262 | } 1263 | } 1264 | ``` 1265 | 1266 | ```php 1267 | class CannotSuspendNewSubscriptionPolicy implements SuspendingPolicyInterface 1268 | { 1269 | public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool 1270 | { 1271 | if ($subscription->isNew()) { 1272 | return false; 1273 | } 1274 | 1275 | return true; 1276 | } 1277 | } 1278 | ``` 1279 | 1280 | ```php 1281 | class CannotSuspendNewSubscriptionPolicyTest extends TestCase 1282 | { 1283 | /** 1284 | * @test 1285 | */ 1286 | public function it_returns_false_when_a_subscription_is_new(): void 1287 | { 1288 | $policy = new CannotSuspendNewSubscriptionPolicy(); 1289 | $subscription = $this->createStub(Subscription::class); 1290 | $subscription->method('isNew')->willReturn(true); 1291 | 1292 | self::assertFalse($policy->suspend($subscription, new \DateTimeImmutable())); 1293 | } 1294 | 1295 | /** 1296 | * @test 1297 | */ 1298 | public function it_returns_true_when_a_subscription_is_not_new(): void 1299 | { 1300 | $policy = new CannotSuspendNewSubscriptionPolicy(); 1301 | $subscription = $this->createStub(Subscription::class); 1302 | $subscription->method('isNew')->willReturn(false); 1303 | 1304 | self::assertTrue($policy->suspend($subscription, new \DateTimeImmutable())); 1305 | } 1306 | } 1307 | ``` 1308 | 1309 | ```php 1310 | class CanSuspendAfterOneMonthPolicy implements SuspendingPolicyInterface 1311 | { 1312 | public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool 1313 | { 1314 | $oneMonthEarlierDate = \DateTime::createFromImmutable($at)->sub(new \DateInterval('P1M')); 1315 | 1316 | return $subscription->isOlderThan(\DateTimeImmutable::createFromMutable($oneMonthEarlierDate)); 1317 | } 1318 | } 1319 | ``` 1320 | 1321 | ```php 1322 | class CanSuspendAfterOneMonthPolicyTest extends TestCase 1323 | { 1324 | /** 1325 | * @test 1326 | */ 1327 | public function it_returns_true_when_a_subscription_is_older_than_one_month(): void 1328 | { 1329 | $date = new \DateTimeImmutable('2021-01-29'); 1330 | $policy = new CanSuspendAfterOneMonthPolicy(); 1331 | $subscription = new Subscription(new \DateTimeImmutable('2020-12-28')); 1332 | 1333 | self::assertTrue($policy->suspend($subscription, $date)); 1334 | } 1335 | 1336 | /** 1337 | * @test 1338 | */ 1339 | public function it_returns_false_when_a_subscription_is_not_older_than_one_month(): void 1340 | { 1341 | $date = new \DateTimeImmutable('2021-01-29'); 1342 | $policy = new CanSuspendAfterOneMonthPolicy(); 1343 | $subscription = new Subscription(new \DateTimeImmutable('2020-01-01')); 1344 | 1345 | self::assertTrue($policy->suspend($subscription, $date)); 1346 | } 1347 | } 1348 | ``` 1349 | 1350 | ```php 1351 | class Status 1352 | { 1353 | private const EXPIRED = 'expired'; 1354 | private const ACTIVE = 'active'; 1355 | private const NEW = 'new'; 1356 | private const SUSPENDED = 'suspended'; 1357 | 1358 | private function __construct(private readonly string $status) 1359 | { 1360 | $this->status = $status; 1361 | } 1362 | 1363 | public static function expired(): self 1364 | { 1365 | return new self(self::EXPIRED); 1366 | } 1367 | 1368 | public static function active(): self 1369 | { 1370 | return new self(self::ACTIVE); 1371 | } 1372 | 1373 | public static function new(): self 1374 | { 1375 | return new self(self::NEW); 1376 | } 1377 | 1378 | public static function suspended(): self 1379 | { 1380 | return new self(self::SUSPENDED); 1381 | } 1382 | 1383 | public function isEqual(self $status): bool 1384 | { 1385 | return $this->status === $status->status; 1386 | } 1387 | } 1388 | ``` 1389 | 1390 | ```php 1391 | class StatusTest extends TestCase 1392 | { 1393 | public function testEquals(): void 1394 | { 1395 | $status1 = Status::active(); 1396 | $status2 = Status::active(); 1397 | 1398 | self::assertTrue($status1->isEqual($status2)); 1399 | } 1400 | 1401 | public function testNotEquals(): void 1402 | { 1403 | $status1 = Status::active(); 1404 | $status2 = Status::expired(); 1405 | 1406 | self::assertFalse($status1->isEqual($status2)); 1407 | } 1408 | } 1409 | ``` 1410 | 1411 | ```php 1412 | class SubscriptionTest extends TestCase 1413 | { 1414 | /** 1415 | * @test 1416 | */ 1417 | public function suspending_a_subscription_is_possible_when_a_policy_returns_true(): void 1418 | { 1419 | $policy = $this->createMock(SuspendingPolicyInterface::class); 1420 | $policy->expects($this->once())->method('suspend')->willReturn(true); 1421 | $sut = new Subscription(new \DateTimeImmutable()); 1422 | 1423 | $result = $sut->suspend($policy, new \DateTimeImmutable()); 1424 | 1425 | self::assertTrue($result); 1426 | self::assertTrue($sut->isSuspended()); 1427 | } 1428 | 1429 | /** 1430 | * @test 1431 | */ 1432 | public function suspending_a_subscription_is_not_possible_when_a_policy_returns_false(): void 1433 | { 1434 | $policy = $this->createMock(SuspendingPolicyInterface::class); 1435 | $policy->expects($this->once())->method('suspend')->willReturn(false); 1436 | $sut = new Subscription(new \DateTimeImmutable()); 1437 | 1438 | $result = $sut->suspend($policy, new \DateTimeImmutable()); 1439 | 1440 | self::assertFalse($result); 1441 | self::assertFalse($sut->isSuspended()); 1442 | } 1443 | 1444 | /** 1445 | * @test 1446 | */ 1447 | public function it_returns_true_when_a_subscription_is_older_than_one_month(): void 1448 | { 1449 | $date = new \DateTimeImmutable(); 1450 | $futureDate = $date->add(new \DateInterval('P1M')); 1451 | $sut = new Subscription($date); 1452 | 1453 | self::assertTrue($sut->isOlderThan($futureDate)); 1454 | } 1455 | 1456 | /** 1457 | * @test 1458 | */ 1459 | public function it_returns_false_when_a_subscription_is_not_older_than_one_month(): void 1460 | { 1461 | $date = new \DateTimeImmutable(); 1462 | $futureDate = $date->add(new \DateInterval('P1D')); 1463 | $sut = new Subscription($date); 1464 | 1465 | self::assertTrue($sut->isOlderThan($futureDate)); 1466 | } 1467 | } 1468 | ``` 1469 | 1470 | > [!ATTENTION] 1471 | > **Do not write code 1:1, 1 class : 1 test. It leads to fragile tests which make that refactoring is tough.** 1472 | 1473 | > [!TIP|style:flat|label:GOOD] 1474 | 1475 | ```php 1476 | final class CannotSuspendExpiredSubscriptionPolicy implements SuspendingPolicyInterface 1477 | { 1478 | public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool 1479 | { 1480 | if ($subscription->isExpired()) { 1481 | return false; 1482 | } 1483 | 1484 | return true; 1485 | } 1486 | } 1487 | ``` 1488 | 1489 | ```php 1490 | final class CannotSuspendNewSubscriptionPolicy implements SuspendingPolicyInterface 1491 | { 1492 | public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool 1493 | { 1494 | if ($subscription->isNew()) { 1495 | return false; 1496 | } 1497 | 1498 | return true; 1499 | } 1500 | } 1501 | ``` 1502 | 1503 | ```php 1504 | final class CanSuspendAfterOneMonthPolicy implements SuspendingPolicyInterface 1505 | { 1506 | public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool 1507 | { 1508 | $oneMonthEarlierDate = \DateTime::createFromImmutable($at)->sub(new \DateInterval('P1M')); 1509 | 1510 | return $subscription->isOlderThan(\DateTimeImmutable::createFromMutable($oneMonthEarlierDate)); 1511 | } 1512 | } 1513 | ``` 1514 | 1515 | ```php 1516 | final class Status 1517 | { 1518 | private const EXPIRED = 'expired'; 1519 | private const ACTIVE = 'active'; 1520 | private const NEW = 'new'; 1521 | private const SUSPENDED = 'suspended'; 1522 | 1523 | private function __construct(private readonly string $status) 1524 | { 1525 | $this->status = $status; 1526 | } 1527 | 1528 | public static function expired(): self 1529 | { 1530 | return new self(self::EXPIRED); 1531 | } 1532 | 1533 | public static function active(): self 1534 | { 1535 | return new self(self::ACTIVE); 1536 | } 1537 | 1538 | public static function new(): self 1539 | { 1540 | return new self(self::NEW); 1541 | } 1542 | 1543 | public static function suspended(): self 1544 | { 1545 | return new self(self::SUSPENDED); 1546 | } 1547 | 1548 | public function isEqual(self $status): bool 1549 | { 1550 | return $this->status === $status->status; 1551 | } 1552 | } 1553 | ``` 1554 | 1555 | ```php 1556 | final class Subscription 1557 | { 1558 | private Status $status; 1559 | 1560 | private \DateTimeImmutable $createdAt; 1561 | 1562 | public function __construct(\DateTimeImmutable $createdAt) 1563 | { 1564 | $this->status = Status::new(); 1565 | $this->createdAt = $createdAt; 1566 | } 1567 | 1568 | public function suspend(SuspendingPolicyInterface $suspendingPolicy, \DateTimeImmutable $at): bool 1569 | { 1570 | $result = $suspendingPolicy->suspend($this, $at); 1571 | if ($result) { 1572 | $this->status = Status::suspended(); 1573 | } 1574 | 1575 | return $result; 1576 | } 1577 | 1578 | public function isOlderThan(\DateTimeImmutable $date): bool 1579 | { 1580 | return $this->createdAt < $date; 1581 | } 1582 | 1583 | public function activate(): void 1584 | { 1585 | $this->status = Status::active(); 1586 | } 1587 | 1588 | public function expire(): void 1589 | { 1590 | $this->status = Status::expired(); 1591 | } 1592 | 1593 | public function isExpired(): bool 1594 | { 1595 | return $this->status->isEqual(Status::expired()); 1596 | } 1597 | 1598 | public function isActive(): bool 1599 | { 1600 | return $this->status->isEqual(Status::active()); 1601 | } 1602 | 1603 | public function isNew(): bool 1604 | { 1605 | return $this->status->isEqual(Status::new()); 1606 | } 1607 | 1608 | public function isSuspended(): bool 1609 | { 1610 | return $this->status->isEqual(Status::suspended()); 1611 | } 1612 | } 1613 | ``` 1614 | 1615 | ```php 1616 | final class SubscriptionSuspendingTest extends TestCase 1617 | { 1618 | /** 1619 | * @test 1620 | */ 1621 | public function suspending_an_expired_subscription_with_cannot_suspend_expired_policy_is_not_possible(): void 1622 | { 1623 | $sut = new Subscription(new \DateTimeImmutable()); 1624 | $sut->activate(); 1625 | $sut->expire(); 1626 | 1627 | $result = $sut->suspend(new CannotSuspendExpiredSubscriptionPolicy(), new \DateTimeImmutable()); 1628 | 1629 | self::assertFalse($result); 1630 | } 1631 | 1632 | /** 1633 | * @test 1634 | */ 1635 | public function suspending_a_new_subscription_with_cannot_suspend_new_policy_is_not_possible(): void 1636 | { 1637 | $sut = new Subscription(new \DateTimeImmutable()); 1638 | 1639 | $result = $sut->suspend(new CannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable()); 1640 | 1641 | self::assertFalse($result); 1642 | } 1643 | 1644 | /** 1645 | * @test 1646 | */ 1647 | public function suspending_an_active_subscription_with_cannot_suspend_new_policy_is_possible(): void 1648 | { 1649 | $sut = new Subscription(new \DateTimeImmutable()); 1650 | $sut->activate(); 1651 | 1652 | $result = $sut->suspend(new CannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable()); 1653 | 1654 | self::assertTrue($result); 1655 | } 1656 | 1657 | /** 1658 | * @test 1659 | */ 1660 | public function suspending_an_active_subscription_with_cannot_suspend_expired_policy_is_possible(): void 1661 | { 1662 | $sut = new Subscription(new \DateTimeImmutable()); 1663 | $sut->activate(); 1664 | 1665 | $result = $sut->suspend(new CannotSuspendExpiredSubscriptionPolicy(), new \DateTimeImmutable()); 1666 | 1667 | self::assertTrue($result); 1668 | } 1669 | 1670 | /** 1671 | * @test 1672 | */ 1673 | public function suspending_an_subscription_before_a_one_month_is_not_possible(): void 1674 | { 1675 | $sut = new Subscription(new \DateTimeImmutable('2020-01-01')); 1676 | 1677 | $result = $sut->suspend(new CanSuspendAfterOneMonthPolicy(), new \DateTimeImmutable('2020-01-10')); 1678 | 1679 | self::assertFalse($result); 1680 | } 1681 | 1682 | /** 1683 | * @test 1684 | */ 1685 | public function suspending_an_subscription_after_a_one_month_is_possible(): void 1686 | { 1687 | $sut = new Subscription(new \DateTimeImmutable('2020-01-01')); 1688 | 1689 | $result = $sut->suspend(new CanSuspendAfterOneMonthPolicy(), new \DateTimeImmutable('2020-02-02')); 1690 | 1691 | self::assertTrue($result); 1692 | } 1693 | } 1694 | ``` 1695 | 1696 | ## Humble pattern 1697 | 1698 | How to properly unit test a class like this? 1699 | 1700 | ```php 1701 | class ApplicationService 1702 | { 1703 | public function __construct( 1704 | private readonly OrderRepository $orderRepository, 1705 | private readonly FormRepository $formRepository 1706 | ) {} 1707 | 1708 | public function changeFormStatus(int $orderId): void 1709 | { 1710 | $order = $this->orderRepository->getById($orderId); 1711 | $soapResponse = $this->getSoapClient()->getStatusByOrderId($orderId); 1712 | $form = $this->formRepository->getByOrderId($orderId); 1713 | $form->setStatus($soapResponse['status']); 1714 | $form->setModifiedAt(new \DateTimeImmutable()); 1715 | 1716 | if ($soapResponse['status'] === 'accepted') { 1717 | $order->setStatus('paid'); 1718 | } 1719 | 1720 | $this->formRepository->save($form); 1721 | $this->orderRepository->save($order); 1722 | } 1723 | 1724 | private function getSoapClient(): \SoapClient 1725 | { 1726 | return new \SoapClient('https://legacy_system.pl/Soap/WebService', []); 1727 | } 1728 | } 1729 | ``` 1730 | 1731 | > [!TIP|style:flat|label:GOOD] 1732 | 1733 | It's required to split up an overcomplicated code to separate classes. 1734 | 1735 | ```php 1736 | final class ApplicationService 1737 | { 1738 | public function __construct( 1739 | private readonly OrderRepositoryInterface $orderRepository, 1740 | private readonly FormRepositoryInterface $formRepository, 1741 | private readonly FormApiInterface $formApi, 1742 | private readonly ChangeFormStatusService $changeFormStatusService 1743 | ) {} 1744 | 1745 | public function changeFormStatus(int $orderId): void 1746 | { 1747 | $order = $this->orderRepository->getById($orderId); 1748 | $form = $this->formRepository->getByOrderId($orderId); 1749 | $status = $this->formApi->getStatusByOrderId($orderId); 1750 | 1751 | $this->changeFormStatusService->changeStatus($order, $form, $status); 1752 | 1753 | $this->formRepository->save($form); 1754 | $this->orderRepository->save($order); 1755 | } 1756 | } 1757 | ``` 1758 | 1759 | ```php 1760 | final class ChangeFormStatusService 1761 | { 1762 | public function changeStatus(Order $order, Form $form, string $formStatus): void 1763 | { 1764 | $status = FormStatus::createFromString($formStatus); 1765 | $form->changeStatus($status); 1766 | 1767 | if ($form->isAccepted()) { 1768 | $order->changeStatus(OrderStatus::paid()); 1769 | } 1770 | } 1771 | } 1772 | ``` 1773 | 1774 | ```php 1775 | final class ChangingFormStatusTest extends TestCase 1776 | { 1777 | /** 1778 | * @test 1779 | */ 1780 | public function changing_a_form_status_to_accepted_changes_an_order_status_to_paid(): void 1781 | { 1782 | $order = new Order(); 1783 | $form = new Form(); 1784 | $status = 'accepted'; 1785 | $sut = new ChangeFormStatusService(); 1786 | 1787 | $sut->changeStatus($order, $form, $status); 1788 | 1789 | self::assertTrue($form->isAccepted()); 1790 | self::assertTrue($order->isPaid()); 1791 | } 1792 | 1793 | /** 1794 | * @test 1795 | */ 1796 | public function changing_a_form_status_to_refused_not_changes_an_order_status(): void 1797 | { 1798 | $order = new Order(); 1799 | $form = new Form(); 1800 | $status = 'new'; 1801 | $sut = new ChangeFormStatusService(); 1802 | 1803 | $sut->changeStatus($order, $form, $status); 1804 | 1805 | self::assertFalse($form->isAccepted()); 1806 | self::assertFalse($order->isPaid()); 1807 | } 1808 | } 1809 | ``` 1810 | 1811 | However, ApplicationService probably should be tested by an integration test with only mocked FormApiInterface. 1812 | 1813 | ## Trivial test 1814 | 1815 | > [!WARNING|style:flat|label:BAD] 1816 | 1817 | ```php 1818 | final class Customer 1819 | { 1820 | public function __construct(private string $name) {} 1821 | 1822 | public function getName(): string 1823 | { 1824 | return $this->name; 1825 | } 1826 | 1827 | public function setName(string $name): void 1828 | { 1829 | $this->name = $name; 1830 | } 1831 | } 1832 | ``` 1833 | 1834 | ```php 1835 | final class CustomerTest extends TestCase 1836 | { 1837 | public function testSetName(): void 1838 | { 1839 | $customer = new Customer('Jack'); 1840 | 1841 | $customer->setName('John'); 1842 | 1843 | self::assertSame('John', $customer->getName()); 1844 | } 1845 | } 1846 | ``` 1847 | 1848 | ```php 1849 | final class EventSubscriber 1850 | { 1851 | public static function getSubscribedEvents(): array 1852 | { 1853 | return ['event' => 'onEvent']; 1854 | } 1855 | 1856 | public function onEvent(): void 1857 | { 1858 | 1859 | } 1860 | } 1861 | ``` 1862 | 1863 | ```php 1864 | final class EventSubscriberTest extends TestCase 1865 | { 1866 | public function testGetSubscribedEvents(): void 1867 | { 1868 | $result = EventSubscriber::getSubscribedEvents(); 1869 | 1870 | self::assertSame(['event' => 'onEvent'], $result); 1871 | } 1872 | } 1873 | ``` 1874 | 1875 | 1876 | > [!ATTENTION] 1877 | > Testing the code without any complicated logic is senseless, but also leads to fragile tests. 1878 | 1879 | ## Fragile test 1880 | 1881 | > [!WARNING|style:flat|label:BAD] 1882 | 1883 | ```php 1884 | final class UserRepository 1885 | { 1886 | public function __construct( 1887 | private readonly Connection $connection 1888 | ) {} 1889 | 1890 | public function getUserNameByEmail(string $email): ?array 1891 | { 1892 | return $this 1893 | ->connection 1894 | ->createQueryBuilder() 1895 | ->from('user', 'u') 1896 | ->where('u.email = :email') 1897 | ->setParameter('email', $email) 1898 | ->execute() 1899 | ->fetch(); 1900 | } 1901 | } 1902 | ``` 1903 | 1904 | ```php 1905 | final class TestUserRepository extends TestCase 1906 | { 1907 | public function testGetUserNameByEmail(): void 1908 | { 1909 | $email = 'test@test.com'; 1910 | $connection = $this->createMock(Connection::class); 1911 | $queryBuilder = $this->createMock(QueryBuilder::class); 1912 | $result = $this->createMock(ResultStatement::class); 1913 | $userRepository = new UserRepository($connection); 1914 | $connection 1915 | ->expects($this->once()) 1916 | ->method('createQueryBuilder') 1917 | ->willReturn($queryBuilder); 1918 | $queryBuilder 1919 | ->expects($this->once()) 1920 | ->method('from') 1921 | ->with('user', 'u') 1922 | ->willReturn($queryBuilder); 1923 | $queryBuilder 1924 | ->expects($this->once()) 1925 | ->method('where') 1926 | ->with('u.email = :email') 1927 | ->willReturn($queryBuilder); 1928 | $queryBuilder 1929 | ->expects($this->once()) 1930 | ->method('setParameter') 1931 | ->with('email', $email) 1932 | ->willReturn($queryBuilder); 1933 | $queryBuilder 1934 | ->expects($this->once()) 1935 | ->method('execute') 1936 | ->willReturn($result); 1937 | $result 1938 | ->expects($this->once()) 1939 | ->method('fetch') 1940 | ->willReturn(['email' => $email]); 1941 | 1942 | $result = $userRepository->getUserNameByEmail($email); 1943 | 1944 | self::assertSame(['email' => $email], $result); 1945 | } 1946 | } 1947 | ``` 1948 | 1949 | > [!ATTENTION] 1950 | > Testing repositories in that way leads to fragile tests and then refactoring is tough. To test repositories write integration tests. 1951 | 1952 | ## Test fixtures 1953 | 1954 | > [!TIP|style:flat|label:GOOD] 1955 | 1956 | ```php 1957 | final class GoodTest extends TestCase 1958 | { 1959 | private SubscriptionFactory $sut; 1960 | 1961 | public function setUp(): void 1962 | { 1963 | $this->sut = new SubscriptionFactory(); 1964 | } 1965 | 1966 | /** 1967 | * @test 1968 | */ 1969 | public function creates_a_subscription_for_a_given_date_range(): void 1970 | { 1971 | $result = $this->sut->create(new \DateTimeImmutable(), new \DateTimeImmutable('now +1 year')); 1972 | 1973 | self::assertInstanceOf(Subscription::class, $result); 1974 | } 1975 | 1976 | /** 1977 | * @test 1978 | */ 1979 | public function throws_an_exception_on_invalid_date_range(): void 1980 | { 1981 | $this->expectException(CreateSubscriptionException::class); 1982 | 1983 | $result = $this->sut->create(new \DateTimeImmutable('now -1 year'), new \DateTimeImmutable()); 1984 | } 1985 | } 1986 | ``` 1987 | 1988 | > [!NOTE] 1989 | > - The best case for using the setUp method will be testing stateless objects. 1990 | > - Any configuration made inside `setUp` couples tests together, and has impact on all tests. 1991 | > - It's better to avoid a shared state between tests and configure the initial state accordingly to test method. 1992 | > - Readability is worse compared to configuration made in the proper test method. 1993 | 1994 | > [!TIP|style:flat|label:BETTER] 1995 | 1996 | ```php 1997 | final class BetterTest extends TestCase 1998 | { 1999 | /** 2000 | * @test 2001 | */ 2002 | public function suspending_an_active_subscription_with_cannot_suspend_new_policy_is_possible(): void 2003 | { 2004 | $sut = $this->createAnActiveSubscription(); 2005 | 2006 | $result = $sut->suspend(new CannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable()); 2007 | 2008 | self::assertTrue($result); 2009 | } 2010 | 2011 | /** 2012 | * @test 2013 | */ 2014 | public function suspending_an_active_subscription_with_cannot_suspend_expired_policy_is_possible(): void 2015 | { 2016 | $sut = $this->createAnActiveSubscription(); 2017 | 2018 | $result = $sut->suspend(new CannotSuspendExpiredSubscriptionPolicy(), new \DateTimeImmutable()); 2019 | 2020 | self::assertTrue($result); 2021 | } 2022 | 2023 | /** 2024 | * @test 2025 | */ 2026 | public function suspending_a_new_subscription_with_cannot_suspend_new_policy_is_not_possible(): void 2027 | { 2028 | $sut = $this->createANewSubscription(); 2029 | 2030 | $result = $sut->suspend(new CannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable()); 2031 | 2032 | self::assertFalse($result); 2033 | } 2034 | 2035 | private function createANewSubscription(): Subscription 2036 | { 2037 | return new Subscription(new \DateTimeImmutable()); 2038 | } 2039 | 2040 | private function createAnActiveSubscription(): Subscription 2041 | { 2042 | $subscription = new Subscription(new \DateTimeImmutable()); 2043 | $subscription->activate(); 2044 | 2045 | return $subscription; 2046 | } 2047 | } 2048 | ``` 2049 | 2050 | > [!NOTE] 2051 | > - This approach improves readability and clarifies the separation (code is more read than written). 2052 | > - Private helpers can be tedious to use in each test method, although they provide explicit intentions. 2053 | 2054 | > To share similar testing objects between multiple test classes use: 2055 | > - [Object mother](#object-mother) 2056 | > - [Builder](#builder) 2057 | 2058 | ## General testing anti-patterns 2059 | 2060 | ### Exposing private state 2061 | 2062 | > [!WARNING|style:flat|label:BAD] 2063 | 2064 | ```php 2065 | final class Customer 2066 | { 2067 | private CustomerType $type; 2068 | 2069 | private DiscountCalculationPolicyInterface $discountCalculationPolicy; 2070 | 2071 | public function __construct() 2072 | { 2073 | $this->type = CustomerType::NORMAL(); 2074 | $this->discountCalculationPolicy = new NormalDiscountPolicy(); 2075 | } 2076 | 2077 | public function makeVip(): void 2078 | { 2079 | $this->type = CustomerType::VIP(); 2080 | $this->discountCalculationPolicy = new VipDiscountPolicy(); 2081 | } 2082 | 2083 | public function getCustomerType(): CustomerType 2084 | { 2085 | return $this->type; 2086 | } 2087 | 2088 | public function getPercentageDiscount(): int 2089 | { 2090 | return $this->discountCalculationPolicy->getPercentageDiscount(); 2091 | } 2092 | } 2093 | ``` 2094 | 2095 | ```php 2096 | final class InvalidTest extends TestCase 2097 | { 2098 | public function testMakeVip(): void 2099 | { 2100 | $sut = new Customer(); 2101 | $sut->makeVip(); 2102 | 2103 | self::assertSame(CustomerType::VIP(), $sut->getCustomerType()); 2104 | } 2105 | } 2106 | ``` 2107 | 2108 | > [!TIP|style:flat|label:GOOD] 2109 | 2110 | ```php 2111 | final class Customer 2112 | { 2113 | private CustomerType $type; 2114 | 2115 | private DiscountCalculationPolicyInterface $discountCalculationPolicy; 2116 | 2117 | public function __construct() 2118 | { 2119 | $this->type = CustomerType::NORMAL(); 2120 | $this->discountCalculationPolicy = new NormalDiscountPolicy(); 2121 | } 2122 | 2123 | public function makeVip(): void 2124 | { 2125 | $this->type = CustomerType::VIP(); 2126 | $this->discountCalculationPolicy = new VipDiscountPolicy(); 2127 | } 2128 | 2129 | public function getPercentageDiscount(): int 2130 | { 2131 | return $this->discountCalculationPolicy->getPercentageDiscount(); 2132 | } 2133 | } 2134 | ``` 2135 | 2136 | ```php 2137 | final class ValidTest extends TestCase 2138 | { 2139 | /** 2140 | * @test 2141 | */ 2142 | public function a_vip_customer_has_a_25_percentage_discount(): void 2143 | { 2144 | $sut = new Customer(); 2145 | $sut->makeVip(); 2146 | 2147 | self::assertSame(25, $sut->getPercentageDiscount()); 2148 | } 2149 | } 2150 | ``` 2151 | 2152 | 2153 | > [!ATTENTION] 2154 | > Adding additional production code (e.g. getter getCustomerType()) only to verify the state in tests is a bad practice. 2155 | > It should be verified by another domain significant value (in this case getPercentageDiscount()). Of course, sometimes it can be tough to find another way to verify the operation, and we can be forced to add additional production code to verify correctness in tests, but we should try to avoid that. 2156 | 2157 | ### Leaking domain details 2158 | 2159 | ```php 2160 | final class DiscountCalculator 2161 | { 2162 | public function calculate(int $isVipFromYears): int 2163 | { 2164 | Assert::greaterThanEq($isVipFromYears, 0); 2165 | return min(($isVipFromYears * 10) + 3, 80); 2166 | } 2167 | } 2168 | ``` 2169 | 2170 | > [!WARNING|style:flat|label:BAD] 2171 | 2172 | ```php 2173 | final class InvalidTest extends TestCase 2174 | { 2175 | /** 2176 | * @dataProvider discountDataProvider 2177 | */ 2178 | public function testCalculate(int $vipDaysFrom, int $expected): void 2179 | { 2180 | $sut = new DiscountCalculator(); 2181 | 2182 | self::assertSame($expected, $sut->calculate($vipDaysFrom)); 2183 | } 2184 | 2185 | public function discountDataProvider(): array 2186 | { 2187 | return [ 2188 | [0, 0 * 10 + 3], //leaking domain details 2189 | [1, 1 * 10 + 3], 2190 | [5, 5 * 10 + 3], 2191 | [8, 80] 2192 | ]; 2193 | } 2194 | } 2195 | ``` 2196 | > [!TIP|style:flat|label:GOOD] 2197 | 2198 | ```php 2199 | final class ValidTest extends TestCase 2200 | { 2201 | /** 2202 | * @dataProvider discountDataProvider 2203 | */ 2204 | public function testCalculate(int $vipDaysFrom, int $expected): void 2205 | { 2206 | $sut = new DiscountCalculator(); 2207 | 2208 | self::assertSame($expected, $sut->calculate($vipDaysFrom)); 2209 | } 2210 | 2211 | public function discountDataProvider(): array 2212 | { 2213 | return [ 2214 | [0, 3], 2215 | [1, 13], 2216 | [5, 53], 2217 | [8, 80] 2218 | ]; 2219 | } 2220 | } 2221 | ``` 2222 | 2223 | 2224 | > [!NOTE] 2225 | > Don't duplicate the production logic in tests. Just verify results by hardcoded values. 2226 | 2227 | ### Mocking concrete classes 2228 | 2229 | > [!WARNING|style:flat|label:BAD] 2230 | 2231 | ```php 2232 | class DiscountCalculator 2233 | { 2234 | public function calculateInternalDiscount(int $isVipFromYears): int 2235 | { 2236 | Assert::greaterThanEq($isVipFromYears, 0); 2237 | return min(($isVipFromYears * 10) + 3, 80); 2238 | } 2239 | 2240 | public function calculateAdditionalDiscountFromExternalSystem(): int 2241 | { 2242 | // get data from an external system to calculate a discount 2243 | return 5; 2244 | } 2245 | } 2246 | ``` 2247 | 2248 | ```php 2249 | class OrderService 2250 | { 2251 | public function __construct(private readonly DiscountCalculator $discountCalculator) {} 2252 | 2253 | public function getTotalPriceWithDiscount(int $totalPrice, int $vipFromDays): int 2254 | { 2255 | $internalDiscount = $this->discountCalculator->calculateInternalDiscount($vipFromDays); 2256 | $externalDiscount = $this->discountCalculator->calculateAdditionalDiscountFromExternalSystem(); 2257 | $discountSum = $internalDiscount + $externalDiscount; 2258 | return $totalPrice - (int) ceil(($totalPrice * $discountSum) / 100); 2259 | } 2260 | } 2261 | ``` 2262 | 2263 | ```php 2264 | final class InvalidTest extends TestCase 2265 | { 2266 | /** 2267 | * @dataProvider orderDataProvider 2268 | */ 2269 | public function testGetTotalPriceWithDiscount(int $totalPrice, int $vipDaysFrom, int $expected): void 2270 | { 2271 | $discountCalculator = $this->createPartialMock(DiscountCalculator::class, ['calculateAdditionalDiscountFromExternalSystem']); 2272 | $discountCalculator->method('calculateAdditionalDiscountFromExternalSystem')->willReturn(5); 2273 | $sut = new OrderService($discountCalculator); 2274 | 2275 | self::assertSame($expected, $sut->getTotalPriceWithDiscount($totalPrice, $vipDaysFrom)); 2276 | } 2277 | 2278 | public function orderDataProvider(): array 2279 | { 2280 | return [ 2281 | [1000, 0, 920], 2282 | [500, 1, 410], 2283 | [644, 5, 270], 2284 | ]; 2285 | } 2286 | } 2287 | ``` 2288 | 2289 | > [!TIP|style:flat|label:GOOD] 2290 | 2291 | 2292 | ```php 2293 | interface ExternalDiscountCalculatorInterface 2294 | { 2295 | public function calculate(): int; 2296 | } 2297 | ``` 2298 | 2299 | ```php 2300 | final class InternalDiscountCalculator 2301 | { 2302 | public function calculate(int $isVipFromYears): int 2303 | { 2304 | Assert::greaterThanEq($isVipFromYears, 0); 2305 | return min(($isVipFromYears * 10) + 3, 80); 2306 | } 2307 | } 2308 | ``` 2309 | 2310 | ```php 2311 | final class OrderService 2312 | { 2313 | public function __construct( 2314 | private readonly InternalDiscountCalculator $discountCalculator, 2315 | private readonly ExternalDiscountCalculatorInterface $externalDiscountCalculator 2316 | ) {} 2317 | 2318 | public function getTotalPriceWithDiscount(int $totalPrice, int $vipFromDays): int 2319 | { 2320 | $internalDiscount = $this->discountCalculator->calculate($vipFromDays); 2321 | $externalDiscount = $this->externalDiscountCalculator->calculate(); 2322 | $discountSum = $internalDiscount + $externalDiscount; 2323 | return $totalPrice - (int) ceil(($totalPrice * $discountSum) / 100); 2324 | } 2325 | } 2326 | ``` 2327 | 2328 | ```php 2329 | final class ValidTest extends TestCase 2330 | { 2331 | /** 2332 | * @dataProvider orderDataProvider 2333 | */ 2334 | public function testGetTotalPriceWithDiscount(int $totalPrice, int $vipDaysFrom, int $expected): void 2335 | { 2336 | $externalDiscountCalculator = new class() implements ExternalDiscountCalculatorInterface { 2337 | public function calculate(): int 2338 | { 2339 | return 5; 2340 | } 2341 | }; 2342 | $sut = new OrderService(new InternalDiscountCalculator(), $externalDiscountCalculator); 2343 | 2344 | self::assertSame($expected, $sut->getTotalPriceWithDiscount($totalPrice, $vipDaysFrom)); 2345 | } 2346 | 2347 | public function orderDataProvider(): array 2348 | { 2349 | return [ 2350 | [1000, 0, 920], 2351 | [500, 1, 410], 2352 | [644, 5, 270], 2353 | ]; 2354 | } 2355 | } 2356 | ``` 2357 | 2358 | > [!NOTE] 2359 | > The necessity to mock a concrete class to replace a part of its behavior means that this class is probably too complicated and violates the Single Responsibility Principle. 2360 | 2361 | ### Testing private methods 2362 | 2363 | ```php 2364 | final class OrderItem 2365 | { 2366 | public function __construct(public readonly int $total) {} 2367 | } 2368 | ``` 2369 | 2370 | ```php 2371 | final class Order 2372 | { 2373 | /** 2374 | * @param OrderItem[] $items 2375 | * @param int $transportCost 2376 | */ 2377 | public function __construct(private array $items, private int $transportCost) {} 2378 | 2379 | public function getTotal(): int 2380 | { 2381 | return $this->getItemsTotal() + $this->transportCost; 2382 | } 2383 | 2384 | private function getItemsTotal(): int 2385 | { 2386 | return array_reduce( 2387 | array_map(fn (OrderItem $item) => $item->total, $this->items), 2388 | fn (int $sum, int $total) => $sum += $total, 2389 | 0 2390 | ); 2391 | } 2392 | } 2393 | ``` 2394 | 2395 | > [!WARNING|style:flat|label:BAD] 2396 | 2397 | 2398 | ```php 2399 | final class InvalidTest extends TestCase 2400 | { 2401 | /** 2402 | * @test 2403 | * @dataProvider ordersDataProvider 2404 | */ 2405 | public function get_total_returns_a_total_cost_of_a_whole_order(Order $order, int $expectedTotal): void 2406 | { 2407 | self::assertSame($expectedTotal, $order->getTotal()); 2408 | } 2409 | 2410 | /** 2411 | * @test 2412 | * @dataProvider orderItemsDataProvider 2413 | */ 2414 | public function get_items_total_returns_a_total_cost_of_all_items(Order $order, int $expectedTotal): void 2415 | { 2416 | self::assertSame($expectedTotal, $this->invokePrivateMethodGetItemsTotal($order)); 2417 | } 2418 | 2419 | public function ordersDataProvider(): array 2420 | { 2421 | return [ 2422 | [new Order([new OrderItem(20), new OrderItem(20), new OrderItem(20)], 15), 75], 2423 | [new Order([new OrderItem(20), new OrderItem(30), new OrderItem(40)], 0), 90], 2424 | [new Order([new OrderItem(99), new OrderItem(99), new OrderItem(99)], 9), 306] 2425 | ]; 2426 | } 2427 | 2428 | public function orderItemsDataProvider(): array 2429 | { 2430 | return [ 2431 | [new Order([new OrderItem(20), new OrderItem(20), new OrderItem(20)], 15), 60], 2432 | [new Order([new OrderItem(20), new OrderItem(30), new OrderItem(40)], 0), 90], 2433 | [new Order([new OrderItem(99), new OrderItem(99), new OrderItem(99)], 9), 297] 2434 | ]; 2435 | } 2436 | 2437 | private function invokePrivateMethodGetItemsTotal(Order &$order): int 2438 | { 2439 | $reflection = new \ReflectionClass(get_class($order)); 2440 | $method = $reflection->getMethod('getItemsTotal'); 2441 | $method->setAccessible(true); 2442 | return $method->invokeArgs($order, []); 2443 | } 2444 | } 2445 | ``` 2446 | 2447 | > [!TIP|style:flat|label:GOOD] 2448 | 2449 | ```php 2450 | final class ValidTest extends TestCase 2451 | { 2452 | /** 2453 | * @test 2454 | * @dataProvider ordersDataProvider 2455 | */ 2456 | public function get_total_returns_a_total_cost_of_a_whole_order(Order $order, int $expectedTotal): void 2457 | { 2458 | self::assertSame($expectedTotal, $order->getTotal()); 2459 | } 2460 | 2461 | public function ordersDataProvider(): array 2462 | { 2463 | return [ 2464 | [new Order([new OrderItem(20), new OrderItem(20), new OrderItem(20)], 15), 75], 2465 | [new Order([new OrderItem(20), new OrderItem(30), new OrderItem(40)], 0), 90], 2466 | [new Order([new OrderItem(99), new OrderItem(99), new OrderItem(99)], 9), 306] 2467 | ]; 2468 | } 2469 | } 2470 | ``` 2471 | 2472 | > [!ATTENTION] 2473 | > Tests should only verify public API. 2474 | 2475 | ### Time as a volatile dependency 2476 | 2477 | The time is a volatile dependency because it is non-deterministic. Each invocation returns a different result. 2478 | 2479 | > [!WARNING|style:flat|label:BAD] 2480 | 2481 | 2482 | ```php 2483 | final class Clock 2484 | { 2485 | public static \DateTime|null $currentDateTime = null; 2486 | 2487 | public static function getCurrentDateTime(): \DateTime 2488 | { 2489 | if (null === self::$currentDateTime) { 2490 | self::$currentDateTime = new \DateTime(); 2491 | } 2492 | 2493 | return self::$currentDateTime; 2494 | } 2495 | 2496 | public static function set(\DateTime $dateTime): void 2497 | { 2498 | self::$currentDateTime = $dateTime; 2499 | } 2500 | 2501 | public static function reset(): void 2502 | { 2503 | self::$currentDateTime = null; 2504 | } 2505 | } 2506 | ``` 2507 | 2508 | ```php 2509 | final class Customer 2510 | { 2511 | private \DateTime $createdAt; 2512 | 2513 | public function __construct() 2514 | { 2515 | $this->createdAt = Clock::getCurrentDateTime(); 2516 | } 2517 | 2518 | public function isVip(): bool 2519 | { 2520 | return $this->createdAt->diff(Clock::getCurrentDateTime())->y >= 1; 2521 | } 2522 | } 2523 | ``` 2524 | 2525 | ```php 2526 | final class InvalidTest extends TestCase 2527 | { 2528 | /** 2529 | * @test 2530 | */ 2531 | public function a_customer_registered_more_than_a_one_year_ago_is_a_vip(): void 2532 | { 2533 | Clock::set(new \DateTime('2019-01-01')); 2534 | $sut = new Customer(); 2535 | Clock::reset(); // you have to remember about resetting the shared state 2536 | 2537 | self::assertTrue($sut->isVip()); 2538 | } 2539 | 2540 | /** 2541 | * @test 2542 | */ 2543 | public function a_customer_registered_less_than_a_one_year_ago_is_not_a_vip(): void 2544 | { 2545 | Clock::set((new \DateTime())->sub(new \DateInterval('P2M'))); 2546 | $sut = new Customer(); 2547 | Clock::reset(); // you have to remember about resetting the shared state 2548 | 2549 | self::assertFalse($sut->isVip()); 2550 | } 2551 | } 2552 | ``` 2553 | 2554 | > [!TIP|style:flat|label:GOOD] 2555 | 2556 | 2557 | ```php 2558 | interface ClockInterface 2559 | { 2560 | public function getCurrentTime(): \DateTimeImmutable; 2561 | } 2562 | ``` 2563 | 2564 | ```php 2565 | final class Clock implements ClockInterface 2566 | { 2567 | private function __construct() 2568 | { 2569 | } 2570 | 2571 | public static function create(): self 2572 | { 2573 | return new self(); 2574 | } 2575 | 2576 | public function getCurrentTime(): \DateTimeImmutable 2577 | { 2578 | return new \DateTimeImmutable(); 2579 | } 2580 | } 2581 | ``` 2582 | 2583 | ```php 2584 | final class FixedClock implements ClockInterface 2585 | { 2586 | private function __construct(private readonly \DateTimeImmutable $fixedDate) {} 2587 | 2588 | public static function create(\DateTimeImmutable $fixedDate): self 2589 | { 2590 | return new self($fixedDate); 2591 | } 2592 | 2593 | public function getCurrentTime(): \DateTimeImmutable 2594 | { 2595 | return $this->fixedDate; 2596 | } 2597 | } 2598 | ``` 2599 | 2600 | ```php 2601 | final class Customer 2602 | { 2603 | public function __construct(private readonly \DateTimeImmutable $createdAt) {} 2604 | 2605 | public function isVip(\DateTimeImmutable $currentDate): bool 2606 | { 2607 | return $this->createdAt->diff($currentDate)->y >= 1; 2608 | } 2609 | } 2610 | ``` 2611 | 2612 | ```php 2613 | final class ValidTest extends TestCase 2614 | { 2615 | /** 2616 | * @test 2617 | */ 2618 | public function a_customer_registered_more_than_a_one_year_ago_is_a_vip(): void 2619 | { 2620 | $sut = new Customer(FixedClock::create(new \DateTimeImmutable('2019-01-01'))->getCurrentTime()); 2621 | 2622 | self::assertTrue($sut->isVip(FixedClock::create(new \DateTimeImmutable('2020-01-02'))->getCurrentTime())); 2623 | } 2624 | 2625 | /** 2626 | * @test 2627 | */ 2628 | public function a_customer_registered_less_than_a_one_year_ago_is_not_a_vip(): void 2629 | { 2630 | $sut = new Customer(FixedClock::create(new \DateTimeImmutable('2019-01-01'))->getCurrentTime()); 2631 | 2632 | self::assertFalse($sut->isVip(FixedClock::create(new \DateTimeImmutable('2019-05-02'))->getCurrentTime())); 2633 | } 2634 | } 2635 | ``` 2636 | 2637 | > [!NOTE] 2638 | > The time and random numbers should not be generated directly in the domain code. To test behavior we must have 2639 | deterministic results, so we need to inject these values into a domain object like in the example above. 2640 | 2641 | ## 100% Test Coverage shouldn't be the goal 2642 | 2643 | 100% Coverage is not the goal or even is undesirable because if there is 100% coverage, tests probably will be very fragile, which means refactoring will be very hard. 2644 | Mutation testing gives better feedback about the quality of tests. 2645 | [Read more](https://sarvendev.com/en/2019/06/mutation-testing-we-are-testing-tests/) 2646 | 2647 | ## Recommended books 2648 | 2649 | - [Test Driven Development: By Example / Kent Beck](https://amzn.to/3HImbax) - the classic 2650 | - [Unit Testing Principles, Practices, and Patterns / Vladimir Khorikov](https://amzn.to/3PCMD7f) - the best book about tests I've ever read 2651 | 2652 | ## Author 2653 | :construction_worker: **Kamil Ruczyński** 2654 | 2655 | **Twitter:** [https://twitter.com/Sarvendev](https://twitter.com/Sarvendev) 2656 | **Blog:** [https://sarvendev.com/](https://sarvendev.com/) 2657 | **LinkedIn:** [https://www.linkedin.com/in/kamilruczynski/](https://www.linkedin.com/in/kamilruczynski/) 2658 | -------------------------------------------------------------------------------- /assets/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarven/unit-testing-tips/4c6cf0c2049f911b5657e305a0522884b7f743d1/assets/android-chrome-192x192.png -------------------------------------------------------------------------------- /assets/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarven/unit-testing-tips/4c6cf0c2049f911b5657e305a0522884b7f743d1/assets/android-chrome-512x512.png -------------------------------------------------------------------------------- /assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarven/unit-testing-tips/4c6cf0c2049f911b5657e305a0522884b7f743d1/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /assets/bmc-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarven/unit-testing-tips/4c6cf0c2049f911b5657e305a0522884b7f743d1/assets/bmc-button.png -------------------------------------------------------------------------------- /assets/ebook-unit-testing-tips.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarven/unit-testing-tips/4c6cf0c2049f911b5657e305a0522884b7f743d1/assets/ebook-unit-testing-tips.png -------------------------------------------------------------------------------- /assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarven/unit-testing-tips/4c6cf0c2049f911b5657e305a0522884b7f743d1/assets/favicon-16x16.png -------------------------------------------------------------------------------- /assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarven/unit-testing-tips/4c6cf0c2049f911b5657e305a0522884b7f743d1/assets/favicon-32x32.png -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarven/unit-testing-tips/4c6cf0c2049f911b5657e305a0522884b7f743d1/assets/favicon.ico -------------------------------------------------------------------------------- /assets/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/assets/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/assets/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /assets/test-doubles.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sarven/unit-testing-tips/4c6cf0c2049f911b5657e305a0522884b7f743d1/assets/test-doubles.jpg -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Testing tips 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 |
22 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | --------------------------------------------------------------------------------