Сегодня юнит-тесты невероятно полезны. Думаю, они есть в большинстве из недавно созданных проектов. Юнит-тесты являются важнейшими в 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 { return $this->fixedDate; } } final class Customer { private \DateTimeImmutable $createdAt; public function __construct(\DateTimeImmutable $createdAt) { $this->createdAt = $createdAt; } public function isVip(\DateTimeImmutable $currentDate): bool { return $this->createdAt->diff($currentDate)->y >= 1; } } final class ValidTest extends TestCase { /** * @test */ public function a_customer_registered_more_than_a_one_year_ago_is_a_vip(): void { $sut = new Customer(FixedClock::create(new \DateTimeImmutable('2019-01-01'))->getCurrentTime()); self::assertTrue($sut->isVip(FixedClock::create(new \DateTimeImmutable('2020-01-02'))->getCurrentTime())); } /** * @test */ public function a_customer_registered_less_than_a_one_year_ago_is_not_a_vip(): void { $sut = new Customer(FixedClock::create(new \DateTimeImmutable('2019-01-01'))->getCurrentTime()); self::assertFalse($sut->isVip(FixedClock::create(new \DateTimeImmutable('2019-05-02'))->getCurrentTime())); } }
В коде, относящемся к предметной области, нельзя напрямую генерировать время и случайные числа. Для проверки поведения нужны детерминистские результаты, поэтому нужно внедрять эти значения в объект, относящийся к предметной области, как в примере выше.
Не гонитесь за полным покрытием
Полное покрытие не является целью, или даже не желательно, потому что в противном случае тесты наверняка будут очень хрупкими, а рефакторинг — очень сложным. Мутационное тестирование даёт более полезную обратную связь о качестве тестов. Подробнее.
Рекомендуемые книги
- Test Driven Development: By Example / Kent Beck — классика.
- Unit Testing Principles, Practices, and Patterns / Vladimir Khorikov — лучшая известная мне книга о тестировании.
ссылка на оригинал статьи https://habr.com/ru/company/mailru/blog/549698/
Добавить комментарий