[Перевод] Юнит-тестирование на PHP в примерах
Сегодня юнит-тесты невероятно полезны. Думаю, они есть в большинстве из недавно созданных проектов. Юнит-тесты являются важнейшими в enterprise-приложениях с обилием бизнес-логики, потому что они быстрые и могут сразу сказать нам, корректна ли наша реализация. Однако я часто сталкиваюсь с проблемами, которые связаны с хорошими тестами, хотя те и крайне полезны. Я дам вам несколько советов с примерами, как писать хорошие юнит-тесты.
Содержание
- Тестовые дубли
- Наименования
- Шаблон AAA
- Мать объекта
- Параметризированный тест
- Две школы юнит-тестирования
- Моки и заглушки
- Три стиля юнит-тестирования
- Функциональная архитектура и тесты
- Наблюдаемое поведение и подробности реализации
- Единица поведения
- Шаблон humble
- Бесполезный тест
- Хрупкий тест
- Исправления тестов
- Общие антипаттерны тестирования
- Не гонитесь за полным покрытием
- Рекомендуемые книги
Тестовые дубли
Это фальшивые зависимости, используемые в тестах.
Заглушки (Stub)
Имитатор (Dummy)
Имитатор — всего лишь простая реализация, которая ничего не делает.
final class Mailer implements MailerInterface
{
public function send(Message $message): void
{
}
}
Фальшивка (Fake)
Фальшивка — это упрощённая реализация, эмулирующая нужное поведение.
final class InMemoryCustomerRepository implements CustomerRepositoryInterface
{
/**
* @var Customer[]
*/
private array $customers;
public function __construct()
{
$this->customers = [];
}
public function store(Customer $customer): void
{
$this->customers[(string) $customer->id()->id()] = $customer;
}
public function get(CustomerId $id): Customer
{
if (!isset($this->customers[(string) $id->id()])) {
throw new CustomerNotFoundException();
}
return $this->customers[(string) $id->id()];
}
public function findByEmail(Email $email): Customer
{
foreach ($this->customers as $customer) {
if ($customer->getEmail()->isEqual($email)) {
return $customer;
}
}
throw new CustomerNotFoundException();
}
}
Заглушка (Stub)
Заглушка — это простейшая реализация с прописанным в коде поведением.
final class UniqueEmailSpecificationStub implements UniqueEmailSpecificationInterface
{
public function isUnique(Email $email): bool
{
return true;
}
}
$specificationStub = $this->createStub(UniqueEmailSpecificationInterface::class);
$specificationStub->method('isUnique')->willReturn(true);
Моки (Mock)
Шпион (Spy)
Шпион — реализация для проверки конкретного поведения.
final class Mailer implements MailerInterface
{
/**
* @var Message[]
*/
private array $messages;
public function __construct()
{
$this->messages = [];
}
public function send(Message $message): void
{
$this->messages[] = $message;
}
public function getCountOfSentMessages(): int
{
return count($this->messages);
}
}
Мок (Mock)
Мок — сконфигурированная имитация для проверки вызовов взаимодействующих объектов.
$message = new Message('test@test.com', 'Test', 'Test test test');
$mailer = $this->createMock(MailerInterface::class);
$mailer
->expects($this->once())
->method('send')
->with($this->equalTo($message));
! Для проверки входящий взаимодействий используйте заглушку, а для проверки исходящих взаимодействий — мок. Подробнее об этом в главе Моки и заглушки.
Наименования
Плохо:
public function test(): void
{
$subscription = SubscriptionMother::new();
$subscription->activate();
self::assertSame(Status::activated(), $subscription->status());
}
Явно указывайте, что вы тестируете.
public function sut(): void
{
// sut = System under test
$sut = SubscriptionMother::new();
$sut->activate();
self::assertSame(Status::activated(), $sut->status());
}
Плохо:
public function it_throws_invalid_credentials_exception_when_sign_in_with_invalid_credentials(): void
{
}
public function testCreatingWithATooShortPasswordIsNotPossible(): void
{
}
public function testDeactivateASubscription(): void
{
}
Лучше:
- Использование нижних подчёркиваний повышает удобочитаемость.
- Наименование должно описывать поведение, а не реализацию.
- Используйте наименования без технических терминов. Они должны быть понятны непрограммистам.
public function sign_in_with_invalid_credentials_is_not_possible(): void
{
}
public function creating_with_a_too_short_password_is_not_possible(): void
{
}
public function deactivating_an_activated_subscription_is_valid(): void
{
}
public function deactivating_an_inactive_subscription_is_invalid(): void
{
}
Описание поведения важно при тестировании предметных сценариев. Если ваш код утилитарный, то это уже не так важно.
Почему важно, чтобы непрограммисты могли читать юнит-тесты? Если в проекте сложная предметная логика, то эта логика должна быть очевидна для всех, а для этого тесты должны описывать подробности без технических терминов, чтобы вы могли говорить с представителями бизнеса на том же языке, что используется в тестах. Освободите от терминов и весь код, связанный с предметной областью, иначе непрограммисты не смогут понять эти тесты. Не надо писать в комментариях «возвращает null», «бросает исключение» и т.д. Такая информация не относится к предметной области.
Шаблон AAA
Также известен как «Given, When, Then».
Выделяйте в тестах три этапа:
- Arrange: приведите тестируемую систему к нужному состоянию. Подготовьте зависимости, аргументы, и создайте SUT.
- Act: извлеките тестируемый элемент.
- Assert: проверьте результат, финальное состояние или взаимодействие с другими объектами.
public function aaa_pattern_example_test(): void
{
//Arrange|Given
$sut = SubscriptionMother::new();
//Act|When
$sut->activate();
//Assert|Then
self::assertSame(Status::activated(), $sut->status());
}
Мать объекта
Этот шаблон помогает создавать конкретные объекты, которые можно использовать в нескольких тестах. Благодаря этому этап «arrange» получается кратким, а весь тест — более удобочитаемым.
final class SubscriptionMother
{
public static function new(): Subscription
{
return new Subscription();
}
public static function activated(): Subscription
{
$subscription = new Subscription();
$subscription->activate();
return $subscription;
}
public static function deactivated(): Subscription
{
$subscription = self::activated();
$subscription->deactivate();
return $subscription;
}
}
final class ExampleTest
{
public function example_test_with_activated_subscription(): void
{
$activatedSubscription = SubscriptionMother::activated();
// do something
// check something
}
public function example_test_with_deactivated_subscription(): void
{
$deactivatedSubscription = SubscriptionMother::deactivated();
// do something
// check something
}
}
Параметризированный тест
Параметризированный тест — хороший способ тестирования SUT с многочисленными параметрами без повторения кода. Но такие тесты менее удобочитаемые. Чтобы немного улучшить ситуацию, отрицательные и положительные примеры нужно раскидать по разным тестам.
final class ExampleTest extends TestCase
{
/**
* @test
* @dataProvider getInvalidEmails
*/
public function detects_an_invalid_email_address(string $email): void
{
$sut = new EmailValidator();
$result = $sut->isValid($email);
self::assertFalse($result);
}
/**
* @test
* @dataProvider getValidEmails
*/
public function detects_an_valid_email_address(string $email): void
{
$sut = new EmailValidator();
$result = $sut->isValid($email);
self::assertTrue($result);
}
public function getInvalidEmails(): array
{
return [
['test'],
['test@'],
['test@test'],
//...
];
}
public function getValidEmails(): array
{
return [
['test@test.com'],
['test123@test.com'],
['Test123@test.com'],
//...
];
}
}
Две школы юнит-тестирования
Классическая (Детройтская школа)
- Модуль — это единица поведения, может состоять из нескольких взаимосвязанных классов.
- Все тесты должны быть изолированы друг от друга. Должна быть возможность вызывать их параллельно или в произвольном порядке.
final class TestExample extends TestCase
{
/**
* @test
*/
public function suspending_an_subscription_with_can_always_suspend_policy_is_always_possible(): void
{
$canAlwaysSuspendPolicy = new CanAlwaysSuspendPolicy();
$sut = new Subscription();
$result = $sut->suspend($canAlwaysSuspendPolicy);
self::assertTrue($result);
self::assertSame(Status::suspend(), $sut->status());
}
}
Моковая (Лондонская школа)
- Модуль — это один класс.
- Модуль должен быть изолирован от взаимодействующих объектов.
final class TestExample extends TestCase
{
/**
* @test
*/
public function suspending_an_subscription_with_can_always_suspend_policy_is_always_possible(): void
{
$canAlwaysSuspendPolicy = $this->createStub(SuspendingPolicyInterface::class);
$canAlwaysSuspendPolicy->method('suspend')->willReturn(true);
$sut = new Subscription();
$result = $sut->suspend($canAlwaysSuspendPolicy);
self::assertTrue($result);
self::assertSame(Status::suspend(), $sut->status());
}
}
Классический подход лучше позволяет избегать хрупких тестов.
Зависимости
[TODO]
Моки и заглушки
Пример:
final class NotificationService
{
public function __construct(
private MailerInterface $mailer,
private MessageRepositoryInterface $messageRepository
) {}
public function send(): void
{
$messages = $this->messageRepository->getAll();
foreach ($messages as $message) {
$this->mailer->send($message);
}
}
}
Плохо:
- Проверочные взаимодействия с заглушками приводят к хрупким тестам.
final class TestExample extends TestCase
{
/**
* @test
*/
public function sends_all_notifications(): void
{
$message1 = new Message();
$message2 = new Message();
$messageRepository = $this->createMock(MessageRepositoryInterface::class);
$messageRepository->method('getAll')->willReturn([$message1, $message2]);
$mailer = $this->createMock(MailerInterface::class);
$sut = new NotificationService($mailer, $messageRepository);
$messageRepository->expects(self::once())->method('getAll');
$mailer->expects(self::exactly(2))->method('send')
->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);
$sut->send();
}
}
Хорошо:
final class TestExample extends TestCase
{
/**
* @test
*/
public function sends_all_notifications(): void
{
$message1 = new Message();
$message2 = new Message();
$messageRepository = $this->createStub(MessageRepositoryInterface::class);
$messageRepository->method('getAll')->willReturn([$message1, $message2]);
$mailer = $this->createMock(MailerInterface::class);
$sut = new NotificationService($mailer, $messageRepository);
// Removed asserting interactions with the stub
$mailer->expects(self::exactly(2))->method('send')
->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);
$sut->send();
}
}
Три стиля юнит-тестирования
Результат
Лучший вариант:
- Наилучшая сопротивляемость рефакторингу.
- Наилучшая точность.
- Меньше всего усилий по сопровождению.
- Если возможно, применяйте этот вид тестов.
final class ExampleTest extends TestCase
{
/**
* @test
* @dataProvider getInvalidEmails
*/
public function detects_an_invalid_email_address(string $email): void
{
$sut = new EmailValidator();
$result = $sut->isValid($email);
self::assertFalse($result);
}
/**
* @test
* @dataProvider getValidEmails
*/
public function detects_an_valid_email_address(string $email): void
{
$sut = new EmailValidator();
$result = $sut->isValid($email);
self::assertTrue($result);
}
public function getInvalidEmails(): array
{
return [
['test'],
['test@'],
['test@test'],
//...
];
}
public function getValidEmails(): array
{
return [
['test@test.com'],
['test123@test.com'],
['Test123@test.com'],
//...
];
}
}
Состояние
Вариант похуже:
- Хуже сопротивляемость рефакторингу.
- Хуже точность.
- Сложнее в сопровождении.
final class ExampleTest extends TestCase
{
/**
* @test
*/
public function adding_an_item_to_cart(): void
{
$item = new CartItem('Product');
$sut = new Cart();
$sut->addItem($item);
self::assertSame(1, $sut->getCount());
self::assertSame($item, $sut->getItems()[0]);
}
}
Взаимодействие
Худший вариант:
- Худшая сопротивляемость рефакторингу.
- Худшая точность.
- Сложнее всего в сопровождении.
final class ExampleTest extends TestCase
{
/**
* @test
*/
public function sends_all_notifications(): void
{
$message1 = new Message();
$message2 = new Message();
$messageRepository = $this->createStub(MessageRepositoryInterface::class);
$messageRepository->method('getAll')->willReturn([$message1, $message2]);
$mailer = $this->createMock(MailerInterface::class);
$sut = new NotificationService($mailer, $messageRepository);
$mailer->expects(self::exactly(2))->method('send')
->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);
$sut->send();
}
}
Функциональная архитектура и тесты
Плохо:
final class NameService
{
public function __construct(private CacheStorageInterface $cacheStorage) {}
public function loadAll(): void
{
$namesCsv = array_map('str_getcsv', file(__DIR__.'/../names.csv'));
$names = [];
foreach ($namesCsv as $nameData) {
if (!isset($nameData[0], $nameData[1])) {
continue;
}
$names[] = new Name($nameData[0], new Gender($nameData[1]));
}
$this->cacheStorage->store('names', $names);
}
}
Как тестировать подобный код? Это можно сделать только с помощью интеграционных тестов, потому что они напрямую используют инфраструктурный код, относящийся к файловой системе.
Хорошо:
Как и в функциональной архитектуре, нам нужно отделить код с побочными эффектами от кода, который содержит только логику.
final class NameParser
{
/**
* @param array $namesData
* @return Name[]
*/
public function parse(array $namesData): array
{
$names = [];
foreach ($namesData as $nameData) {
if (!isset($nameData[0], $nameData[1])) {
continue;
}
$names[] = new Name($nameData[0], new Gender($nameData[1]));
}
return $names;
}
}
final class CsvNamesFileLoader
{
public function load(): array
{
return array_map('str_getcsv', file(__DIR__.'/../names.csv'));
}
}
final class ApplicationService
{
public function __construct(
private CsvNamesFileLoader $fileLoader,
private NameParser $parser,
private CacheStorageInterface $cacheStorage
) {}
public function loadNames(): void
{
$namesData = $this->fileLoader->load();
$names = $this->parser->parse($namesData);
$this->cacheStorage->store('names', $names);
}
}
final class ValidUnitExampleTest extends TestCase
{
/**
* @test
*/
public function parse_all_names(): void
{
$namesData = [
['John', 'M'],
['Lennon', 'U'],
['Sarah', 'W']
];
$sut = new NameParser();
$result = $sut->parse($namesData);
self::assertSame(
[
new Name('John', new Gender('M')),
new Name('Lennon', new Gender('U')),
new Name('Sarah', new Gender('W'))
],
$result
);
}
}
Наблюдаемое поведение и подробности реализации
Плохо:
final class ApplicationService
{
public function __construct(private SubscriptionRepositoryInterface $subscriptionRepository) {}
public function renewSubscription(int $subscriptionId): bool
{
$subscription = $this->subscriptionRepository->findById($subscriptionId);
if (!$subscription->getStatus()->isEqual(Status::expired())) {
return false;
}
$subscription->setStatus(Status::active());
$subscription->setModifiedAt(new \DateTimeImmutable());
return true;
}
}
final class Subscription
{
private Status $status;
private \DateTimeImmutable $modifiedAt;
public function __construct(Status $status, \DateTimeImmutable $modifiedAt)
{
$this->status = $status;
$this->modifiedAt = $modifiedAt;
}
public function getStatus(): Status
{
return $this->status;
}
public function setStatus(Status $status): void
{
$this->status = $status;
}
public function getModifiedAt(): \DateTimeImmutable
{
return $this->modifiedAt;
}
public function setModifiedAt(\DateTimeImmutable $modifiedAt): void
{
$this->modifiedAt = $modifiedAt;
}
}
final class InvalidTestExample extends TestCase
{
/**
* @test
*/
public function renew_an_expired_subscription_is_possible(): void
{
$modifiedAt = new \DateTimeImmutable();
$expiredSubscription = new Subscription(Status::expired(), $modifiedAt);
$repository = $this->createStub(SubscriptionRepositoryInterface::class);
$repository->method('findById')->willReturn($expiredSubscription);
$sut = new ApplicationService($repository);
$result = $sut->renewSubscription(1);
self::assertSame(Status::active(), $expiredSubscription->getStatus());
self::assertGreaterThan($modifiedAt, $expiredSubscription->getModifiedAt());
self::assertTrue($result);
}
/**
* @test
*/
public function renew_an_active_subscription_is_not_possible(): void
{
$modifiedAt = new \DateTimeImmutable();
$activeSubscription = new Subscription(Status::active(), $modifiedAt);
$repository = $this->createStub(SubscriptionRepositoryInterface::class);
$repository->method('findById')->willReturn($activeSubscription);
$sut = new ApplicationService($repository);
$result = $sut->renewSubscription(1);
self::assertSame($modifiedAt, $activeSubscription->getModifiedAt());
self::assertFalse($result);
}
}
Хорошо:
final class ApplicationService
{
public function __construct(private SubscriptionRepositoryInterface $subscriptionRepository) {}
public function renewSubscription(int $subscriptionId): bool
{
$subscription = $this->subscriptionRepository->findById($subscriptionId);
return $subscription->renew(new \DateTimeImmutable());
}
}
final class Subscription
{
private Status $status;
private \DateTimeImmutable $modifiedAt;
public function __construct(\DateTimeImmutable $modifiedAt)
{
$this->status = Status::new();
$this->modifiedAt = $modifiedAt;
}
public function renew(\DateTimeImmutable $modifiedAt): bool
{
if (!$this->status->isEqual(Status::expired())) {
return false;
}
$this->status = Status::active();
$this->modifiedAt = $modifiedAt;
return true;
}
public function active(\DateTimeImmutable $modifiedAt): void
{
//simplified
$this->status = Status::active();
$this->modifiedAt = $modifiedAt;
}
public function expire(\DateTimeImmutable $modifiedAt): void
{
//simplified
$this->status = Status::expired();
$this->modifiedAt = $modifiedAt;
}
public function isActive(): bool
{
return $this->status->isEqual(Status::active());
}
}
final class ValidTestExample extends TestCase
{
/**
* @test
*/
public function renew_an_expired_subscription_is_possible(): void
{
$expiredSubscription = SubscriptionMother::expired();
$repository = $this->createStub(SubscriptionRepositoryInterface::class);
$repository->method('findById')->willReturn($expiredSubscription);
$sut = new ApplicationService($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
*/
public function renew_an_active_subscription_is_not_possible(): void
{
$activeSubscription = SubscriptionMother::active();
$repository = $this->createStub(SubscriptionRepositoryInterface::class);
$repository->method('findById')->willReturn($activeSubscription);
$sut = new ApplicationService($repository);
$result = $sut->renewSubscription(1);
self::assertTrue($activeSubscription->isActive());
self::assertFalse($result);
}
}
У первой модели подписки плохая архитектура. Для вызова одной бизнес-операции нужно вызывать три метода. Также не рекомендуется использовать методы-получатели (геттеры) для проверки операции. В данном примере пропущена проверка изменения
modifiedAt
. Возможно, указание конкретного modifiedAt
в ходе операции renew
можно протестировать с помощью бизнес-операции устаревания. Для modifiedAt
метод-получатель не требуется. Конечно, есть ситуации, в которых очень трудно найти способ избежать использования методов-получателей только для тестов, но их нужно избегать всеми силами.Единица поведения
Плохо:
class CannotSuspendExpiredSubscriptionPolicy implements SuspendingPolicyInterface
{
public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool
{
if ($subscription->isExpired()) {
return false;
}
return true;
}
}
class CannotSuspendExpiredSubscriptionPolicyTest extends TestCase
{
/**
* @test
*/
public function it_returns_true_when_a_subscription_is_expired(): void
{
$policy = new CannotSuspendExpiredSubscriptionPolicy();
$subscription = $this->createStub(Subscription::class);
$subscription->method('isExpired')->willReturn(true);
self::assertFalse($policy->suspend($subscription, new \DateTimeImmutable()));
}
/**
* @test
*/
public function it_returns_false_when_a_subscription_is_not_expired(): void
{
$policy = new CannotSuspendExpiredSubscriptionPolicy();
$subscription = $this->createStub(Subscription::class);
$subscription->method('isExpired')->willReturn(false);
self::assertTrue($policy->suspend($subscription, new \DateTimeImmutable()));
}
}
class CannotSuspendNewSubscriptionPolicy implements SuspendingPolicyInterface
{
public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool
{
if ($subscription->isNew()) {
return false;
}
return true;
}
}
class CannotSuspendNewSubscriptionPolicyTest extends TestCase
{
/**
* @test
*/
public function it_returns_false_when_a_subscription_is_new(): void
{
$policy = new CannotSuspendNewSubscriptionPolicy();
$subscription = $this->createStub(Subscription::class);
$subscription->method('isNew')->willReturn(true);
self::assertFalse($policy->suspend($subscription, new \DateTimeImmutable()));
}
/**
* @test
*/
public function it_returns_true_when_a_subscription_is_not_new(): void
{
$policy = new CannotSuspendNewSubscriptionPolicy();
$subscription = $this->createStub(Subscription::class);
$subscription->method('isNew')->willReturn(false);
self::assertTrue($policy->suspend($subscription, new \DateTimeImmutable()));
}
}
class CanSuspendAfterOneMonthPolicy implements SuspendingPolicyInterface
{
public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool
{
$oneMonthEarlierDate = \DateTime::createFromImmutable($at)->sub(new \DateInterval('P1M'));
return $subscription->isOlderThan(\DateTimeImmutable::createFromMutable($oneMonthEarlierDate));
}
}
class CanSuspendAfterOneMonthPolicyTest extends TestCase
{
/**
* @test
*/
public function it_returns_true_when_a_subscription_is_older_than_one_month(): void
{
$date = new \DateTimeImmutable('2021-01-29');
$policy = new CanSuspendAfterOneMonthPolicy();
$subscription = new Subscription(new \DateTimeImmutable('2020-12-28'));
self::assertTrue($policy->suspend($subscription, $date));
}
/**
* @test
*/
public function it_returns_false_when_a_subscription_is_not_older_than_one_month(): void
{
$date = new \DateTimeImmutable('2021-01-29');
$policy = new CanSuspendAfterOneMonthPolicy();
$subscription = new Subscription(new \DateTimeImmutable('2020-01-01'));
self::assertTrue($policy->suspend($subscription, $date));
}
}
class Status
{
private const EXPIRED = 'expired';
private const ACTIVE = 'active';
private const NEW = 'new';
private const SUSPENDED = 'suspended';
private string $status;
private function __construct(string $status)
{
$this->status = $status;
}
public static function expired(): self
{
return new self(self::EXPIRED);
}
public static function active(): self
{
return new self(self::ACTIVE);
}
public static function new(): self
{
return new self(self::NEW);
}
public static function suspended(): self
{
return new self(self::SUSPENDED);
}
public function isEqual(self $status): bool
{
return $this->status === $status->status;
}
}
class StatusTest extends TestCase
{
public function testEquals(): void
{
$status1 = Status::active();
$status2 = Status::active();
self::assertTrue($status1->isEqual($status2));
}
public function testNotEquals(): void
{
$status1 = Status::active();
$status2 = Status::expired();
self::assertFalse($status1->isEqual($status2));
}
}
class SubscriptionTest extends TestCase
{
/**
* @test
*/
public function suspending_a_subscription_is_possible_when_a_policy_returns_true(): void
{
$policy = $this->createMock(SuspendingPolicyInterface::class);
$policy->expects($this->once())->method('suspend')->willReturn(true);
$sut = new Subscription(new \DateTimeImmutable());
$result = $sut->suspend($policy, new \DateTimeImmutable());
self::assertTrue($result);
self::assertTrue($sut->isSuspended());
}
/**
* @test
*/
public function suspending_a_subscription_is_not_possible_when_a_policy_returns_false(): void
{
$policy = $this->createMock(SuspendingPolicyInterface::class);
$policy->expects($this->once())->method('suspend')->willReturn(false);
$sut = new Subscription(new \DateTimeImmutable());
$result = $sut->suspend($policy, new \DateTimeImmutable());
self::assertFalse($result);
self::assertFalse($sut->isSuspended());
}
/**
* @test
*/
public function it_returns_true_when_a_subscription_is_older_than_one_month(): void
{
$date = new \DateTimeImmutable();
$futureDate = $date->add(new \DateInterval('P1M'));
$sut = new Subscription($date);
self::assertTrue($sut->isOlderThan($futureDate));
}
/**
* @test
*/
public function it_returns_false_when_a_subscription_is_not_older_than_one_month(): void
{
$date = new \DateTimeImmutable();
$futureDate = $date->add(new \DateInterval('P1D'));
$sut = new Subscription($date);
self::assertTrue($sut->isOlderThan($futureDate));
}
}
Не пишите код 1:1: один класс — один тест. Это приводит к хрупким тестам, что затрудняет рефакторинг.
Хорошо:
final class CannotSuspendExpiredSubscriptionPolicy implements SuspendingPolicyInterface
{
public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool
{
if ($subscription->isExpired()) {
return false;
}
return true;
}
}
final class CannotSuspendNewSubscriptionPolicy implements SuspendingPolicyInterface
{
public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool
{
if ($subscription->isNew()) {
return false;
}
return true;
}
}
final class CanSuspendAfterOneMonthPolicy implements SuspendingPolicyInterface
{
public function suspend(Subscription $subscription, \DateTimeImmutable $at): bool
{
$oneMonthEarlierDate = \DateTime::createFromImmutable($at)->sub(new \DateInterval('P1M'));
return $subscription->isOlderThan(\DateTimeImmutable::createFromMutable($oneMonthEarlierDate));
}
}
final class Status
{
private const EXPIRED = 'expired';
private const ACTIVE = 'active';
private const NEW = 'new';
private const SUSPENDED = 'suspended';
private string $status;
private function __construct(string $status)
{
$this->status = $status;
}
public static function expired(): self
{
return new self(self::EXPIRED);
}
public static function active(): self
{
return new self(self::ACTIVE);
}
public static function new(): self
{
return new self(self::NEW);
}
public static function suspended(): self
{
return new self(self::SUSPENDED);
}
public function isEqual(self $status): bool
{
return $this->status === $status->status;
}
}
final class Subscription
{
private Status $status;
private \DateTimeImmutable $createdAt;
public function __construct(\DateTimeImmutable $createdAt)
{
$this->status = Status::new();
$this->createdAt = $createdAt;
}
public function suspend(SuspendingPolicyInterface $suspendingPolicy, \DateTimeImmutable $at): bool
{
$result = $suspendingPolicy->suspend($this, $at);
if ($result) {
$this->status = Status::suspended();
}
return $result;
}
public function isOlderThan(\DateTimeImmutable $date): bool
{
return $this->createdAt < $date;
}
public function activate(): void
{
$this->status = Status::active();
}
public function expire(): void
{
$this->status = Status::expired();
}
public function isExpired(): bool
{
return $this->status->isEqual(Status::expired());
}
public function isActive(): bool
{
return $this->status->isEqual(Status::active());
}
public function isNew(): bool
{
return $this->status->isEqual(Status::new());
}
public function isSuspended(): bool
{
return $this->status->isEqual(Status::suspended());
}
}
final class SubscriptionSuspendingTest extends TestCase
{
/**
* @test
*/
public function suspending_an_expired_subscription_with_cannot_suspend_expired_policy_is_not_possible(): void
{
$sut = new Subscription(new \DateTimeImmutable());
$sut->activate();
$sut->expire();
$result = $sut->suspend(new CannotSuspendExpiredSubscriptionPolicy(), new \DateTimeImmutable());
self::assertFalse($result);
}
/**
* @test
*/
public function suspending_a_new_subscription_with_cannot_suspend_new_policy_is_not_possible(): void
{
$sut = new Subscription(new \DateTimeImmutable());
$result = $sut->suspend(new CannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable());
self::assertFalse($result);
}
/**
* @test
*/
public function suspending_an_active_subscription_with_cannot_suspend_new_policy_is_possible(): void
{
$sut = new Subscription(new \DateTimeImmutable());
$sut->activate();
$result = $sut->suspend(new CannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable());
self::assertTrue($result);
}
/**
* @test
*/
public function suspending_an_active_subscription_with_cannot_suspend_expired_policy_is_possible(): void
{
$sut = new Subscription(new \DateTimeImmutable());
$sut->activate();
$result = $sut->suspend(new CannotSuspendExpiredSubscriptionPolicy(), new \DateTimeImmutable());
self::assertTrue($result);
}
/**
* @test
*/
public function suspending_an_subscription_before_a_one_month_is_not_possible(): void
{
$sut = new Subscription(new \DateTimeImmutable('2020-01-01'));
$result = $sut->suspend(new CanSuspendAfterOneMonthPolicy(), new \DateTimeImmutable('2020-01-10'));
self::assertFalse($result);
}
/**
* @test
*/
public function suspending_an_subscription_after_a_one_month_is_possible(): void
{
$sut = new Subscription(new \DateTimeImmutable('2020-01-01'));
$result = $sut->suspend(new CanSuspendAfterOneMonthPolicy(), new \DateTimeImmutable('2020-02-02'));
self::assertTrue($result);
}
}
Шаблон humble
Как правильно выполнять юнит-тестирование такого класса?
class ApplicationService
{
public function __construct(
private OrderRepository $orderRepository,
private FormRepository $formRepository
) {}
public function changeFormStatus(int $orderId): void
{
$order = $this->orderRepository->getById($orderId);
$soapResponse = $this->getSoapClient()->getStatusByOrderId($orderId);
$form = $this->formRepository->getByOrderId($orderId);
$form->setStatus($soapResponse['status']);
$form->setModifiedAt(new \DateTimeImmutable());
if ($soapResponse['status'] === 'accepted') {
$order->setStatus('paid');
}
$this->formRepository->save($form);
$this->orderRepository->save($order);
}
private function getSoapClient(): \SoapClient
{
return new \SoapClient('https://legacy_system.pl/Soap/WebService', []);
}
}
Нужно разбить чрезмерно усложнённый код на отдельные классы.
final class ApplicationService
{
public function __construct(
private OrderRepositoryInterface $orderRepository,
private FormRepositoryInterface $formRepository,
private FormApiInterface $formApi,
private ChangeFormStatusService $changeFormStatusService
) {}
public function changeFormStatus(int $orderId): void
{
$order = $this->orderRepository->getById($orderId);
$form = $this->formRepository->getByOrderId($orderId);
$status = $this->formApi->getStatusByOrderId($orderId);
$this->changeFormStatusService->changeStatus($order, $form, $status);
$this->formRepository->save($form);
$this->orderRepository->save($order);
}
}
final class ChangeFormStatusService
{
public function changeStatus(Order $order, Form $form, string $formStatus): void
{
$status = FormStatus::createFromString($formStatus);
$form->changeStatus($status);
if ($form->isAccepted()) {
$order->changeStatus(OrderStatus::paid());
}
}
}
final class ChangingFormStatusTest extends TestCase
{
/**
* @test
*/
public function changing_a_form_status_to_accepted_changes_an_order_status_to_paid(): void
{
$order = new Order();
$form = new Form();
$status = 'accepted';
$sut = new ChangeFormStatusService();
$sut->changeStatus($order, $form, $status);
self::assertTrue($form->isAccepted());
self::assertTrue($order->isPaid());
}
/**
* @test
*/
public function changing_a_form_status_to_refused_not_changes_an_order_status(): void
{
$order = new Order();
$form = new Form();
$status = 'new';
$sut = new ChangeFormStatusService();
$sut->changeStatus($order, $form, $status);
self::assertFalse($form->isAccepted());
self::assertFalse($order->isPaid());
}
}
Однако
ApplicationService
, вероятно, нужно проверить с помощью интеграционного теста с моком FormApiInterface
.Бесполезный тест
Плохо:
final class Customer
{
public function __construct(private string $name) {}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
}
final class CustomerTest extends TestCase
{
public function testSetName(): void
{
$customer = new Customer('Jack');
$customer->setName('John');
self::assertSame('John', $customer->getName());
}
}
final class EventSubscriber
{
public static function getSubscribedEvents(): array
{
return ['event' => 'onEvent'];
}
public function onEvent(): void
{
}
}
final class EventSubscriberTest extends TestCase
{
public function testGetSubscribedEvents(): void
{
$result = EventSubscriber::getSubscribedEvents();
self::assertSame(['event' => 'onEvent'], $result);
}
}
Тестировать код, не содержащий какой-либо сложной логики, не только бессмысленно, но и приводит к хрупким тестам.
Хрупкий тест
Плохо:
final class UserRepository
{
public function __construct(
private Connection $connection
) {}
public function getUserNameByEmail(string $email): ?array
{
return $this
->connection
->createQueryBuilder()
->from('user', 'u')
->where('u.email = :email')
->setParameter('email', $email)
->execute()
->fetch();
}
}
final class TestUserRepository extends TestCase
{
public function testGetUserNameByEmail(): void
{
$email = 'test@test.com';
$connection = $this->createMock(Connection::class);
$queryBuilder = $this->createMock(QueryBuilder::class);
$result = $this->createMock(ResultStatement::class);
$userRepository = new UserRepository($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);
}
}
Подобное тестирование репозиториев приводит к хрупким тестам и затрудняет рефакторинг. Тестируйте репозитории с помощью интеграционных тестов.
Исправления тестов
Плохо:
final class InvalidTest extends TestCase
{
private ?Subscription $subscription;
public function setUp(): void
{
$this->subscription = new Subscription(new \DateTimeImmutable());
$this->subscription->activate();
}
/**
* @test
*/
public function suspending_an_active_subscription_with_cannot_suspend_new_policy_is_possible(): void
{
$result = $this->subscription->suspend(new CannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable());
self::assertTrue($result);
}
/**
* @test
*/
public function suspending_an_active_subscription_with_cannot_suspend_expired_policy_is_possible(): void
{
$result = $this->subscription->suspend(new CannotSuspendExpiredSubscriptionPolicy(), new \DateTimeImmutable());
self::assertTrue($result);
}
/**
* @test
*/
public function suspending_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
}
}
Хорошо:
final class ValidTest extends TestCase
{
/**
* @test
*/
public function suspending_an_active_subscription_with_cannot_suspend_new_policy_is_possible(): void
{
$sut = $this->createAnActiveSubscription();
$result = $sut->suspend(new CannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable());
self::assertTrue($result);
}
/**
* @test
*/
public function suspending_an_active_subscription_with_cannot_suspend_expired_policy_is_possible(): void
{
$sut = $this->createAnActiveSubscription();
$result = $sut->suspend(new CannotSuspendExpiredSubscriptionPolicy(), new \DateTimeImmutable());
self::assertTrue($result);
}
/**
* @test
*/
public function suspending_a_new_subscription_with_cannot_suspend_new_policy_is_not_possible(): void
{
$sut = $this->createANewSubscription();
$result = $sut->suspend(new CannotSuspendNewSubscriptionPolicy(), new \DateTimeImmutable());
self::assertFalse($result);
}
private function createANewSubscription(): Subscription
{
return new Subscription(new \DateTimeImmutable());
}
private function createAnActiveSubscription(): Subscription
{
$subscription = new Subscription(new \DateTimeImmutable());
$subscription->activate();
return $subscription;
}
}
- Лучше избегать использования общего для нескольких тестов состояния.
- Чтобы повторно использовать элементы в нескольких тестах применяйте:
Общие антипаттерны тестирования
Раскрытие приватного состояния
Плохо:
final class Customer
{
private CustomerType $type;
private DiscountCalculationPolicyInterface $discountCalculationPolicy;
public function __construct()
{
$this->type = CustomerType::NORMAL();
$this->discountCalculationPolicy = new NormalDiscountPolicy();
}
public function makeVip(): void
{
$this->type = CustomerType::VIP();
$this->discountCalculationPolicy = new VipDiscountPolicy();
}
public function getCustomerType(): CustomerType
{
return $this->type;
}
public function getPercentageDiscount(): int
{
return $this->discountCalculationPolicy->getPercentageDiscount();
}
}
final class InvalidTest extends TestCase
{
public function testMakeVip(): void
{
$sut = new Customer();
$sut->makeVip();
self::assertSame(CustomerType::VIP(), $sut->getCustomerType());
}
}
Хорошо:
final class Customer
{
private CustomerType $type;
private DiscountCalculationPolicyInterface $discountCalculationPolicy;
public function __construct()
{
$this->type = CustomerType::NORMAL();
$this->discountCalculationPolicy = new NormalDiscountPolicy();
}
public function makeVip(): void
{
$this->type = CustomerType::VIP();
$this->discountCalculationPolicy = new VipDiscountPolicy();
}
public function getPercentageDiscount(): int
{
return $this->discountCalculationPolicy->getPercentageDiscount();
}
}
final class ValidTest extends TestCase
{
/**
* @test
*/
public function a_vip_customer_has_a_25_percentage_discount(): void
{
$sut = new Customer();
$sut->makeVip();
self::assertSame(25, $sut->getPercentageDiscount());
}
}
Внесение дополнительного production-кода (например, метода-получателя
getCustomerType()
) только ради проверки состояния в тестах — плохая практика. Состояние нужно проверять другим важным предметным значением (в этом случае — getPercentageDiscount()
). Конечно, иногда трудно найти другой способ проверки операции, и мы можем оказаться вынуждены внести дополнительный production-код для проверки корректности тестов, но нужно стараться избегать этого.Утечка подробностей о предметной области
final class DiscountCalculator
{
public function calculate(int $isVipFromYears): int
{
Assert::greaterThanEq($isVipFromYears, 0);
return min(($isVipFromYears * 10) + 3, 80);
}
}
Плохо:
final class InvalidTest extends TestCase
{
/**
* @dataProvider discountDataProvider
*/
public function testCalculate(int $vipDaysFrom, int $expected): void
{
$sut = new DiscountCalculator();
self::assertSame($expected, $sut->calculate($vipDaysFrom));
}
public function discountDataProvider(): array
{
return [
[0, 0 * 10 + 3], //leaking domain details
[1, 1 * 10 + 3],
[5, 5 * 10 + 3],
[8, 80]
];
}
}
Хорошо:
final class ValidTest extends TestCase
{
/**
* @dataProvider discountDataProvider
*/
public function testCalculate(int $vipDaysFrom, int $expected): void
{
$sut = new DiscountCalculator();
self::assertSame($expected, $sut->calculate($vipDaysFrom));
}
public function discountDataProvider(): array
{
return [
[0, 3],
[1, 13],
[5, 53],
[8, 80]
];
}
}
Не дублируйте в тестах production-логику. Проверяйте результаты с помощью прописанных в коде значений.
Мокинг конкретных классов
Плохо:
class DiscountCalculator
{
public function calculateInternalDiscount(int $isVipFromYears): int
{
Assert::greaterThanEq($isVipFromYears, 0);
return min(($isVipFromYears * 10) + 3, 80);
}
public function calculateAdditionalDiscountFromExternalSystem(): int
{
// get data from an external system to calculate a discount
return 5;
}
}
class OrderService
{
public function __construct(private DiscountCalculator $discountCalculator) {}
public function getTotalPriceWithDiscount(int $totalPrice, int $vipFromDays): int
{
$internalDiscount = $this->discountCalculator->calculateInternalDiscount($vipFromDays);
$externalDiscount = $this->discountCalculator->calculateAdditionalDiscountFromExternalSystem();
$discountSum = $internalDiscount + $externalDiscount;
return $totalPrice - (int) ceil(($totalPrice * $discountSum) / 100);
}
}
final class InvalidTest extends TestCase
{
/**
* @dataProvider orderDataProvider
*/
public function testGetTotalPriceWithDiscount(int $totalPrice, int $vipDaysFrom, int $expected): void
{
$discountCalculator = $this->createPartialMock(DiscountCalculator::class, ['calculateAdditionalDiscountFromExternalSystem']);
$discountCalculator->method('calculateAdditionalDiscountFromExternalSystem')->willReturn(5);
$sut = new OrderService($discountCalculator);
self::assertSame($expected, $sut->getTotalPriceWithDiscount($totalPrice, $vipDaysFrom));
}
public function orderDataProvider(): array
{
return [
[1000, 0, 920],
[500, 1, 410],
[644, 5, 270],
];
}
}
Хорошо:
interface ExternalDiscountCalculatorInterface
{
public function calculate(): int;
}
final class InternalDiscountCalculator
{
public function calculate(int $isVipFromYears): int
{
Assert::greaterThanEq($isVipFromYears, 0);
return min(($isVipFromYears * 10) + 3, 80);
}
}
final class OrderService
{
public function __construct(
private InternalDiscountCalculator $discountCalculator,
private ExternalDiscountCalculatorInterface $externalDiscountCalculator
) {}
public function getTotalPriceWithDiscount(int $totalPrice, int $vipFromDays): int
{
$internalDiscount = $this->discountCalculator->calculate($vipFromDays);
$externalDiscount = $this->externalDiscountCalculator->calculate();
$discountSum = $internalDiscount + $externalDiscount;
return $totalPrice - (int) ceil(($totalPrice * $discountSum) / 100);
}
}
final class ValidTest extends TestCase
{
/**
* @dataProvider orderDataProvider
*/
public function testGetTotalPriceWithDiscount(int $totalPrice, int $vipDaysFrom, int $expected): void
{
$externalDiscountCalculator = $this->createStub(ExternalDiscountCalculatorInterface::class);
$externalDiscountCalculator->method('calculate')->willReturn(5);
$sut = new OrderService(new InternalDiscountCalculator(), $externalDiscountCalculator);
self::assertSame($expected, $sut->getTotalPriceWithDiscount($totalPrice, $vipDaysFrom));
}
public function orderDataProvider(): array
{
return [
[1000, 0, 920],
[500, 1, 410],
[644, 5, 270],
];
}
}
Необходимость мокать конкретный класс для замены части его поведения означает, что этот класс, вероятно, слишком сложен и нарушает принцип единственной ответственности.
Тестирование приватных методов
final class OrderItem
{
public function __construct(private int $total) {}
public function getTotal(): int
{
return $this->total;
}
}
final class Order
{
/**
* @param OrderItem[] $items
* @param int $transportCost
*/
public function __construct(private array $items, private int $transportCost) {}
public function getTotal(): int
{
return $this->getItemsTotal() + $this->transportCost;
}
private function getItemsTotal(): int
{
return array_reduce(
array_map(fn (OrderItem $item) => $item->getTotal(), $this->items),
fn (int $sum, int $total) => $sum += $total,
0
);
}
}
Плохо:
final class InvalidTest extends TestCase
{
/**
* @test
* @dataProvider ordersDataProvider
*/
public function get_total_returns_a_total_cost_of_a_whole_order(Order $order, int $expectedTotal): void
{
self::assertSame($expectedTotal, $order->getTotal());
}
/**
* @test
* @dataProvider orderItemsDataProvider
*/
public function get_items_total_returns_a_total_cost_of_all_items(Order $order, int $expectedTotal): void
{
self::assertSame($expectedTotal, $this->invokePrivateMethodGetItemsTotal($order));
}
public function ordersDataProvider(): array
{
return [
[new Order([new OrderItem(20), new OrderItem(20), new OrderItem(20)], 15), 75],
[new Order([new OrderItem(20), new OrderItem(30), new OrderItem(40)], 0), 90],
[new Order([new OrderItem(99), new OrderItem(99), new OrderItem(99)], 9), 306]
];
}
public function orderItemsDataProvider(): array
{
return [
[new Order([new OrderItem(20), new OrderItem(20), new OrderItem(20)], 15), 60],
[new Order([new OrderItem(20), new OrderItem(30), new OrderItem(40)], 0), 90],
[new Order([new OrderItem(99), new OrderItem(99), new OrderItem(99)], 9), 297]
];
}
private function invokePrivateMethodGetItemsTotal(Order &$order): int
{
$reflection = new \ReflectionClass(get_class($order));
$method = $reflection->getMethod('getItemsTotal');
$method->setAccessible(true);
return $method->invokeArgs($order, []);
}
}
Хорошо:
final class ValidTest extends TestCase
{
/**
* @test
* @dataProvider ordersDataProvider
*/
public function get_total_returns_a_total_cost_of_a_whole_order(Order $order, int $expectedTotal): void
{
self::assertSame($expectedTotal, $order->getTotal());
}
public function ordersDataProvider(): array
{
return [
[new Order([new OrderItem(20), new OrderItem(20), new OrderItem(20)], 15), 75],
[new Order([new OrderItem(20), new OrderItem(30), new OrderItem(40)], 0), 90],
[new Order([new OrderItem(99), new OrderItem(99), new OrderItem(99)], 9), 306]
];
}
}
Тесты должны проверять только публичный API.
Время как непостоянная зависимость
Время является непостоянной зависимостью из-за своего недетерминизма. Каждый вызов даёт другой результат.
Плохо:
final class Clock
{
public static \DateTime|null $currentDateTime = null;
public static function getCurrentDateTime(): \DateTime
{
if (null === self::$currentDateTime) {
self::$currentDateTime = new \DateTime();
}
return self::$currentDateTime;
}
public static function set(\DateTime $dateTime): void
{
self::$currentDateTime = $dateTime;
}
public static function reset(): void
{
self::$currentDateTime = null;
}
}
final class Customer
{
private \DateTime $createdAt;
public function __construct()
{
$this->createdAt = Clock::getCurrentDateTime();
}
public function isVip(): bool
{
return $this->createdAt->diff(Clock::getCurrentDateTime())->y >= 1;
}
}
final class InvalidTest extends TestCase
{
/**
* @test
*/
public function a_customer_registered_more_than_a_one_year_ago_is_a_vip(): void
{
Clock::set(new \DateTime('2019-01-01'));
$sut = new Customer();
Clock::reset(); // you have to remember about resetting the shared state
self::assertTrue($sut->isVip());
}
/**
* @test
*/
public function a_customer_registered_less_than_a_one_year_ago_is_not_a_vip(): void
{
Clock::set((new \DateTime())->sub(new \DateInterval('P2M')));
$sut = new Customer();
Clock::reset(); // you have to remember about resetting the shared state
self::assertFalse($sut->isVip());
}
}
Хорошо:
interface ClockInterface
{
public function getCurrentTime(): \DateTimeImmutable;
}
final class Clock implements ClockInterface
{
private function __construct()
{
}
public static function create(): self
{
return new self();
}
public function getCurrentTime(): \DateTimeImmutable
{
return new \DateTimeImmutable();
}
}
final class FixedClock implements ClockInterface
{
private function __construct(private \DateTimeImmutable $fixedDate) {}
public static function create(\DateTimeImmutable $fixedDate): self
{
return new self($fixedDate);
}
public function getCurrentTime(): \DateTimeImmutable