Skip to content

Latest commit

History

95 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

GitHub starsGitHub watchersGitHub forksGitHub contributors

Testing tips

In these times, the benefits of writing unit tests are huge. I think that most of the recently started projects contain any unit tests. In enterprise applications with a lot of business logic, unit tests are the most important tests, because they are fast and can instantly assure us that our implementation is correct. However, I often see a problem with good tests in projects, though these tests' benefits are only huge when you have good unit tests. So in these examples, I will try to share some tips on what to do to write good unit tests.

Easy-to-read version:https://testing-tips.sarvendev.com/

Author

👷 Kamil Ruczyński

TwitterGithub

Blog:https://sarvendev.com/
LinkedIn:https://www.linkedin.com/in/kamilruczynski/

Support

Your support means the world to me! If you've enjoyed this guide and find value in the knowledge shared, consider supporting me on BuyMeCoffee:

BuyMeCoffee

or simply leaving a star on the repository and following me on Twitter and Github to be up-to-date with all updates. Your generosity fuels my passion for creating more insightful content for you.

If you have any improvement ideas or a topic to write about, feel free to prepare a pull request or just let me know.

Free ebook – Unit testing tips

FreeEbookUnitTestingTips

Subscribe and master unit testing with my FREE eBook! 🚀
👉 Details

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.

Table of Contents

  1. Introduction
  2. Author
  3. Test doubles
  4. Naming
  5. AAA pattern
  6. Object mother
  7. Builder
  8. Assert object
  9. Parameterized test
  10. Two schools of unit testing
  11. Mock vs Stub
  12. Three styles of unit testing
  13. Functional architecture and tests
  14. Observable behavior vs implementation details
  15. Unit of behavior
  16. Humble pattern
  17. Trivial test
  18. Fragile test
  19. Test fixtures
  20. General testing anti-patterns
  21. 100% Test Coverage shouldn't be the goal
  22. Recommended books

Test doubles

Test doubles are fake dependencies used in tests.

Test doubles

Stubs

Dummy

A dummy is a just simple implementation that does nothing.

finalclass Mailer implements MailerInterface
{
publicfunctionsend(Message$message): void
{
}
}

Fake

A fake is a simplified implementation to simulate the original behavior.

finalclass InMemoryCustomerRepository implements CustomerRepositoryInterface
{
/** * @var Customer[] */privatearray$customers;
publicfunction__construct()
{
$this->customers = [];
}
publicfunctionstore(Customer$customer): void
{
$this->customers[(string) $customer->id()->id()] = $customer;
}
publicfunctionget(CustomerId$id): Customer
{
if (!isset($this->customers[(string) $id->id()])) {
thrownewCustomerNotFoundException();
}
return$this->customers[(string) $id->id()];
}
publicfunctionfindByEmail(Email$email): Customer
{
foreach ($this->customersas$customer) {
if ($customer->getEmail()->isEqual($email)) {
return$customer;
}
}
thrownewCustomerNotFoundException();
}
}

Stub

A stub is the simplest implementation with a hardcoded behavior.

finalclass UniqueEmailSpecificationStub implements UniqueEmailSpecificationInterface
{
publicfunctionisUnique(Email$email): bool
{
returntrue;
}
}
$specificationStub = $this->createStub(UniqueEmailSpecificationInterface::class);
$specificationStub->method('isUnique')->willReturn(true);

Mocks

Spy

A spy is an implementation to verify a specific behavior.

finalclass Mailer implements MailerInterface
{
/** * @var Message[] */privatearray$messages;
publicfunction__construct()
{
$this->messages = [];
}
publicfunctionsend(Message$message): void
{
$this->messages[] = $message;
}
publicfunctiongetCountOfSentMessages(): int
{
returncount($this->messages);
}
}

Mock

A mock is a configured imitation to verify calls on a collaborator.

$message = newMessage('test@test.com', 'Test', 'Test test test');
$mailer = $this->createMock(MailerInterface::class);
$mailer
->expects($this->once())
->method('send')
->with($this->equalTo($message));

[!ATTENTION] To verify incoming interactions, use a stub, but to verify outcoming interactions, use a mock.
More: Mock vs Stub

Always prefer own test double classes than those provided by a framework

[!WARNING|style:flat|label:NOT GOOD]

finalclass TestExample extends TestCase
{
/** * @test */publicfunctionsends_all_notifications(): void
{
$message1 = newMessage();
$message2 = newMessage();
$messageRepository = $this->createMock(MessageRepositoryInterface::class);
$messageRepository->method('getAll')->willReturn([$message1, $message2]);
$mailer = $this->createMock(MailerInterface::class);
$sut = newNotificationService($mailer, $messageRepository);
$mailer->expects(self::exactly(2))->method('send')
->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);
$sut->send();
}
}

[!TIP|style:flat|label:BETTER]

  • Better resistance to refactoring
    • Using Refactor->Rename on the particular method doesn't break the test
  • Better readability
  • Lower cost of maintainability
    • Not required to learn those sophisticated mocks frameworks
    • Just simple plain PHP code
finalclass TestExample extends TestCase
{
/** * @test */publicfunctionsends_all_notifications(): void
{
$message1 = newMessage();
$message2 = newMessage();
$messageRepository = newInMemoryMessageRepository();
$messageRepository->save($message1);
$messageRepository->save($message2);
$mailer = newSpyMailer();
$sut = newNotificationService($mailer, $messageRepository);
$sut->send();
$mailer->assertThatMessagesHaveBeenSent([$message1, $message2]);
}
}

Naming

[!WARNING|style:flat|label:NOT GOOD]

publicfunctiontest(): void
{
$subscription = SubscriptionMother::new();
$subscription->activate();
self::assertSame(Status::activated(), $subscription->status());
}

[!TIP|style:flat|label:Specify explicitly what you are testing]

publicfunctionsut(): void
{
// sut = System under test$sut = SubscriptionMother::new();
$sut->activate();
self::assertSame(Status::activated(), $sut->status());
}

[!WARNING|style:flat|label:NOT GOOD]

publicfunctionit_throws_invalid_credentials_exception_when_sign_in_with_invalid_credentials(): void
{
}
publicfunctiontestCreatingWithATooShortPasswordIsNotPossible(): void
{
}
publicfunctiontestDeactivateASubscription(): void
{
}

[!TIP|style:flat|label:BETTER]

  • Using underscore improves readability
  • The name should describe the behavior, not the implementation
  • Use names without technical keywords. It should be readable for a non-programmer person.
publicfunctionsign_in_with_invalid_credentials_is_not_possible(): void
{
}
publicfunctioncreating_with_a_too_short_password_is_not_possible(): void
{
}
publicfunctiondeactivating_an_activated_subscription_is_valid(): void
{
}
publicfunctiondeactivating_an_inactive_subscription_is_invalid(): void
{
}

Note

Describing the behavior is important in testing the domain scenarios. If your code is just a utility one it's less important.

Why would it be useful for a non-programmer to read unit tests?

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. 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.

AAA pattern

It's also common Given, When, Then.

Separate three sections of the test:

  • Arrange: Bring the system under test in the desired state. Prepare dependencies, arguments and finally construct the SUT.
  • Act: Invoke a tested element.
  • Assert: Verify the result, the final state, or the communication with collaborators.

[!TIP|style:flat|label:GOOD]

publicfunctionaaa_pattern_example_test(): void
{
//Arrange|Given$sut = SubscriptionMother::new();
//Act|When$sut->activate();
//Assert|Thenself::assertSame(Status::activated(), $sut->status());
}

Object mother

The pattern helps to create specific objects which can be reused in a few tests. Because of that the arrange section is concise and the test as a whole is more readable.

finalclass SubscriptionMother
{
publicstaticfunctionnew(): Subscription
{
returnnewSubscription();
}
publicstaticfunctionactivated(): Subscription
{
$subscription = newSubscription();
$subscription->activate();
return$subscription;
}
publicstaticfunctiondeactivated(): Subscription
{
$subscription = self::activated();
$subscription->deactivate();
return$subscription;
}
}
finalclass ExampleTest
{
publicfunctionexample_test_with_activated_subscription(): void
{
$activatedSubscription = SubscriptionMother::activated();
// do something// check something
}
publicfunctionexample_test_with_deactivated_subscription(): void
{
$deactivatedSubscription = SubscriptionMother::deactivated();
// do something// check something
}
}

Builder

Builder is another pattern that helps us to create objects in tests. Compared to Object Mother pattern Builder is better for creating more complex objects.

finalclass OrderBuilder
{
privateDateTimeImmutable|null$createdAt = null;
/** * @var OrderItem[] */privatearray$items = [];
publicfunctioncreatedAt(DateTimeImmutable$createdAt): self
{
$this->createdAt = $createdAt;
return$this;
}
publicfunctionwithItem(string$name, int$price): self
{
$this->items[] = newOrderItem($name, $price);
return$this;
}
publicfunctionbuild(): Order
{
Assert::notEmpty($this->items);
returnnewOrder(
$this->createdAt ?? newDateTimeImmutable(),
$this->items,
);
}
}
finalclass ExampleTest extends TestCase
{
/** * @test */publicfunctionexample_test_with_order_builder(): void
{
$order = (newOrderBuilder())
->createdAt(newDateTimeImmutable('2022-11-10 20:00:00'))
->withItem('Item 1', 1000)
->withItem('Item 2', 2000)
->withItem('Item 3', 3000)
->build();
// do something// check something
}
}

Assert object

Assert object pattern helps write more readable assert sections. Instead of using a few asserts, we can just prepare an abstraction, and use natural language to describe what result is expected.

finalclass ExampleTest extends TestCase
{
/** * @test */publicfunctionexample_test_with_asserter(): void
{
$currentTime = newDateTimeImmutable('2022-11-10 20:00:00');
$sut = newOrderService();
$order = $sut->create($currentTime);
OrderAsserter::assertThat($order)
->wasCreatedAt($currentTime)
->hasTotal(6000);
}
}
usePHPUnit\Framework\Assert;
finalclass OrderAsserter
{
publicfunction__construct(privatereadonlyOrder$order) {}
publicstaticfunctionassertThat(Order$order): self
{
returnnewOrderAsserter($order);
}
publicfunctionwasCreatedAt(DateTimeImmutable$createdAt): self
{
Assert::assertEquals($createdAt, $this->order->createdAt);
return$this;
}
publicfunctionhasTotal(int$total): self
{
Assert::assertSame($total, $this->order->getTotal());
return$this;
}
}

Parameterized test

The parameterized test is a good option to test the SUT with many parameters without repeating the code.

Warning

👎 This kind of test is less readable. To increase the readability a little, negative and positive examples should be split up to different tests.

finalclass ExampleTest extends TestCase
{
/** * @test * @dataProvider getInvalidEmails */publicfunctiondetects_an_invalid_email_address(string$email): void
{
$sut = newEmailValidator();
$result = $sut->isValid($email);
self::assertFalse($result);
}
/** * @test * @dataProvider getValidEmails */publicfunctiondetects_an_valid_email_address(string$email): void
{
$sut = newEmailValidator();
$result = $sut->isValid($email);
self::assertTrue($result);
}
publicfunctiongetInvalidEmails(): iterable
{
yield'An invalid email without @' => ['test'];
yield'An invalid email without the domain after @' => ['test@'];
yield'An invalid email without TLD' => ['test@test'];
//...
}
publicfunctiongetValidEmails(): iterable
{
yield'A valid email with lowercase letters' => ['test@test.com'];
yield'A valid email with lowercase letters and digits' => ['test123@test.com'];
yield'A valid email with uppercase letters and digits' => ['Test123@test.com'];
//...
}
}

Note

Use yield and add a text description to cases to improve the readability.

Two schools of unit testing

Classical (Detroit school)

  • The unit is a single unit of behavior, it can be a few related classes.
finalclass TestExample extends TestCase
{
/** * @test */publicfunctionsuspending_an_subscription_with_can_always_suspend_policy_is_always_possible(): void
{
$canAlwaysSuspendPolicy = newCanAlwaysSuspendPolicy();
$sut = newSubscription();
$result = $sut->suspend($canAlwaysSuspendPolicy);
self::assertTrue($result);
self::assertSame(Status::suspend(), $sut->status());
}
}

Mockist (London school)

  • The unit is a single class.
  • The unit should be isolated from all collaborators.
finalclass TestExample extends TestCase
{
/** * @test */publicfunctionsuspending_an_subscription_with_can_always_suspend_policy_is_always_possible(): void
{
$canAlwaysSuspendPolicy = $this->createStub(SuspendingPolicyInterface::class);
$canAlwaysSuspendPolicy->method('suspend')->willReturn(true);
$sut = newSubscription();
$result = $sut->suspend($canAlwaysSuspendPolicy);
self::assertTrue($result);
self::assertSame(Status::suspend(), $sut->status());
}
}

Note

The classical approach is better to avoid fragile tests.

Dependencies

[TODO]

Mock vs. Stub

Example:

finalclass NotificationService
{
publicfunction__construct(
privatereadonlyMailerInterface$mailer,
privatereadonlyMessageRepositoryInterface$messageRepository
) {}
publicfunctionsend(): void
{
$messages = $this->messageRepository->getAll();
foreach ($messagesas$message) {
$this->mailer->send($message);
}
}
}

[!WARNING|style:flat|label:BAD]

  • Asserting interactions with stubs leads to fragile tests
finalclass TestExample extends TestCase
{
/** * @test */publicfunctionsends_all_notifications(): void
{
$message1 = newMessage();
$message2 = newMessage();
$messageRepository = $this->createMock(MessageRepositoryInterface::class);
$messageRepository->method('getAll')->willReturn([$message1, $message2]);
$mailer = $this->createMock(MailerInterface::class);
$sut = newNotificationService($mailer, $messageRepository);
$messageRepository->expects(self::once())->method('getAll');
$mailer->expects(self::exactly(2))->method('send')
->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);
$sut->send();
}
}

[!TIP|style:flat|label:GOOD]

finalclass TestExample extends TestCase
{
/** * @test */publicfunctionsends_all_notifications(): void
{
$message1 = newMessage();
$message2 = newMessage();
$messageRepository = newInMemoryMessageRepository();
$messageRepository->save($message1);
$messageRepository->save($message2);
$mailer = $this->createMock(MailerInterface::class);
$sut = newNotificationService($mailer, $messageRepository);
// Removed asserting interactions with the stub$mailer->expects(self::exactly(2))->method('send')
->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);
$sut->send();
}
}

[!TIP|style:flat|label:EVEN BETTER USING SPY]

finalclass TestExample extends TestCase
{
/** * @test */publicfunctionsends_all_notifications(): void
{
$message1 = newMessage();
$message2 = newMessage();
$messageRepository = newInMemoryMessageRepository();
$messageRepository->save($message1);
$messageRepository->save($message2);
$mailer = newSpyMailer();
$sut = newNotificationService($mailer, $messageRepository);
$sut->send();
$mailer->assertThatMessagesHaveBeenSent([$message1, $message2]);
}
}

Three styles of unit testing

Output

[!TIP|style:flat|label:The best option]

  • The best resistance to refactoring
  • The best accuracy
  • The lowest cost of maintainability
  • If it is possible, you should prefer this kind of test
finalclass ExampleTest extends TestCase
{
/** * @test * @dataProvider getInvalidEmails */publicfunctiondetects_an_invalid_email_address(string$email): void
{
$sut = newEmailValidator();
$result = $sut->isValid($email);
self::assertFalse($result);
}
/** * @test * @dataProvider getValidEmails */publicfunctiondetects_an_valid_email_address(string$email): void
{
$sut = newEmailValidator();
$result = $sut->isValid($email);
self::assertTrue($result);
}
publicfunctiongetInvalidEmails(): array
{
return [
['test'],
['test@'],
['test@test'],
//...
];
}
publicfunctiongetValidEmails(): array
{
return [
['test@test.com'],
['test123@test.com'],
['Test123@test.com'],
//...
];
}
}

State

[!WARNING|style:flat|label:Worse option]

  • Worse resistance to refactoring
  • Worse accuracy
  • Higher cost of maintainability
finalclass ExampleTest extends TestCase
{
/** * @test */publicfunctionadding_an_item_to_cart(): void
{
$item = newCartItem('Product');
$sut = newCart();
$sut->addItem($item);
self::assertSame(1, $sut->getCount());
self::assertSame($item, $sut->getItems()[0]);
}
}

Communication

[!ATTENTION|style:flat|label:The worst option]

  • The worst resistance to refactoring
  • The worst accuracy
  • The highest cost of maintainability
finalclass ExampleTest extends TestCase
{
/** * @test */publicfunctionsends_all_notifications(): void
{
$message1 = newMessage();
$message2 = newMessage();
$messageRepository = newInMemoryMessageRepository();
$messageRepository->save($message1);
$messageRepository->save($message2);
$mailer = $this->createMock(MailerInterface::class);
$sut = newNotificationService($mailer, $messageRepository);
$mailer->expects(self::exactly(2))->method('send')
->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);
$sut->send();
}
}

Functional architecture and tests

[!WARNING|style:flat|label:BAD]

finalclass NameService
{
publicfunction__construct(privatereadonlyCacheStorageInterface$cacheStorage) {}
publicfunctionloadAll(): void
{
$namesCsv = array_map('str_getcsv', file(__DIR__.'/../names.csv'));
$names = [];
foreach ($namesCsvas$nameData) {
if (!isset($nameData[0], $nameData[1])) {
continue;
}
$names[] = newName($nameData[0], newGender($nameData[1]));
}
$this->cacheStorage->store('names', $names);
}
}

How to test a code like this? It is possible only with an integration test because it directly uses an infrastructure code related to a file system.

[!TIP|style:flat|label:GOOD]

Like in functional architecture, we need to separate a code with side effects and code that contains only logic.

finalclass NameParser
{
/** * @param array<string[]> $namesData * @return Name[] */publicfunctionparse(array$namesData): array
{
$names = [];
foreach ($namesDataas$nameData) {
if (!isset($nameData[0], $nameData[1])) {
continue;
}
$names[] = newName($nameData[0], newGender($nameData[1]));
}
return$names;
}
}
finalclass CsvNamesFileLoader
{
publicfunctionload(): array
{
returnarray_map('str_getcsv', file(__DIR__.'/../names.csv'));
}
}
finalclass ApplicationService
{
publicfunction__construct(
privatereadonlyCsvNamesFileLoader$fileLoader,
privatereadonlyNameParser$parser,
privatereadonlyCacheStorageInterface$cacheStorage
) {}
publicfunctionloadNames(): void
{
$namesData = $this->fileLoader->load();
$names = $this->parser->parse($namesData);
$this->cacheStorage->store('names', $names);
}
}
finalclass ValidUnitExampleTest extends TestCase
{
/** * @test */publicfunctionparse_all_names(): void
{
$namesData = [
['John', 'M'],
['Lennon', 'U'],
['Sarah', 'W']
];
$sut = newNameParser();
$result = $sut->parse($namesData);
self::assertSame(
[
newName('John', newGender('M')),
newName('Lennon', newGender('U')),
newName('Sarah', newGender('W'))
],
$result
);
}
}

Observable behavior vs. implementation details

[!WARNING|style:flat|label:BAD]

finalclass ApplicationService
{
publicfunction__construct(privatereadonlySubscriptionRepositoryInterface$subscriptionRepository) {}
publicfunctionrenewSubscription(int$subscriptionId): bool
{
$subscription = $this->subscriptionRepository->findById($subscriptionId);
if (!$subscription->getStatus()->isEqual(Status::expired())) {
returnfalse;
}
$subscription->setStatus(Status::active());
$subscription->setModifiedAt(new \DateTimeImmutable());
returntrue;
}
}
finalclass Subscription
{
publicfunction__construct(privateStatus$status, private\DateTimeImmutable$modifiedAt) {}
publicfunctiongetStatus(): Status
{
return$this->status;
}
publicfunctionsetStatus(Status$status): void
{
$this->status = $status;
}
publicfunctiongetModifiedAt(): \DateTimeImmutable
{
return$this->modifiedAt;
}
publicfunctionsetModifiedAt(\DateTimeImmutable$modifiedAt): void
{
$this->modifiedAt = $modifiedAt;
}
}
finalclass InvalidTestExample extends TestCase
{
/** * @test */publicfunctionrenew_an_expired_subscription_is_possible(): void
{
$modifiedAt = new \DateTimeImmutable();
$expiredSubscription = newSubscription(Status::expired(), $modifiedAt);
$sut = newApplicationService($this->createRepository($expiredSubscription));
$result = $sut->renewSubscription(1);
self::assertSame(Status::active(), $expiredSubscription->getStatus());
self::assertGreaterThan($modifiedAt, $expiredSubscription->getModifiedAt());
self::assertTrue($result);
}
/** * @test */publicfunctionrenew_an_active_subscription_is_not_possible(): void
{
$modifiedAt = new \DateTimeImmutable();
$activeSubscription = newSubscription(Status::active(), $modifiedAt);
$sut = newApplicationService($this->createRepository($activeSubscription));
$result = $sut->renewSubscription(1);
self::assertSame($modifiedAt, $activeSubscription->getModifiedAt());
self::assertFalse($result);
}
privatefunctioncreateRepository(Subscription$subscription): SubscriptionRepositoryInterface
{
returnnewclass ($expiredSubscription) implements SubscriptionRepositoryInterface {
publicfunction__construct(privatereadonlySubscription$subscription) {} publicfunctionfindById(int$id): Subscription
{
return$this->subscription;
}
};
}
}

[!TIP|style:flat|label:GOOD]

finalclass ApplicationService
{
publicfunction__construct(
privatereadonlySubscriptionRepositoryInterface$subscriptionRepository
) {}
publicfunctionrenewSubscription(int$subscriptionId): bool
{
$subscription = $this->subscriptionRepository->findById($subscriptionId);
return$subscription->renew(new \DateTimeImmutable());
}
}
finalclass Subscription
{
privateStatus$status;
private\DateTimeImmutable$modifiedAt;
publicfunction__construct(\DateTimeImmutable$modifiedAt)
{
$this->status = Status::new();
$this->modifiedAt = $modifiedAt;
}
publicfunctionrenew(\DateTimeImmutable$modifiedAt): bool
{
if (!$this->status->isEqual(Status::expired())) {
returnfalse;
}
$this->status = Status::active();
$this->modifiedAt = $modifiedAt;
returntrue;
}
publicfunctionactive(\DateTimeImmutable$modifiedAt): void
{
//simplified$this->status = Status::active();
$this->modifiedAt = $modifiedAt;
}
publicfunctionexpire(\DateTimeImmutable$modifiedAt): void
{
//simplified$this->status = Status::expired();
$this->modifiedAt = $modifiedAt;
}
publicfunctionisActive(): bool
{
return$this->status->isEqual(Status::active());
}
}
finalclass ValidTestExample extends TestCase
{
/** * @test */publicfunctionrenew_an_expired_subscription_is_possible(): void
{
$expiredSubscription = SubscriptionMother::expired();
$sut = newApplicationService($this->createRepository($expiredSubscription));
$result = $sut->renewSubscription(1);
// skip checking modifiedAt as it's not a part of observable behavior. To check this value we// would have to add a getter for modifiedAt, probably only for test purposes.self::assertTrue($expiredSubscription->isActive());
self::assertTrue($result);
}
/** * @test */publicfunctionrenew_an_active_subscription_is_not_possible(): void
{
$activeSubscription = SubscriptionMother::active();
$sut = newApplicationService($this->createRepository($activeSubscription));
$result = $sut->renewSubscription(1);
self::assertTrue($activeSubscription->isActive());
self::assertFalse($result);
}
privatefunctioncreateRepository(Subscription$subscription): SubscriptionRepositoryInterface
{
returnnewclass ($expiredSubscription) implements SubscriptionRepositoryInterface {
publicfunction__construct(privatereadonlySubscription$subscription) {} publicfunctionfindById(int$id): Subscription
{
return$this->subscription;
}
};
}
}

Note

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. 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. 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.

Unit of behavior

[!WARNING|style:flat|label:BAD]

class CannotSuspendExpiredSubscriptionPolicy implements SuspendingPolicyInterface
{
publicfunctionsuspend(Subscription$subscription, \DateTimeImmutable$at): bool
{
if ($subscription->isExpired()) {
returnfalse;
}
returntrue;
}
}
class CannotSuspendExpiredSubscriptionPolicyTest extends TestCase
{
/** * @test */publicfunctionit_returns_false_when_a_subscription_is_expired(): void
{
$policy = newCannotSuspendExpiredSubscriptionPolicy();
$subscription = $this->createStub(Subscription::class);
$subscription->method('isExpired')->willReturn(true);
self::assertFalse($policy->suspend($subscription, new \DateTimeImmutable()));
}
/** * @test */publicfunctionit_returns_true_when_a_subscription_is_not_expired(): void
{
$policy = newCannotSuspendExpiredSubscriptionPolicy();
$subscription = $this->createStub(Subscription::class);
$subscription->method('isExpired')->willReturn(false);
self::assertTrue($policy->suspend($subscription, new \DateTimeImmutable()));
}
}
class CannotSuspendNewSubscriptionPolicy implements SuspendingPolicyInterface
{
publicfunctionsuspend(Subscription$subscription, \DateTimeImmutable$at): bool
{
if ($subscription->isNew()) {
returnfalse;
}
returntrue;
}
}
class CannotSuspendNewSubscriptionPolicyTest extends TestCase
{
/** * @test */publicfunctionit_returns_false_when_a_subscription_is_new(): void
{
$policy = newCannotSuspendNewSubscriptionPolicy();
$subscription = $this->createStub(Subscription::class);
$subscription->method('isNew')->willReturn(true);
self::assertFalse($policy->suspend($subscription, new \DateTimeImmutable()));
}
/** * @test */publicfunctionit_returns_true_when_a_subscription_is_not_new(): void
{
$policy = newCannotSuspendNewSubscriptionPolicy();
$subscription = $this->createStub(Subscription::class);
$subscription->method('isNew')->willReturn(false);
self::assertTrue($policy->suspend($subscription, new \DateTimeImmutable()));
}
}
class CanSuspendAfterOneMonthPolicy implements SuspendingPolicyInterface
{
publicfunctionsuspend(Subscription$subscription, \DateTimeImmutable$at): bool
{
$oneMonthEarlierDate = \DateTime::createFromImmutable($at)->sub(new \DateInterval('P1M'));
return$subscription->isOlderThan(\DateTimeImmutable::createFromMutable($oneMonthEarlierDate));
}
}
class CanSuspendAfterOneMonthPolicyTest extends TestCase
{
/** * @test */publicfunctionit_returns_true_when_a_subscription_is_older_than_one_month(): void
{
$date = new \DateTimeImmutable('2021-01-29');
$policy = newCanSuspendAfterOneMonthPolicy();
$subscription = newSubscription(new \DateTimeImmutable('2020-12-28'));
self::assertTrue($policy->suspend($subscription, $date));
}
/** * @test */publicfunctionit_returns_false_when_a_subscription_is_not_older_than_one_month(): void
{
$date = new \DateTimeImmutable('2021-01-29');
$policy = newCanSuspendAfterOneMonthPolicy();
$subscription = newSubscription(new \DateTimeImmutable('2020-01-01'));
self::assertTrue($policy->suspend($subscription, $date));
}
}
class Status
{
privateconstEXPIRED = 'expired';
privateconstACTIVE = 'active';
privateconstNEW = 'new';
privateconstSUSPENDED = 'suspended';
privatefunction__construct(privatereadonlystring$status)
{
$this->status = $status;
}
publicstaticfunctionexpired(): self
{
returnnewself(self::EXPIRED);
}
publicstaticfunctionactive(): self
{
returnnewself(self::ACTIVE);
}
publicstaticfunctionnew(): self
{
returnnewself(self::NEW);
}
publicstaticfunctionsuspended(): self
{
returnnewself(self::SUSPENDED);
}
publicfunctionisEqual(self$status): bool
{
return$this->status === $status->status;
}
}
class StatusTest extends TestCase
{
publicfunctiontestEquals(): void
{
$status1 = Status::active();
$status2 = Status::active();
self::assertTrue($status1->isEqual($status2));
}
publicfunctiontestNotEquals(): void
{
$status1 = Status::active();
$status2 = Status::expired();
self::assertFalse($status1->isEqual($status2));
}
}
class SubscriptionTest extends TestCase
{
/** * @test */publicfunctionsuspending_a_subscription_is_possible_when_a_policy_returns_true(): void
{
$policy = $this->createMock(SuspendingPolicyInterface::class);
$policy->expects($this->once())->method('suspend')->willReturn(true);
$sut = newSubscription(new \DateTimeImmutable());
$result = $sut->suspend($policy, new \DateTimeImmutable());
self::assertTrue($result);
self::assertTrue($sut->isSuspended());
}
/** * @test */publicfunctionsuspending_a_subscription_is_not_possible_when_a_policy_returns_false(): void
{
$policy = $this->createMock(SuspendingPolicyInterface::class);
$policy->expects($this->once())->method('suspend')->willReturn(false);
$sut = newSubscription(new \DateTimeImmutable());
$result = $sut->suspend($policy, new \DateTimeImmutable());
self::assertFalse($result);
self::assertFalse($sut->isSuspended());
}
/** * @test */publicfunctionit_returns_true_when_a_subscription_is_older_than_one_month(): void
{
$date = new \DateTimeImmutable();
$futureDate = $date->add(new \DateInterval('P1M'));
$sut = newSubscription($date);
self::assertTrue($sut->isOlderThan($futureDate));
}
/** * @test */publicfunctionit_returns_false_when_a_subscription_is_not_older_than_one_month(): void
{
$date = new \DateTimeImmutable();
$futureDate = $date->add(new \DateInterval('P1D'));
$sut = newSubscription($date);
self::assertTrue($sut->isOlderThan($futureDate));
}
}

[!ATTENTION] Do not write code 1:1, 1 class : 1 test. It leads to fragile tests which make refactoring more cumbersome.

[!TIP|style:flat|label:GOOD]

finalclass CannotSuspendExpiredSubscriptionPolicy implements SuspendingPolicyInterface
{
publicfunctionsuspend(Subscription$subscription, \DateTimeImmutable$at): bool
{
if ($subscription->isExpired()) {
returnfalse;
}
returntrue;
}
}
finalclass CannotSuspendNewSubscriptionPolicy implements SuspendingPolicyInterface
{
publicfunctionsuspend(Subscription$subscription, \DateTimeImmutable$at): bool
{
if ($subscription->isNew()) {
returnfalse;
}
returntrue;
}
}
finalclass CanSuspendAfterOneMonthPolicy implements SuspendingPolicyInterface
{
publicfunctionsuspend(Subscription$subscription, \DateTimeImmutable$at): bool
{
$oneMonthEarlierDate = \DateTime::createFromImmutable($at)->sub(new \DateInterval('P1M'));
return$subscription->isOlderThan(\DateTimeImmutable::createFromMutable($oneMonthEarlierDate));
}
}
finalclass Status
{
privateconstEXPIRED = 'expired';
privateconstACTIVE = 'active';
privateconstNEW = 'new';
privateconstSUSPENDED = 'suspended';
privatefunction__construct(privatereadonlystring$status)
{
$this->status = $status;
}
publicstaticfunctionexpired(): self
{
returnnewself(self::EXPIRED);
}
publicstaticfunctionactive(): self
{
returnnewself(self::ACTIVE);
}
publicstaticfunctionnew(): self
{
returnnewself(self::NEW);
}
publicstaticfunctionsuspended(): self
{
returnnewself(self::SUSPENDED);
}
publicfunctionisEqual(self$status): bool
{
return$this->status === $status->status;
}
}
finalclass Subscription
{
privateStatus$status;
private\DateTimeImmutable$createdAt;
publicfunction__construct(\DateTimeImmutable$createdAt)
{
$this->status = Status::new();
$this->createdAt = $createdAt;
}
publicfunctionsuspend(SuspendingPolicyInterface$suspendingPolicy, \DateTimeImmutable$at): bool
{
$result = $suspendingPolicy->suspend($this, $at);
if ($result) {
$this->status = Status::suspended();
}
return$result;
}
publicfunctionisOlderThan(\DateTimeImmutable$date): bool
{
return$this->createdAt < $date;
}
publicfunctionactivate(): void
{
$this->status = Status::active();
}
publicfunctionexpire(): void
{
$this->status = Status::expired();
}
publicfunctionisExpired(): bool
{
return$this->status->isEqual(Status::expired());
}
publicfunctionisActive(): bool
{
return$this->status->isEqual(Status::active());
}
publicfunctionisNew(): bool
{
return$this->status->isEqual(Status::new());
}
publicfunctionisSuspended(): bool
{
return$this->status->isEqual(Status::suspended());
}
}
finalclass SubscriptionSuspendingTest extends TestCase
{
/** * @test */publicfunctionsuspending_an_expired_subscription_with_cannot_suspend_expired_policy_is_not_possible(): void
{
$sut = newSubscription(new \DateTimeImmutable());
$sut->activate();
$sut->expire();
$result = $sut->suspend(newCannotSuspendExpiredSubscriptionPolicy(), new \DateTimeImmutable());
self::assertFalse($result);
}
/** * @test */publicfunctionsuspending_a_new_subscription_with_cannot_suspend_new_policy_is_not_possible(): void
{
$sut = newSubscription(new \DateTimeImmutable());
$result = $sut->suspend(newCannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable());
self::assertFalse($result);
}
/** * @test */publicfunctionsuspending_an_active_subscription_with_cannot_suspend_new_policy_is_possible(): void
{
$sut = newSubscription(new \DateTimeImmutable());
$sut->activate();
$result = $sut->suspend(newCannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable());
self::assertTrue($result);
}
/** * @test */publicfunctionsuspending_an_active_subscription_with_cannot_suspend_expired_policy_is_possible(): void
{
$sut = newSubscription(new \DateTimeImmutable());
$sut->activate();
$result = $sut->suspend(newCannotSuspendExpiredSubscriptionPolicy(), new \DateTimeImmutable());
self::assertTrue($result);
}
/** * @test */publicfunctionsuspending_an_subscription_before_a_one_month_is_not_possible(): void
{
$sut = newSubscription(new \DateTimeImmutable('2020-01-01'));
$result = $sut->suspend(newCanSuspendAfterOneMonthPolicy(), new \DateTimeImmutable('2020-01-10'));
self::assertFalse($result);
}
/** * @test */publicfunctionsuspending_an_subscription_after_a_one_month_is_possible(): void
{
$sut = newSubscription(new \DateTimeImmutable('2020-01-01'));
$result = $sut->suspend(newCanSuspendAfterOneMonthPolicy(), new \DateTimeImmutable('2020-02-02'));
self::assertTrue($result);
}
}

Humble pattern

How to properly unit test a class like this?

class ApplicationService
{
publicfunction__construct(
privatereadonlyOrderRepository$orderRepository,
privatereadonlyFormRepository$formRepository
) {}
publicfunctionchangeFormStatus(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'] === 'accepted') {
$order->setStatus('paid');
}
$this->formRepository->save($form);
$this->orderRepository->save($order);
}
privatefunctiongetSoapClient(): \SoapClient
{
returnnew \SoapClient('https://legacy_system.pl/Soap/WebService', []);
}
}

[!TIP|style:flat|label:GOOD]

It's required to split up an overcomplicated code to separate classes.

finalclass ApplicationService
{
publicfunction__construct(
privatereadonlyOrderRepositoryInterface$orderRepository,
privatereadonlyFormRepositoryInterface$formRepository,
privatereadonlyFormApiInterface$formApi,
privatereadonlyChangeFormStatusService$changeFormStatusService
) {}
publicfunctionchangeFormStatus(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);
}
}
finalclass ChangeFormStatusService
{
publicfunctionchangeStatus(Order$order, Form$form, string$formStatus): void
{
$status = FormStatus::createFromString($formStatus);
$form->changeStatus($status);
if ($form->isAccepted()) {
$order->changeStatus(OrderStatus::paid());
}
}
}
finalclass ChangingFormStatusTest extends TestCase
{
/** * @test */publicfunctionchanging_a_form_status_to_accepted_changes_an_order_status_to_paid(): void
{
$order = newOrder();
$form = newForm();
$status = 'accepted';
$sut = newChangeFormStatusService();
$sut->changeStatus($order, $form, $status);
self::assertTrue($form->isAccepted());
self::assertTrue($order->isPaid());
}
/** * @test */publicfunctionchanging_a_form_status_to_refused_not_changes_an_order_status(): void
{
$order = newOrder();
$form = newForm();
$status = 'new';
$sut = newChangeFormStatusService();
$sut->changeStatus($order, $form, $status);
self::assertFalse($form->isAccepted());
self::assertFalse($order->isPaid());
}
}

However, ApplicationService probably should be tested by an integration test with only mocked FormApiInterface.

Trivial test

[!WARNING|style:flat|label:BAD]

finalclass Customer
{
publicfunction__construct(privatestring$name) {}
publicfunctiongetName(): string
{
return$this->name;
}
publicfunctionsetName(string$name): void
{
$this->name = $name;
}
}
finalclass CustomerTest extends TestCase
{
publicfunctiontestSetName(): void
{
$customer = newCustomer('Jack');
$customer->setName('John');
self::assertSame('John', $customer->getName());
}
}
finalclass EventSubscriber
{
publicstaticfunctiongetSubscribedEvents(): array
{
return ['event' => 'onEvent'];
}
publicfunctiononEvent(): void
{
}
}
finalclass EventSubscriberTest extends TestCase
{
publicfunctiontestGetSubscribedEvents(): void
{
$result = EventSubscriber::getSubscribedEvents();
self::assertSame(['event' => 'onEvent'], $result);
}
}

[!ATTENTION] Testing the code without any complicated logic is senseless, but also leads to fragile tests.

Fragile test

[!WARNING|style:flat|label:BAD]

finalclass UserRepository
{
publicfunction__construct(
privatereadonlyConnection$connection
) {}
publicfunctiongetUserNameByEmail(string$email): ?array
{
return$this
->connection
->createQueryBuilder()
->from('user', 'u')
->where('u.email = :email')
->setParameter('email', $email)
->execute()
->fetch();
}
}
finalclass TestUserRepository extends TestCase
{
publicfunctiontestGetUserNameByEmail(): void
{
$email = 'test@test.com';
$connection = $this->createMock(Connection::class);
$queryBuilder = $this->createMock(QueryBuilder::class);
$result = $this->createMock(ResultStatement::class);
$userRepository = newUserRepository($connection);
$connection
->expects($this->once())
->method('createQueryBuilder')
->willReturn($queryBuilder);
$queryBuilder
->expects($this->once())
->method('from')
->with('user', 'u')
->willReturn($queryBuilder);
$queryBuilder
->expects($this->once())
->method('where')
->with('u.email = :email')
->willReturn($queryBuilder);
$queryBuilder
->expects($this->once())
->method('setParameter')
->with('email', $email)
->willReturn($queryBuilder);
$queryBuilder
->expects($this->once())
->method('execute')
->willReturn($result);
$result
->expects($this->once())
->method('fetch')
->willReturn(['email' => $email]);
$result = $userRepository->getUserNameByEmail($email);
self::assertSame(['email' => $email], $result);
}
}

[!ATTENTION] Testing repositories in that way leads to fragile tests and then refactoring is tough. To test repositories write integration tests.

Test fixtures

[!TIP|style:flat|label:GOOD]

finalclass GoodTest extends TestCase
{
privateSubscriptionFactory$sut;
publicfunctionsetUp(): void
{
$this->sut = newSubscriptionFactory();
}
/** * @test */publicfunctioncreates_a_subscription_for_a_given_date_range(): void
{
$result = $this->sut->create(new \DateTimeImmutable(), new \DateTimeImmutable('now +1 year'));
self::assertInstanceOf(Subscription::class, $result);
}
/** * @test */publicfunctionthrows_an_exception_on_invalid_date_range(): void
{
$this->expectException(CreateSubscriptionException::class);
$result = $this->sut->create(new \DateTimeImmutable('now -1 year'), new \DateTimeImmutable());
}
}

Note

  • The best case for using the setUp method will be testing stateless objects.
  • Any configuration made inside setUp couples tests together, and has impact on all tests.
  • It's better to avoid a shared state between tests and configure the initial state accordingly to test method.
  • Readability is worse compared to configuration made in the proper test method.

[!TIP|style:flat|label:BETTER]

finalclass BetterTest extends TestCase
{
/** * @test */publicfunctionsuspending_an_active_subscription_with_cannot_suspend_new_policy_is_possible(): void
{
$sut = $this->createAnActiveSubscription();
$result = $sut->suspend(newCannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable());
self::assertTrue($result);
}
/** * @test */publicfunctionsuspending_an_active_subscription_with_cannot_suspend_expired_policy_is_possible(): void
{
$sut = $this->createAnActiveSubscription();
$result = $sut->suspend(newCannotSuspendExpiredSubscriptionPolicy(), new \DateTimeImmutable());
self::assertTrue($result);
}
/** * @test */publicfunctionsuspending_a_new_subscription_with_cannot_suspend_new_policy_is_not_possible(): void
{
$sut = $this->createANewSubscription();
$result = $sut->suspend(newCannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable());
self::assertFalse($result);
}
privatefunctioncreateANewSubscription(): Subscription
{
returnnewSubscription(new \DateTimeImmutable());
}
privatefunctioncreateAnActiveSubscription(): Subscription
{
$subscription = newSubscription(new \DateTimeImmutable());
$subscription->activate();
return$subscription;
}
}

Note

  • This approach improves readability and clarifies the separation (code is more read than written).
  • Private helpers can be tedious to use in each test method, although they provide explicit intentions.

To share similar testing objects between multiple test classes use:

General testing anti-patterns

Exposing private state

[!WARNING|style:flat|label:BAD]

finalclass Customer
{
privateCustomerType$type;
privateDiscountCalculationPolicyInterface$discountCalculationPolicy;
publicfunction__construct()
{
$this->type = CustomerType::NORMAL();
$this->discountCalculationPolicy = newNormalDiscountPolicy();
}
publicfunctionmakeVip(): void
{
$this->type = CustomerType::VIP();
$this->discountCalculationPolicy = newVipDiscountPolicy();
}
publicfunctiongetCustomerType(): CustomerType
{
return$this->type;
}
publicfunctiongetPercentageDiscount(): int
{
return$this->discountCalculationPolicy->getPercentageDiscount();
}
}
finalclass InvalidTest extends TestCase
{
publicfunctiontestMakeVip(): void
{
$sut = newCustomer();
$sut->makeVip();
self::assertSame(CustomerType::VIP(), $sut->getCustomerType());
}
}

[!TIP|style:flat|label:GOOD]

finalclass Customer
{
privateCustomerType$type;
privateDiscountCalculationPolicyInterface$discountCalculationPolicy;
publicfunction__construct()
{
$this->type = CustomerType::NORMAL();
$this->discountCalculationPolicy = newNormalDiscountPolicy();
}
publicfunctionmakeVip(): void
{
$this->type = CustomerType::VIP();
$this->discountCalculationPolicy = newVipDiscountPolicy();
}
publicfunctiongetPercentageDiscount(): int
{
return$this->discountCalculationPolicy->getPercentageDiscount();
}
}
finalclass ValidTest extends TestCase
{
/** * @test */publicfunctiona_vip_customer_has_a_25_percentage_discount(): void
{
$sut = newCustomer();
$sut->makeVip();
self::assertSame(25, $sut->getPercentageDiscount());
}
}

[!ATTENTION] Adding additional production code (e.g. getter getCustomerType()) only to verify the state in tests is a bad practice. 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.

Leaking domain details

finalclass DiscountCalculator
{
publicfunctioncalculate(int$isVipFromYears): int
{
Assert::greaterThanEq($isVipFromYears, 0);
returnmin(($isVipFromYears * 10) + 3, 80);
}
}

[!WARNING|style:flat|label:BAD]

finalclass InvalidTest extends TestCase
{
/** * @dataProvider discountDataProvider */publicfunctiontestCalculate(int$vipDaysFrom, int$expected): void
{
$sut = newDiscountCalculator();
self::assertSame($expected, $sut->calculate($vipDaysFrom));
}
publicfunctiondiscountDataProvider(): array
{
return [
[0, 0 * 10 + 3], //leaking domain details
[1, 1 * 10 + 3],
[5, 5 * 10 + 3],
[8, 80]
];
}
}

[!TIP|style:flat|label:GOOD]

finalclass ValidTest extends TestCase
{
/** * @dataProvider discountDataProvider */publicfunctiontestCalculate(int$vipDaysFrom, int$expected): void
{
$sut = newDiscountCalculator();
self::assertSame($expected, $sut->calculate($vipDaysFrom));
}
publicfunctiondiscountDataProvider(): array
{
return [
[0, 3],
[1, 13],
[5, 53],
[8, 80]
];
}
}

Note

Don't duplicate the production logic in tests. Just verify results by hardcoded values.

Mocking concrete classes

[!WARNING|style:flat|label:BAD]

class DiscountCalculator
{
publicfunctioncalculateInternalDiscount(int$isVipFromYears): int
{
Assert::greaterThanEq($isVipFromYears, 0);
returnmin(($isVipFromYears * 10) + 3, 80);
}
publicfunctioncalculateAdditionalDiscountFromExternalSystem(): int
{
// get data from an external system to calculate a discountreturn5;
}
}
class OrderService
{
publicfunction__construct(privatereadonlyDiscountCalculator$discountCalculator) {}
publicfunctiongetTotalPriceWithDiscount(int$totalPrice, int$vipFromDays): int
{
$internalDiscount = $this->discountCalculator->calculateInternalDiscount($vipFromDays);
$externalDiscount = $this->discountCalculator->calculateAdditionalDiscountFromExternalSystem();
$discountSum = $internalDiscount + $externalDiscount;
return$totalPrice - (int) ceil(($totalPrice * $discountSum) / 100);
}
}
finalclass InvalidTest extends TestCase
{
/** * @dataProvider orderDataProvider */publicfunctiontestGetTotalPriceWithDiscount(int$totalPrice, int$vipDaysFrom, int$expected): void
{
$discountCalculator = $this->createPartialMock(DiscountCalculator::class, ['calculateAdditionalDiscountFromExternalSystem']);
$discountCalculator->method('calculateAdditionalDiscountFromExternalSystem')->willReturn(5);
$sut = newOrderService($discountCalculator);
self::assertSame($expected, $sut->getTotalPriceWithDiscount($totalPrice, $vipDaysFrom));
}
publicfunctionorderDataProvider(): array
{
return [
[1000, 0, 920],
[500, 1, 410],
[644, 5, 270],
];
}
}

[!TIP|style:flat|label:GOOD]

interface ExternalDiscountCalculatorInterface
{
publicfunctioncalculate(): int;
}
finalclass InternalDiscountCalculator
{
publicfunctioncalculate(int$isVipFromYears): int
{
Assert::greaterThanEq($isVipFromYears, 0);
returnmin(($isVipFromYears * 10) + 3, 80);
}
}
finalclass OrderService
{
publicfunction__construct(
privatereadonlyInternalDiscountCalculator$discountCalculator,
privatereadonlyExternalDiscountCalculatorInterface$externalDiscountCalculator
) {}
publicfunctiongetTotalPriceWithDiscount(int$totalPrice, int$vipFromDays): int
{
$internalDiscount = $this->discountCalculator->calculate($vipFromDays);
$externalDiscount = $this->externalDiscountCalculator->calculate();
$discountSum = $internalDiscount + $externalDiscount;
return$totalPrice - (int) ceil(($totalPrice * $discountSum) / 100);
}
}
finalclass ValidTest extends TestCase
{
/** * @dataProvider orderDataProvider */publicfunctiontestGetTotalPriceWithDiscount(int$totalPrice, int$vipDaysFrom, int$expected): void
{
$externalDiscountCalculator = newclass() implements ExternalDiscountCalculatorInterface {
publicfunctioncalculate(): int
{
return5;
}
};
$sut = newOrderService(newInternalDiscountCalculator(), $externalDiscountCalculator);
self::assertSame($expected, $sut->getTotalPriceWithDiscount($totalPrice, $vipDaysFrom));
}
publicfunctionorderDataProvider(): array
{
return [
[1000, 0, 920],
[500, 1, 410],
[644, 5, 270],
];
}
}

Note

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.

Testing private methods

finalclass OrderItem
{
publicfunction__construct(publicreadonlyint$total) {}
}
finalclass Order
{
/** * @param OrderItem[] $items * @param int $transportCost */publicfunction__construct(privatearray$items, privateint$transportCost) {}
publicfunctiongetTotal(): int
{
return$this->getItemsTotal() + $this->transportCost;
}
privatefunctiongetItemsTotal(): int
{
returnarray_reduce(
array_map(fn (OrderItem$item) => $item->total, $this->items),
fn (int$sum, int$total) => $sum += $total,
0
);
}
}

[!WARNING|style:flat|label:BAD]

finalclass InvalidTest extends TestCase
{
/** * @test * @dataProvider ordersDataProvider */publicfunctionget_total_returns_a_total_cost_of_a_whole_order(Order$order, int$expectedTotal): void
{
self::assertSame($expectedTotal, $order->getTotal());
}
/** * @test * @dataProvider orderItemsDataProvider */publicfunctionget_items_total_returns_a_total_cost_of_all_items(Order$order, int$expectedTotal): void
{
self::assertSame($expectedTotal, $this->invokePrivateMethodGetItemsTotal($order));
}
publicfunctionordersDataProvider(): array
{
return [
[newOrder([newOrderItem(20), newOrderItem(20), newOrderItem(20)], 15), 75],
[newOrder([newOrderItem(20), newOrderItem(30), newOrderItem(40)], 0), 90],
[newOrder([newOrderItem(99), newOrderItem(99), newOrderItem(99)], 9), 306]
];
}
publicfunctionorderItemsDataProvider(): array
{
return [
[newOrder([newOrderItem(20), newOrderItem(20), newOrderItem(20)], 15), 60],
[newOrder([newOrderItem(20), newOrderItem(30), newOrderItem(40)], 0), 90],
[newOrder([newOrderItem(99), newOrderItem(99), newOrderItem(99)], 9), 297]
];
}
privatefunctioninvokePrivateMethodGetItemsTotal(Order &$order): int
{
$reflection = new \ReflectionClass(get_class($order));
$method = $reflection->getMethod('getItemsTotal');
$method->setAccessible(true);
return$method->invokeArgs($order, []);
}
}

[!TIP|style:flat|label:GOOD]

finalclass ValidTest extends TestCase
{
/** * @test * @dataProvider ordersDataProvider */publicfunctionget_total_returns_a_total_cost_of_a_whole_order(Order$order, int$expectedTotal): void
{
self::assertSame($expectedTotal, $order->getTotal());
}
publicfunctionordersDataProvider(): array
{
return [
[newOrder([newOrderItem(20), newOrderItem(20), newOrderItem(20)], 15), 75],
[newOrder([newOrderItem(20), newOrderItem(30), newOrderItem(40)], 0), 90],
[newOrder([newOrderItem(99), newOrderItem(99), newOrderItem(99)], 9), 306]
];
}
}

[!ATTENTION] Tests should only verify public API.

Time as a volatile dependency

The time is a volatile dependency because it is non-deterministic. Each invocation returns a different result.

[!WARNING|style:flat|label:BAD]

finalclass Clock
{
publicstatic\DateTime|null$currentDateTime = null;
publicstaticfunctiongetCurrentDateTime(): \DateTime
{
if (null === self::$currentDateTime) {
self::$currentDateTime = new \DateTime();
}
returnself::$currentDateTime;
}
publicstaticfunctionset(\DateTime$dateTime): void
{
self::$currentDateTime = $dateTime;
}
publicstaticfunctionreset(): void
{
self::$currentDateTime = null;
}
}
finalclass Customer
{
private\DateTime$createdAt;
publicfunction__construct()
{
$this->createdAt = Clock::getCurrentDateTime();
}
publicfunctionisVip(): bool
{
return$this->createdAt->diff(Clock::getCurrentDateTime())->y >= 1;
}
}
finalclass InvalidTest extends TestCase
{
/** * @test */publicfunctiona_customer_registered_more_than_a_one_year_ago_is_a_vip(): void
{
Clock::set(new \DateTime('2019-01-01'));
$sut = newCustomer();
Clock::reset(); // you have to remember about resetting the shared stateself::assertTrue($sut->isVip());
}
/** * @test */publicfunctiona_customer_registered_less_than_a_one_year_ago_is_not_a_vip(): void
{
Clock::set((new \DateTime())->sub(new \DateInterval('P2M')));
$sut = newCustomer();
Clock::reset(); // you have to remember about resetting the shared stateself::assertFalse($sut->isVip());
}
}

[!TIP|style:flat|label:GOOD]

interface ClockInterface
{
publicfunctiongetCurrentTime(): \DateTimeImmutable;
}
finalclass Clock implements ClockInterface
{
privatefunction__construct()
{
}
publicstaticfunctioncreate(): self
{
returnnewself();
}
publicfunctiongetCurrentTime(): \DateTimeImmutable
{
returnnew \DateTimeImmutable();
}
}
finalclass FixedClock implements ClockInterface
{
privatefunction__construct(privatereadonly\DateTimeImmutable$fixedDate) {}
publicstaticfunctioncreate(\DateTimeImmutable$fixedDate): self
{
returnnewself($fixedDate);
}
publicfunctiongetCurrentTime(): \DateTimeImmutable
{
return$this->fixedDate;
}
}
finalclass Customer
{
publicfunction__construct(privatereadonly\DateTimeImmutable$createdAt) {}
publicfunctionisVip(\DateTimeImmutable$currentDate): bool
{
return$this->createdAt->diff($currentDate)->y >= 1;
}
}
finalclass ValidTest extends TestCase
{
/** * @test */publicfunctiona_customer_registered_more_than_a_one_year_ago_is_a_vip(): void
{
$sut = newCustomer(FixedClock::create(new \DateTimeImmutable('2019-01-01'))->getCurrentTime());
self::assertTrue($sut->isVip(FixedClock::create(new \DateTimeImmutable('2020-01-02'))->getCurrentTime()));
}
/** * @test */publicfunctiona_customer_registered_less_than_a_one_year_ago_is_not_a_vip(): void
{
$sut = newCustomer(FixedClock::create(new \DateTimeImmutable('2019-01-01'))->getCurrentTime());
self::assertFalse($sut->isVip(FixedClock::create(new \DateTimeImmutable('2019-05-02'))->getCurrentTime()));
}
}

Note

The time and random numbers should not be generated directly in the domain code. To test behavior we must have deterministic results, so we need to inject these values into a domain object like in the example above.

100% Test Coverage shouldn't be the goal

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. Mutation testing gives better feedback about the quality of tests. Read more

Recommended books

Author

👷 Kamil Ruczyński

Twitter:https://twitter.com/Sarvendev
Blog:https://sarvendev.com/
LinkedIn:https://www.linkedin.com/in/kamilruczynski/

Contributors

Languages