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 us instantly assure 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.
- Introduction
- Test doubles
- Naming
- AAA pattern
- Object mother
- Parameterized test
- Two schools of unit testing
- Mock vs Stub
- Three styles of unit testing
- Functional architecture and tests
- Observable behavior vs implementation details
- Unit of behavior
- Humble pattern
- Trivial test
- Fragile test
- Test fixtures
- General testing anti-patterns
- 100% Test Coverage shouldn't be the goal
- Recommended books
Test doubles are fake dependencies used in tests.
A dummy is a just simple implementation that does nothing.
finalclass Mailer implements MailerInterface
{
publicfunctionsend(Message$message): void
{
}
}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();
}
}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);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);
}
}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));❗ To verify incoming interactions, use a stub, but to verify outcoming interactions, use a mock. More: Mock vs Stub
➖ Not good:
publicfunctiontest(): void
{
$subscription = SubscriptionMother::new();
$subscription->activate();
self::assertSame(Status::activated(), $subscription->status());
}✔️ Specify explicitly what you are testing
publicfunctionsut(): void
{
// sut = System under test$sut = SubscriptionMother::new();
$sut->activate();
self::assertSame(Status::activated(), $sut->status());
}➖ Not good:
publicfunctionit_throws_invalid_credentials_exception_when_sign_in_with_invalid_credentials(): void
{
}
publicfunctiontestCreatingWithATooShortPasswordIsNotPossible(): void
{
}
publicfunctiontestDeactivateASubscription(): void
{
}✔️ 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
{
}ℹ️ 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.
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.
publicfunctionaaa_pattern_example_test(): void
{
//Arrange|Given$sut = SubscriptionMother::new();
//Act|When$sut->activate();
//Assert|Thenself::assertSame(Status::activated(), $sut->status());
}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
}
}The parameterized test is a good option to test the SUT with many parameters without repeating the code.
👎 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(): array
{
return [
['test'],
['test@'],
['test@test'],
//...
];
}
publicfunctiongetValidEmails(): array
{
return [
['test@test.com'],
['test123@test.com'],
['Test123@test.com'],
//...
];
}
}- The unit is a single unit of behavior, it can be a few related classes.
- Every test should be isolated from others. So it must be possible to invoke them in parallel or in any order.
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());
}
}- 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());
}
}ℹ️ The classical approach is better to avoid fragile tests.
[TODO]
Example:
finalclass NotificationService
{
publicfunction__construct(
privateMailerInterface$mailer,
privateMessageRepositoryInterface$messageRepository
) {}
publicfunctionsend(): void
{
$messages = $this->messageRepository->getAll();
foreach ($messagesas$message) {
$this->mailer->send($message);
}
}
}❌ 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();
}
}✔️ Good:
finalclass TestExample extends TestCase
{
/** * @test */publicfunctionsends_all_notifications(): void
{
$message1 = newMessage();
$message2 = newMessage();
$messageRepository = $this->createStub(MessageRepositoryInterface::class);
$messageRepository->method('getAll')->willReturn([$message1, $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();
}
}✔️ 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'],
//...
];
}
}✅ 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]);
}
}✅ 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 = $this->createStub(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();
}
}❌ Bad:
finalclass NameService
{
publicfunction__construct(privateCacheStorageInterface$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.
✔️ Good:
Like in functional architecture, we need to separate a code with side effects and code that contains only logic.
finalclass NameParser
{
/** * @param array $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(
privateCsvNamesFileLoader$fileLoader,
privateNameParser$parser,
privateCacheStorageInterface$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
);
}
}❌ Bad:
finalclass ApplicationService
{
publicfunction__construct(privateSubscriptionRepositoryInterface$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
{
privateStatus$status;
private\DateTimeImmutable$modifiedAt;
publicfunction__construct(Status$status, \DateTimeImmutable$modifiedAt)
{
$this->status = $status;
$this->modifiedAt = $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);
$repository = $this->createStub(SubscriptionRepositoryInterface::class);
$repository->method('findById')->willReturn($expiredSubscription);
$sut = newApplicationService($repository);
$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);
$repository = $this->createStub(SubscriptionRepositoryInterface::class);
$repository->method('findById')->willReturn($activeSubscription);
$sut = newApplicationService($repository);
$result = $sut->renewSubscription(1);
self::assertSame($modifiedAt, $activeSubscription->getModifiedAt());
self::assertFalse($result);
}
}✔️ Good:
finalclass ApplicationService
{
publicfunction__construct(privateSubscriptionRepositoryInterface$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();
$repository = $this->createStub(SubscriptionRepositoryInterface::class);
$repository->method('findById')->willReturn($expiredSubscription);
$sut = newApplicationService($repository);
$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();
$repository = $this->createStub(SubscriptionRepositoryInterface::class);
$repository->method('findById')->willReturn($activeSubscription);
$sut = newApplicationService($repository);
$result = $sut->renewSubscription(1);
self::assertTrue($activeSubscription->isActive());
self::assertFalse($result);
}
}ℹ️ 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.
❌ 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';
privatestring$status;
privatefunction__construct(string$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));
}
}❗ Do not write code 1:1, 1 class : 1 test. It leads to fragile tests which make that refactoring is tough.
✔️ 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';
privatestring$status;
privatefunction__construct(string$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);
}
}How to properly unit test a class like this?
class ApplicationService
{
publicfunction__construct(
privateOrderRepository$orderRepository,
privateFormRepository$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', []);
}
}✔️ It's required to split up an overcomplicated code to separate classes.
finalclass ApplicationService
{
publicfunction__construct(
privateOrderRepositoryInterface$orderRepository,
privateFormRepositoryInterface$formRepository,
privateFormApiInterface$formApi,
privateChangeFormStatusService$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.
❌ 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);
}
}❗ Testing the code without any complicated logic is senseless, but also leads to fragile tests.
❌ Bad:
finalclass UserRepository
{
publicfunction__construct(
privateConnection$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);
}
}❗ Testing repositories in that way leads to fragile tests and then refactoring is tough. To test repositories write integration tests.
❌ Bad:
finalclass InvalidTest extends TestCase
{
private ?Subscription$subscription;
publicfunctionsetUp(): void
{
$this->subscription = newSubscription(new \DateTimeImmutable());
$this->subscription->activate();
}
/** * @test */publicfunctionsuspending_an_active_subscription_with_cannot_suspend_new_policy_is_possible(): void
{
$result = $this->subscription->suspend(newCannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable());
self::assertTrue($result);
}
/** * @test */publicfunctionsuspending_an_active_subscription_with_cannot_suspend_expired_policy_is_possible(): void
{
$result = $this->subscription->suspend(newCannotSuspendExpiredSubscriptionPolicy(), new \DateTimeImmutable());
self::assertTrue($result);
}
/** * @test */publicfunctionsuspending_a_new_subscription_with_cannot_suspend_new_policy_is_not_possible(): void
{
// Here we need to create a new subscription, it is not possible to change $this->subscription to a new subscription
}
}✔️ Good:
finalclass ValidTest 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;
}
}- It's better to avoid a shared state between tests.
- To reuse elements between a few tests:
- private factory methods - reusing in one class (like above)
- Object mother - reusing in a few classes
❌ 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());
}
}✔️ 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());
}
}❗ 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.
finalclass DiscountCalculator
{
publicfunctioncalculate(int$isVipFromYears): int
{
Assert::greaterThanEq($isVipFromYears, 0);
returnmin(($isVipFromYears * 10) + 3, 80);
}
}❌ 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]
];
}
}✔️ 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]
];
}
}ℹ️ Don't duplicate the production logic in tests. Just verify results by hardcoded values.
❌ 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(privateDiscountCalculator$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],
];
}
}✔️ Good:
interface ExternalDiscountCalculatorInterface
{
publicfunctioncalculate(): int;
}finalclass InternalDiscountCalculator
{
publicfunctioncalculate(int$isVipFromYears): int
{
Assert::greaterThanEq($isVipFromYears, 0);
returnmin(($isVipFromYears * 10) + 3, 80);
}
}finalclass OrderService
{
publicfunction__construct(
privateInternalDiscountCalculator$discountCalculator,
privateExternalDiscountCalculatorInterface$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 = $this->createStub(ExternalDiscountCalculatorInterface::class);
$externalDiscountCalculator->method('calculate')->willReturn(5);
$sut = newOrderService(newInternalDiscountCalculator(), $externalDiscountCalculator);
self::assertSame($expected, $sut->getTotalPriceWithDiscount($totalPrice, $vipDaysFrom));
}
publicfunctionorderDataProvider(): array
{
return [
[1000, 0, 920],
[500, 1, 410],
[644, 5, 270],
];
}
}ℹ️ 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.
finalclass OrderItem
{
publicfunction__construct(privateint$total) {}
publicfunctiongetTotal(): int
{
return$this->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->getTotal(), $this->items),
fn (int$sum, int$total) => $sum += $total,
0
);
}
}❌ 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, []);
}
}✔️ 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]
];
}
}❗ Tests should only verify public API.
ℹ️ The time is a volatile dependency because it is non-deterministic. Each invocation returns a different result.
❌ 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());
}
}✔️ Good:
interface ClockInterface
{
publicfunctiongetCurrentTime(): \DateTimeImmutable;
}finalclass Clock implements ClockInterface
{
privatefunction__construct()
{
}
publicstaticfunctioncreate(): self
{
returnnewself();
}
publicfunctiongetCurrentTime(): \DateTimeImmutable
{
returnnew \DateTimeImmutable();
}
}finalclass FixedClock implements ClockInterface
{
privatefunction__construct(private\DateTimeImmutable$fixedDate) {}
publicstaticfunctioncreate(\DateTimeImmutable$fixedDate): self
{
returnnewself($fixedDate);
}
publicfunctiongetCurrentTime(): \DateTimeImmutable
{
return$this->fixedDate;
}
}finalclass Customer
{
private\DateTimeImmutable$createdAt;
publicfunction__construct(\DateTimeImmutable$createdAt)
{
$this->createdAt = $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()));
}
}ℹ️ 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% 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
- Test Driven Development: By Example / Kent Beck - the classic
- Unit Testing Principles, Practices, and Patterns / Vladimir Khorikov - the best book about tests I've ever read
Twitter:Kamil "sarven" Ruczyński
Blog EN:https://sarvendev.com/en/
Blog PL:https://sarvendev.com/