В наше время преимущества написания модульных тестов огромны. Я думаю, что большинство недавно начатых проектов содержат какие-либо юнит-тесты. В корпоративных приложениях с большим количеством бизнес-логики модульные тесты являются наиболее важными тестами, поскольку они выполняются быстро и позволяют мгновенно убедиться в правильности нашей реализации. Однако я часто вижу проблему с хорошими тестами в проектах, хотя польза от этих тестов огромна только тогда, когда у вас есть хорошие модульные тесты. Итак, в этих примерах я попытаюсь поделиться некоторыми советами о том, что нужно делать, чтобы писать хорошие модульные тесты.
Удобная для чтения версия: https://testing-tips.sarvendev.com/
Камиль Ручиньски
Блог: https://sarvendev.com/
LinkedIn: https://www.linkedin.com/in/kamilruczynski/
Ваша поддержка значит для меня мир! Если вам понравилось это руководство и вы цените полученные знания, поддержите меня на BuyMeCoffee:
или просто оставьте звездочку в репозитории и подпишитесь на меня в Twitter и Github, чтобы быть в курсе всех обновлений. Ваша щедрость подогревает мою страсть к созданию для вас более познавательного контента.
Если у вас есть идеи по улучшению или тема, о которой можно написать, подготовьте запрос на включение или просто дайте мне знать.
Подпишитесь и освойте модульное тестирование с помощью моей БЕСПЛАТНОЙ электронной книги!
Подробности
У меня все еще есть довольно длинный список TODO улучшений этого руководства по модульному тестированию, и я представлю его в ближайшем будущем.
Введение
Автор
Тестовые двойки
Именование
ААА шаблон
Мать объекта
Строитель
Утверждать объект
Параметризованный тест
Две школы модульного тестирования
Классический
Насмешник
Зависимости
Мок против Стаба
Три стиля модульного тестирования
Выход
Состояние
Коммуникация
Функциональная архитектура и тесты
Наблюдаемое поведение и детали реализации
Единица поведения
Скромный образец
Тривиальный тест
Хрупкий тест
Тестовые приспособления
Общее тестирование антипаттернов
Разоблачение частного государства
Утечка данных о домене
Издевательские конкретные классы
Тестирование частных методов
Время как изменчивая зависимость
100% покрытие тестами не должно быть целью
Рекомендуемые книги
Тестовые двойники — это поддельные зависимости, используемые в тестах.
Манекен — это простая реализация, которая ничего не делает.
последний класс Mailer реализует MailerInterface {публичная функция send(Сообщение $message): void{ } }
Подделка — это упрощенная реализация, имитирующая исходное поведение.
последний класс InMemoryCustomerRepository реализует CustomerRepositoryInterface {/** * @var Customer[] */private array $customers;публичная функция __construct() {$this->клиенты = []; } Публичное хранилище функций (Клиент $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; } }выбросить новое исключение CustomerNotFoundException(); } }
Заглушка — это простейшая реализация с жестко запрограммированным поведением.
последний класс UniqueEmailSpecificationStub реализует UniqueEmailSpecificationInterface {публичная функция isUnique (электронная почта $email): bool {return true; } }
$specificationStub = $this->createStub(UniqueEmailSpecificationInterface::class);$specificationStub->method('isUnique')->willReturn(true);
Шпион — это реализация для проверки определенного поведения.
последний класс Mailer реализует MailerInterface {/** * @var Message[] */private array $messages; публичная функция __construct() {$this->messages = []; } Публичная функция send(Message $message): void{$this->messages[] = $message; } Публичная функция getCountOfSentMessages(): int{return count($this->messages); } }
Макет — это настроенная имитация для проверки вызовов соавтора.
$message = новое сообщение('[email protected]', 'Test', 'Тест-тест-тест');$mailer = $this->createMock(MailerInterface::class);$mailer->expects($this-> один раз()) -> метод('отправить') ->with($this->equalTo($message));
[!ВНИМАНИЕ] Для проверки входящих взаимодействий используйте заглушку, а для проверки исходящих взаимодействий используйте макет.
Подробнее: Mock против Stub
[!ПРЕДУПРЕЖДЕНИЕ|стиль:плоский|метка:НЕ ХОРОШО]
последний класс TestExample расширяет TestCase {/** * @test */public function send_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);$mailer->expects(self::exactly(2))->method ('отправлять') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);$sut->send(); } }
[!СОВЕТ|стиль:плоский|метка:ЛУЧШЕ]
Повышенная устойчивость к рефакторингу
Использование Refactor->Rename для конкретного метода не нарушает тест.
Лучшая читаемость
Более низкая стоимость ремонтопригодности
Не требуется изучать эти сложные структуры макетов.
Просто простой PHP-код
последний класс TestExample расширяет TestCase {/** * @test */public function send_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($message2);$mailer = новый SpyMailer();$sut = новый NotificationService($mailer, $messageRepository);$sut->send(); $mailer->assertThatMessagesHaveBeenSent([$message1, $message2]); } }
[!ПРЕДУПРЕЖДЕНИЕ|стиль:плоский|метка:НЕ ХОРОШО]
тест публичной функции(): void {$subscription = SubscriptionMother::new();$subscription->activate();self::assertSame(Status::activated(), $subscription->status()); }
[!TIP|style:flat|label:Явно укажите, что вы тестируете]
публичная функция sut(): void{// sut = тестируемая система$sut = SubscriptionMother::new();$sut->activate();self::assertSame(Status::activated(), $sut->status ()); }
[!ПРЕДУПРЕЖДЕНИЕ|стиль:плоский|метка:НЕ ХОРОШО]
общественная функция it_throws_invalid_credentials_Exception_when_sign_in_with_invalid_credentials(): void{ } Публичная функция testCreatingWithATooShortPasswordIsNotPossible(): void{ }публичная функция testDeactivateASubscription(): void{ }
[!СОВЕТ|стиль:плоский|метка:ЛУЧШЕ]
Использование подчеркивания улучшает читабельность
Имя должно описывать поведение, а не реализацию.
Используйте имена без технических ключевых слов. Оно должно быть читабельным для человека, не являющегося программистом.
общественная функция Sign_in_with_invalid_credentials_is_not_possible(): void{ }публичная функция create_with_a_too_short_password_is_not_possible(): void{ }публичная функция deactivating_an_activated_subscription_is_valid(): void{ }публичная функция deactivating_an_inactive_subscription_is_invalid(): void{ }
Примечание
Описание поведения важно при тестировании сценариев предметной области. Если ваш код просто служебный, это менее важно.
Почему непрограммисту будет полезно читать модульные тесты?
Если есть проект со сложной доменной логикой, эта логика должна быть предельно понятной для всех, чтобы тесты описывали детали предметной области без технических ключевых слов, и вы могли разговаривать с бизнесом на таком языке, как в этих тестах. Весь код, связанный с доменом, не должен содержать технических подробностей. Непрограммист эти тесты читать не будет. Если вы хотите поговорить о домене, эти тесты будут полезны, чтобы узнать, что делает этот домен. Будет описание без технических подробностей, например, возвращает значение null, выдает исключение и т. д. Такая информация не имеет никакого отношения к домену, поэтому нам не следует использовать эти ключевые слова.
Это также распространенное выражение «Дано, когда, тогда».
Выделите три раздела теста:
Организовать : Привести тестируемую систему в желаемое состояние. Подготовьте зависимости, аргументы и, наконец, постройте SUT.
Действие : вызвать тестируемый элемент.
Утверждение : проверьте результат, конечное состояние или связь с соавторами.
[!СОВЕТ|стиль:плоский|метка:ХОРОШО]
публичная функция aaa_pattern_example_test(): void{//Arrange|Given$sut = SubscriptionMother::new();//Act|When$sut->activate();//Assert|Thenself::assertSame(Status::activate( ), $sut->status()); }
Шаблон помогает создавать конкретные объекты, которые можно повторно использовать в нескольких тестах. Благодаря этому раздел компоновки является кратким, а тест в целом более читабельным.
последний класс по подпискемама {публичная статическая функция new(): Subscription{return new Subscription(); }публичная статическая функция активирована(): Subscription{$subscription = новая подписка();$subscription->activate();return $subscription; }публичная статическая функция деактивирована(): Subscription{$subscription = self::activated();$subscription->deactivate();return $subscription; } }
окончательный класс {публичная функция example_test_with_activated_subscription(): void{$activatedSubscription = SubscriptionMother::activated();// делаем что-то// проверяем что-то}публичная функция example_test_with_deactivated_subscription(): void{$deactivatedSubscription = SubscriptionMother::deactivated();// делаем что-нибудь // проверяем что-то} }
Builder — еще один шаблон, который помогает нам создавать объекты в тестах. По сравнению с Object Mother Pattern Builder лучше подходит для создания более сложных объектов.
последний класс OrderBuilder {private DateTimeImmutable|null $createdAt = null;/** * @var OrderItem[] */private array $items = [];публичная функция createAt(DateTimeImmutable $createdAt): self{$this->createdAt = $createdAt;return $ это; } Публичная функция withItem (строка $name, int $price): self {$this->items[] = new OrderItem($name, $price);return $this; }публичная функция build(): Order{ Assert::notEmpty($this->items); return new Order($this->createdAt ?? new DateTimeImmutable(),$this->items, ); } }
последний класс SampleTest расширяет TestCase {/** * @test */public function example_test_with_order_builder(): void{$order = (new OrderBuilder()) ->createdAt(new DateTimeImmutable('2022-11-10 20:00:00')) ->withItem('Предмет 1', 1000) ->withItem('Пункт 2', 2000) ->withItem('Элемент 3', 3000) ->build();// делаем что-то// что-то проверяем} }
Шаблон объекта Assert помогает писать более читаемые разделы Assert. Вместо использования нескольких утверждений мы можем просто подготовить абстракцию и использовать естественный язык для описания ожидаемого результата.
последний класс SampleTest расширяет TestCase {/** * @test */public function example_test_with_asserter(): void{$currentTime = new DateTimeImmutable('2022-11-10 20:00:00');$sut = new OrderService();$order = $sut ->создать($currentTime); OrderAsserter::assertThat($order) ->wasCreatedAt($currentTime) ->hasTotal(6000); } }
используйте PHPUnitFrameworkAssert; последний класс OrderAsserter {public function __construct(private readonly Order $order) {}public static function AssertThat(Order $order): self{return new OrderAsserter($order); } Публичная функция wasCreatedAt(DateTimeImmutable $createdAt): self{ Assert::assertEquals($createdAt, $this->order->createdAt);return $this; }публичная функция hasTotal(int $total): self{ Assert::assertSame($total, $this->order->getTotal()); return $this; } }
Параметризованный тест — хороший вариант для тестирования тестируемой системы со многими параметрами без повторения кода.
Предупреждение
Этот вид теста менее читаем. Чтобы немного повысить читабельность, отрицательные и положительные примеры следует разбить по разным тестам.
последний класс SampleTest расширяет TestCase {/** * @test * @dataProvider getInvalidEmails */public functionDetects_an_invalid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($email);self::assertFalse( $результат); }/** * @test * @dataProvider getValidEmails */public functionDetects_an_valid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($email);self::assertTrue( $результат); }public function getInvalidEmails(): iterable{yield 'Неверный адрес электронной почты без @' => ['test'];yield 'Недопустимый адрес электронной почты без домена после @' => ['test@'];yield 'Недействительный адрес электронной почты без TLD' => ['test@test'];//...}public function getValidEmails(): iterable{yield 'Действительный адрес электронной почты с строчными буквами' => ['[email protected]'];yield 'Действительный адрес электронной почты, содержащий строчные буквы и цифры' => ['[email protected]'];yield 'Действительный адрес электронной почты, содержащий заглавные буквы и цифры' => ['Test123@ test.com'];//...} }
Примечание
Используйте yield
и добавляйте текстовое описание к обращениям, чтобы улучшить читабельность.
Юнит — это единая единица поведения, это может быть несколько связанных классов.
Каждый тест должен быть изолирован от других. Поэтому должна быть возможность вызывать их параллельно или в любом порядке.
последний класс TestExample расширяет 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()); } }
Отряд представляет собой один класс.
Подразделение должно быть изолировано от всех сотрудников.
последний класс TestExample расширяет 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);$ сут = новый Subscription();$result = $sut->suspend($canAlwaysSuspendPolicy);self::assertTrue($result);self::assertSame(Status::suspend(), $sut->status()); } }
Примечание
При классическом подходе лучше избегать хрупких тестов.
[ТОДО]
Пример:
последний класс NotificationService {public function __construct(private readonly MailerInterface $mailer,private readonly MessageRepositoryInterface $messageRepository) {}public function send(): void {$messages = $this->messageRepository->getAll();foreach ($messages as $message) { $this->mailer->send($message); } } }
[!ПРЕДУПРЕЖДЕНИЕ|стиль:плоский|метка:ПЛОХО]
Утверждение взаимодействия с заглушками приводит к ненадежным тестам.
последний класс TestExample расширяет TestCase {/** * @test */public function send_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(); } }
[!СОВЕТ|стиль:плоский|метка:ХОРОШО]
последний класс TestExample расширяет TestCase {/** * @test */public функция send_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($message2);$mailer = $this->createMock(MailerInterface::class);$sut = new NotificationService($mailer, $messageRepository);// Удалены утверждения взаимодействия с методом заглушки $mailer->expects(self::exactly(2))-> ('отправлять') ->withConsecutive([self::equalTo($message1)], [self::equalTo($message2)]);$sut->send(); } }
[!СОВЕТ|стиль:плоский|метка:ДАЖЕ ЛУЧШЕ ИСПОЛЬЗОВАТЬ ШПИОНА]
последний класс TestExample расширяет TestCase {/** * @test */public function send_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($message2);$mailer = новый SpyMailer();$sut = новый NotificationService($mailer, $messageRepository);$sut->send(); $mailer->assertThatMessagesHaveBeenSent([$message1, $message2]); } }
[!TIP|стиль:плоский|метка:Лучший вариант]
Лучшая устойчивость к рефакторингу
Лучшая точность
Самая низкая стоимость ремонтопригодности
Если это возможно, вам следует предпочесть этот вид теста.
последний класс SampleTest расширяет TestCase {/** * @test * @dataProvider getInvalidEmails */public functionDetects_an_invalid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($email);self::assertFalse( $результат); }/** * @test * @dataProvider getValidEmails */public functionDetects_an_valid_email_address(string $email): void{$sut = new EmailValidator();$result = $sut->isValid($email);self::assertTrue( $результат); } Публичная функция getInvalidEmails(): array{return [ ['тест'], ['тест@'], ['test@test'],//...]; } Публичная функция getValidEmails(): array{return [ ['[email protected]'], ['[email protected]'], ['[email protected]'],//...]; } }
[!ПРЕДУПРЕЖДЕНИЕ|стиль:плоский|метка:Худший вариант]
Худшая устойчивость к рефакторингу
Хуже точность
Более высокая стоимость ремонтопригодности
последний класс SampleTest расширяет TestCase {/** * @test */public function add_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]); } }
[!ВНИМАНИЕ|стиль:плоский|метка:Худший вариант]
Худшая устойчивость к рефакторингу
Самая худшая точность
Самая высокая стоимость ремонтопригодности
последний класс SampleTest расширяет TestCase {/** * @test */public функция send_all_notifications(): void{$message1 = new Message();$message2 = new Message();$messageRepository = new InMemoryMessageRepository();$messageRepository->save($message1) ;$messageRepository->save($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(); } }
[!ПРЕДУПРЕЖДЕНИЕ|стиль:плоский|метка:ПЛОХО]
последний класс NameService {public function __construct(private readonly 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])) {продолжить; }$names[] = новое имя($nameData[0], новый пол($nameData[1])); }$this->cacheStorage->store('names', $names); } }
Как протестировать такой код? Это возможно только с помощью интеграционного теста, поскольку он напрямую использует код инфраструктуры, связанный с файловой системой.
[!СОВЕТ|стиль:плоский|метка:ХОРОШО]
Как и в функциональной архитектуре, нам нужно разделить код с побочными эффектами и код, содержащий только логику.
последний класс NameParser {/** * @param array<string[]> $namesData * @return Name[] */public function parse(array $namesData): array{$names = [];foreach ($namesData as $nameData) {if (!isset($nameData[0], $nameData[1])) {continue; }$names[] = новое имя($nameData[0], новый пол($nameData[1])); } вернуть $имена; } }
последний класс CsvNamesFileLoader {публичная функция load(): array{return array_map('str_getcsv', file(__DIR__.'/../names.csv')); } }
последний класс ApplicationService {public function __construct(private only readonly CsvNamesFileLoader $fileLoader,private only readonly NameParser $parser,private only readonly CacheStorageInterface $cacheStorage) {}public function loadNames(): void{$namesData = $this->fileLoader->load();$names = $this->parser->parse($namesData);$this->cacheStorage->store('names', $names); } }
последний класс ValidUnitExampleTest расширяет TestCase {/** * @test */public function parse_all_names(): void{$namesData = [ ['Джон', 'М'], ['Леннон', 'У'], ['Сара', 'W'] ];$sut = новый NameParser();$result = $sut->parse($namesData); self::assertSame( [новое имя («Джон», новый пол («M»)), новое имя («Леннон», новый пол («U»)), новое имя («Сара», новый пол («W»)) ],$результат); } }
[!ПРЕДУПРЕЖДЕНИЕ|стиль:плоский|метка:ПЛОХО]
последний класс ApplicationService {public function __construct(private readonly 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; } }
подписка на финальное занятие {публичная функция __construct (частный статус $status, частный DateTimeImmutable $modifiedAt) {}публичная функция getStatus(): Status{return $this->status; } Публичная функция setStatus (Состояние $status): void {$this->status = $status; } Публичная функция getModifiedAt(): DateTimeImmutable{return $this->modifiedAt; } Публичная функция setModifiedAt (DateTimeImmutable $modifiedAt): void {$this->modifiedAt = $modifiedAt; } }
последний класс InvalidTestExample расширяет TestCase {/** * @test */public function renew_an_expired_subscription_is_possible(): void{$modifiedAt = new DateTimeImmutable();$expiredSubscription = новая подписка(Status::expired(), $modifiedAt);$sut = new ApplicationService($this ->createRepository($expiredSubscription));$result = $sut->renewSubscription(1);self::assertSame(Status::active(), $expiredSubscription->getStatus());self::assertGreaterThan($modifiedAt, $expiredSubscription->getModifiedAt());self:: утверждатьTrue ($ результат); }/** * @test */public function renew_an_active_subscription_is_not_possible(): void{$modifiedAt = new DateTimeImmutable();$activeSubscription = новая подписка(Status::active(), $modifiedAt);$sut = new ApplicationService($this ->createRepository($activeSubscription));$result = $sut->renewSubscription(1);self::assertSame($modifiedAt, $activeSubscription->getModifiedAt());self::assertFalse($result); } частная функция createRepository(Subscription $subscription): SubscriptionRepositoryInterface{возвращает новый класс ($expiredSubscription) реализует SubscriptionRepositoryInterface {публичная функция __construct(частная подписка только для чтения $subscription) {} общественная функция findById(int $id): Subscription{return $this->subscription; } }; } }
[!СОВЕТ|стиль:плоский|метка:ХОРОШО]
последний класс ApplicationService {public function __construct (private readonly SubscriptionRepositoryInterface $subscriptionRepository) {}public function renewSubscription(int $subscriptionId): bool {$subscription = $this->subscriptionRepository->findById($subscriptionId); return $subscription->renew(new DateTimeImmutable( )); } }
подписка на финальное занятие {частный статус $status;частный DateTimeImmutable $modifiedAt;публичная функция __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;вернуть true; } Публичная функция active(DateTimeImmutable $modifiedAt): void{//simplified$this->status = Status::active();$this->modifiedAt = $modifiedAt; } публичная функция expire(DateTimeImmutable $modifiedAt): void{//simplified$this->status = Status::expired();$this->modifiedAt = $modifiedAt; } Публичная функция isActive(): bool{return $this->status->isEqual(Status::active()); } }
последний класс ValidTestExample расширяет TestCase {/** * @test */public function renew_an_expired_subscription_is_possible(): void{$expiredSubscription = SubscriptionMother::expired();$sut = new ApplicationService($this->createRepository($expiredSubscription));$result = $sut- >renewSubscription(1);// пропустить проверку модифицируемого объекта, поскольку это не является частью наблюдаемого поведения. Чтобы проверить это значение, нам // придется добавить метод получения для ModifiedAt, вероятно, только в целях тестирования.self::assertTrue($expiredSubscription->isActive());self::assertTrue($result); }/** * @test */public function renew_an_active_subscription_is_not_possible(): void{$activeSubscription = SubscriptionMother::active();$sut = new ApplicationService($this->createRepository($activeSubscription));$result = $sut->renewSubscription(1);self::assertTrue($activeSubscription->isActive());self::assertFalse($result); } частная функция createRepository(Subscription $subscription): SubscriptionRepositoryInterface{возвращает новый класс ($expiredSubscription) реализует SubscriptionRepositoryInterface {публичная функция __construct(частная подписка только для чтения $subscription) {} общественная функция findById(int $id): Subscription{return $this->subscription; } }; } }
Примечание
Первая модель подписки имеет плохой дизайн. Чтобы вызвать одну бизнес-операцию, вам необходимо вызвать три метода. Также использование геттеров для проверки работы не является хорошей практикой. В этом случае проверка изменения modifiedAt
пропускается, возможно, настройку конкретного modifiedAt
во время операции обновления можно протестировать с помощью бизнес-операции истечения срока действия. Геттер для modifiedAt
не требуется. Конечно, бывают случаи, когда найти возможность избежать геттеров, предусмотренных только для тестов, будет очень сложно, но всегда нужно стараться их не вводить.
[!ПРЕДУПРЕЖДЕНИЕ|стиль:плоский|метка:ПЛОХО]
класс CannotSuspendExpiredSubscriptionPolicy реализует SuspendingPolicyInterface {публичная функция suspend (подписка $subscription, DateTimeImmutable $at): bool {if ($subscription->isExpired()) {return false; } Вернуть истину; } }
класс CannotSuspendExpiredSubscriptionPolicyTest расширяет TestCase {/** * @test */public function it_returns_false_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_true_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())); } }
класс CannotSuspendNewSubscriptionPolicy реализует SuspendingPolicyInterface {публичная функция suspend (подписка $subscription, DateTimeImmutable $at): bool {if ($subscription->isNew()) {return false; } Вернуть истину; } }
класс CannotSuspendNewSubscriptionPolicyTest расширяет 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())); } }
класс CanSuspendAfterOneMonthPolicy реализует SuspendingPolicyInterface {публичная функция suspend (подписка $subscription, DateTimeImmutable $at): bool {$oneMonthEarlierDate = DateTime::createFromImmutable($at)->sub(new DateInterval('P1M')); return $subscription->isOlderThan(DateTimeImmutable:: createFromMutable($oneMonthEarlierDate)); } }
класс CanSuspendAfterOneMonthPolicyTest расширяет 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 = новая подписка(новая) DateTimeImmutable('2020-01-01'));self::assertTrue($policy->suspend($subscription, $date)); } }
Статус класса {private const EXPIRED = 'истёк';private const ACTIVE = 'active';private const NEW = 'new';private const SUSPENDED = 'приостановлено';частная функция __construct(частная строка только для чтения $status) {$this->статус = $статус; } публичная статическая функция 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); } публичная статическая функция приостановлено (): self {return new self (self:: SUSPENDED); } Публичная функция isEqual(self $status): bool{return $this->status === $status->status; } }
класс StatusTest расширяет TestCase {публичная функция testEquals(): void{$status1 = Status::active();$status2 = Status::active();self::assertTrue($status1->isEqual($status2)); } Публичная функция testNotEquals(): void{$status1 = Status::active();$status2 = Status::expired();self::assertFalse($status1->isEqual($status2)); } }
класс SubscriptionTest расширяет 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( 'приостановить')->willReturn(true);$sut = новая подписка(новая 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( 'приостановить')->willReturn(false);$sut = новый 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 = новая подписка($ 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, 1 класс : 1 тест. Это приводит к хрупким тестам, из-за которых рефакторинг становится затруднительным.
[!СОВЕТ|стиль:плоский|метка:ХОРОШО]
последний класс CannotSuspendExpiredSubscriptionPolicy реализует SuspendingPolicyInterface {публичная функция suspend (подписка $subscription, DateTimeImmutable $at): bool {if ($subscription->isExpired()) {return false; } Вернуть истину; } }
последний класс CannotSuspendNewSubscriptionPolicy реализует SuspendingPolicyInterface {публичная функция suspend (подписка $subscription, DateTimeImmutable $at): bool {if ($subscription->isNew()) {return false; } Вернуть истину; } }
последний класс CanSuspendAfterOneMonthPolicy реализует SuspendingPolicyInterface {публичная функция suspend (подписка $subscription, DateTimeImmutable $at): bool {$oneMonthEarlierDate = DateTime::createFromImmutable($at)->sub(new DateInterval('P1M')); return $subscription->isOlderThan(DateTimeImmutable:: createFromMutable($oneMonthEarlierDate)); } }
Статус финального класса {private const EXPIRED = 'истёк';private const ACTIVE = 'active';private const NEW = 'new';private const SUSPENDED = 'приостановлено';частная функция __construct(частная строка только для чтения $status) {$this->статус = $статус; } публичная статическая функция 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); } публичная статическая функция приостановлено (): self {return new self (self:: SUSPENDED); } Публичная функция isEqual(self $status): bool{return $this->status === $status->status; } }
подписка на финальное занятие {частный статус $status;частный DateTimeImmutable $createdAt;публичная функция __construct(DateTimeImmutable $createdAt) {$this->status = Status::new();$this->createdAt = $createdAt; } Публичная функция suspend(SuspendingPolicyInterface $suspendingPolicy, DateTimeImmutable $at): bool{$result = $suspendingPolicy->suspend($this, $at);if ($result) {$this->status = Status::suspended() ; } вернуть $результат; } Публичная функция isOlderThan(DateTimeImmutable $date): bool{return $this->createdAt <$date; }публичная функция active(): void{$this->status = Status::active(); }публичная функция expire(): void{$this->status = Status::expired(); } Публичная функция isExpired(): bool{return $this->status->isEqual(Status::expired()); } Публичная функция isActive(): bool{return $this->status->isEqual(Status::active()); } Публичная функция isNew(): bool{return $this->status->isEqual(Status::new()); } Публичная функция isSuspended(): bool{return $this->status->isEqual(Status::suspended()); } }
последний класс SubscriptionSuspendingTest расширяет 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 -> приостановить (новый CannotSuspendExpiredSubscriptionPolicy(), новый 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($результат); }/** * @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()) , новый 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()) , новый 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(), новый 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(), новый DateTimeImmutable('2020-02-02'));self::assertTrue($result); } }
Как правильно провести модульное тестирование такого класса?
класс ApplicationService {public function __construct(private OrderRepository $orderRepository,частный только для чтения FormRepository $formRepository) {}public functionchangeFormStatus(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'] === 'принято') {$order->setStatus('оплачено'); }$this->formRepository->save($form);$this->orderRepository->save($order); }частная функция getSoapClient(): SoapClient{return new SoapClient('https://legacy_system.pl/Soap/WebService', []); } }
[!СОВЕТ|стиль:плоский|метка:ХОРОШО]
Требуется разбить слишком сложный код на отдельные классы.
последний класс ApplicationService {public function __construct (частный OrderRepositoryInterface $orderRepository, частный только для чтения FormRepositoryInterface $formRepository, частный FormApiInterface только для чтения $formApi, частный только для чтения ChangeFormStatusService $changeFormStatusService) {}public functionchangeFormStatus(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); } }
последний класс ChangeFormStatusService {public functionchangeStatus(Order $order, Form $form, string $formStatus): void{$status = FormStatus::createFromString($formStatus);$form->changeStatus($status);if ($form->isAccepted( )) {$order->changeStatus(OrderStatus::paid()); } } }
последний класс ChangeFormStatusTest расширяет TestCase {/** * @test */public functionchange_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 functionchange_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.
[!ПРЕДУПРЕЖДЕНИЕ|стиль:плоский|метка:ПЛОХО]
клиент финального класса {публичная функция __construct(частная строка $name) {}публичная функция getName(): строка {return $this->name; } публичная функция setName (строка $name): void {$this->name = $name; } }
последний класс CustomerTest расширяет TestCase {public function testSetName(): void{$customer = new Customer('Jack');$customer->setName('John');self::assertSame('John', $customer->getName()); } }
последний класс EventSubscriber {публичная статическая функция getSubscribedEvents(): array{return ['event' => 'onEvent']; }публичная функция onEvent(): void{ } }
последний класс EventSubscriberTest расширяет TestCase {публичная функция testGetSubscribedEvents(): void {$result = EventSubscriber::getSubscribedEvents();self::assertSame(['event' => 'onEvent'], $result); } }
[!ВНИМАНИЕ] Тестирование кода без какой-либо сложной логики бессмысленно, но также приводит к ненадежным тестам.
[!ПРЕДУПРЕЖДЕНИЕ|стиль:плоский|метка:ПЛОХО]
последний класс UserRepository {публичная функция __construct(частное соединение только для чтения $connection) {}публичная функция getUserNameByEmail(строка $email): ?array{return $this->connection->createQueryBuilder() ->from('пользователь', 'ты') ->where('u.email = :email') ->setParameter('электронная почта', $email) -> выполнить() -> выборка(); } }
последний класс TestUserRepository расширяет TestCase {публичная функция testGetUserNameByEmail(): void {$email = '[email protected]';$connection = $this->createMock(Connection::class);$queryBuilder = $this->createMock(QueryBuilder::class); $result = $this->createMock(ResultStatement::class);$userRepository = новый UserRepository($connection);$connection->ожидает($this->once()) ->метод('createQueryBuilder') ->willReturn($queryBuilder);$queryBuilder->ожидает($this->once()) -> метод («из») ->with('пользователь', 'u') ->willReturn($queryBuilder);$queryBuilder->ожидает($this->once()) -> метод('где') ->with('u.email = :email') ->willReturn($queryBuilder);$queryBuilder->ожидает($this->once()) ->метод('установитьпараметр') ->with('электронная почта', $email) ->willReturn($queryBuilder);$queryBuilder->ожидает($this->once()) ->метод('выполнить') -> WillReturn ($ result); $ result-> ожидает ($ this-> one ()) -> Метод ('Fetch') -> WillReturn (['email' => $ email]); $ result = $ userRepository-> getUsernamebyemail ($ email); self :: assertsame (['email' => $ email], $ result); } }
[!ВНИМАНИЕ] Такое тестирование репозиториев приводит к ненадежным тестам, а затем рефакторинг становится трудным. Для тестирования репозиториев напишите интеграционные тесты.
[!СОВЕТ|стиль:плоский|метка:ХОРОШО]
Окончательный класс GoodTest Extends Testcase {private pootcpringFactory $ sut; public function setup (): void {$ this-> sut = new posppringFactory (); }/** * @test */public function creates_a_subscription_for_a_given_date_range (): void {$ result = $ this-> sut-> create (new DateTimeImb (Подписка :: class, $ result); }/** * @test */public function throws_an_exception_on_invalid_date_range (): void {$ this-> wearsException (createSubscriptionException :: class); $ result = $ this-> sut-> create (new DateTimeImmutable ('сейчас -1 год'), new DateTimeImmutable ()); } }
Примечание
Лучшим вариантом использования метода setUp будет тестирование объектов без сохранения состояния.
Любая конфигурация, созданная внутри setUp
, объединяет тесты и влияет на все тесты.
Лучше избегать общего состояния между тестами и настраивать начальное состояние в соответствии с методом тестирования.
Читабельность хуже по сравнению с конфигурацией, выполненной с помощью правильного метода тестирования.
[!СОВЕТ|стиль:плоский|метка:ЛУЧШЕ]
Окончательный класс BetterTest расширяет тестовый камень {/** * @test */public function suppling_an_active_subscription_with_cannot_suspend_new_policy_is_possible (): void {$ sut = $ this-> createAnactivesubscription (); $ result = $ sut-> suppust (new CannotsPendnewsubsUppriptionPolicy (), newsemitable; : asserttrue ($ result); }/** * @test */public function suppling_an_active_subscription_with_cannot_suspend_expired_policy_is_possible (): void {$ sut = $ this-> createAnactivesubscription (); $ result = $ sut-> suprendexpendexpiredsubsupcription (), $ sut-> newnatsUs DatetimeImmutable ()); self :: asserttrue ($ result); }/** * @test */public function suppling_a_new_subscription_with_cannot_suspend_new_policy_is_not_possible (): void {$ sut = $ this-> createAnewsubscription (); $ result = $ sut-> suprendendemememememememememememememememememememememememem : assertfalse ($ result); } частная функция createAnewSubscription (): подписка {return New Подписка (New DateTimeImmutable ()); } частная функция createActivesubscription (): подписка {$ ntabring = new Подписка (new DateTimeImmutable ()); $ pootppring-> activate (); вернуть $ подписки; } }
Примечание
Такой подход улучшает читаемость и уточняет разделение (код больше читается, чем пишется).
Использование частных помощников в каждом методе тестирования может оказаться утомительным, хотя они и предоставляют явные намерения.
Чтобы использовать схожие объекты тестирования между несколькими классами тестов, используйте:
Мать объекта
Строитель
[!ПРЕДУПРЕЖДЕНИЕ|стиль:плоский|метка:ПЛОХО]
Заключительный клиент {private Customertype $ type; Private DiscountCalculationPolicyInterface $ DiscountCalcalulationPolicy; Public Function __construct () {$ this-> type = customertype :: normal (); $ this-> discountCalculationPolicy = new NormalDiscountPolicy (); } публичная функция makeVip (): void {$ this-> type = customertype :: vip (); $ this-> discountCalculationPolicy = new vipdiscountpolicy (); } public function getCustomerType (): customertype {return $ this-> type; } public function getPerCentagEdIscount (): int {return $ this-> discountCalculationPolicy-> getpercentageScount (); } }
Окончательный класс Invalidtest Extends Testcase {public function testmakevip (): void {$ sut = new Customer (); $ sut-> makevip (); self :: assertsame (customertype :: vip (), $ sut-> getCustomerType ()); } }
[!СОВЕТ|стиль:плоский|метка:ХОРОШО]
Заключительный клиент {private Customertype $ type; Private DiscountCalculationPolicyInterface $ DiscountCalcalulationPolicy; Public Function __construct () {$ this-> type = customertype :: normal (); $ this-> discountCalculationPolicy = new NormalDiscountPolicy (); } публичная функция makeVip (): void {$ this-> type = customertype :: vip (); $ this-> discountCalculationPolicy = new vipdiscountpolicy (); } public function getPerCentagEdIscount (): int {return $ this-> discountCalculationPolicy-> getpercentageScount (); } }
Окончательный класс ValueStest Extends Testcase {/** * @test */public function a_vip_customer_has_a_25_percentage_discount (): void {$ sut = new Customer (); $ sut-> makevip (); self :: arsertsame (25, $ sut-> getpercentiscount ()); } }
[!ВНИМАНИЕ] Добавление дополнительного производственного кода (например, метода получения getCustomerType()) только для проверки состояния в тестах является плохой практикой. Оно должно быть проверено другим значимым значением домена (в данном случае getPercentageDiscount()). Конечно, иногда бывает сложно найти другой способ проверки работы, и нам могут быть вынуждены добавлять дополнительный производственный код для проверки правильности в тестах, но мы должны стараться избегать этого.
Окончательный класс дисконтизатор {Public Function Рассчитайте (int $ isvipfromyears): int { Assert :: Berreathaneq ($ iSvipfromyears, 0); return min (($ isvipfromyears * 10) + 3, 80); } }
[!ПРЕДУПРЕЖДЕНИЕ|стиль:плоский|метка:ПЛОХО]
Окончательный класс Invalidtest Extends Testcase {/** * @dataprovider discountdataprovider */public function testcalculate (int $ vipdaysfrom, int $ wed): void {$ sut = new DiscountCalculator (); Self :: AssertSame ($ ожидается, $ sut-> Рассчитайте ($ vipdaysfrom) ); } public function discountDatapRovIder (): array {return [ [0, 0 * 10 + 3], // Обтекающие сведения о домене [1, 1 * 10 + 3], [5, 5 * 10 + 3], [8, 80] ]; } }
[!СОВЕТ|стиль:плоский|метка:ХОРОШО]
Окончательный класс ValueStest Extends Testcase {/** * @dataprovider discountdataprovider */public function testcalculate (int $ vipdaysfrom, int $ wed): void {$ sut = new DiscountCalculator (); Self :: AssertSame ($ ожидается, $ sut-> Рассчитайте ($ vipdaysfrom) ); } public function discountDatapRovIder (): array {return [ [0, 3], [1, 13], [5, 53], [8, 80] ]; } }
Примечание
Не дублируйте производственную логику в тестах. Просто проверьте результаты по жестко запрограммированным значениям.
[!ПРЕДУПРЕЖДЕНИЕ|стиль:плоский|метка:ПЛОХО]
Класс дисконтизатор {Публичная функция рассчитывает, Assert :: Berreathaneq ($ iSvipfromyears, 0); return min (($ isvipfromyears * 10) + 3, 80); } public function calculateadditionaldiscountfromexternalsystem (): int {// Получить данные из внешней системы для вычисления дисконтрона 5; } }
класс заказы {public function __construct (Private Readonly DiscountCalculator $ DiscountCalculator) {} публичная функция getTotalPriceWithDiscount (int $ totalprice, int $ vipfromDays): int {$ internaldiscount = $ this-> discountcalculator-> CountulatingIncondiscount ($ vipfromdays); $ this-> discountcalculator-> calculateadditionaldiscountfromexternalsystem (); $ discountisum = $ internaldiscount + $ externaldiscount; return $ totalprice-(int) ceil ($ totalprice * $ discountum) / 100); } }
Окончательный класс Invalidtest Extends Testcase {/** * @dataprovider orderdataprovider */public function testgettotalpricewithdiscount (int $ totalprice, int $ vipdaysfrom, int $ weding): void {$ discountcalculator = $ this-> createPartialMock (discountCalculator :: class, ['calculateadditionaldiscountfromexternalsystem']); $ discountcalculator-> Метод ('calculateadditionaldiscountfromexternalsystem')-> Willreturn (5); $ sut = new Orderservice ($ discountcalculator); Self :: assertsame ($ vesing, $ sut-> getTotOtRActuctor); , $ vipdaysfrom)); } публичная функция OrderDatapRovIder (): Array {return [ [1000, 0, 920], [500, 1, 410], [644, 5, 270], ]; } }
[!СОВЕТ|стиль:плоский|метка:ХОРОШО]
Интерфейс ExternalDiscountCalculatorInterface {public function calculate (): int; }
Окончательный класс InternaldiscountCalculator {Public Function Рассчитайте (int $ isvipfromyears): int { Assert :: Berreathaneq ($ iSvipfromyears, 0); return min (($ isvipfromyears * 10) + 3, 80); } }
Окончательный класс orderservice {public function __construct (Private Readonly InternallDiscountCalculator $ DISCOUNTCALCUTUTUTUTUTUTUTUTURON, Private ReadOnly ExternaldIscountCalculatorInterface $ ExternalDiscountCalculator) {} Общественная функция getTotalPriceWithDiscount (int $ totalprice, int $ vipfromdays): int {$ internaldiscount = $ this-> discountCalculator-> Рассчитайте ($ vipfromDays); $ externaldiscount = $ this-> externaldiscountCalculator-> Countulate (); $ discountisum = $ enternaldiscount + $ externaldiscount; return $ totalprice-(int) ceil (($ totalprice * $ скидки) / 100); } }
Окончательный класс ValueStest Extends Testcase {/** * @dataprovider orderdataprovider */public function testgettotalpricewithdiscount (int $ totalprice, int $ vipdaysfrom, int $ weding): void {$ externaldiscountcalculator = new Class () реализует ExternaldIscountCalulatorInterface {public Function DecultAlulault (): int {return 5; } }; $ sut = new Orderservice (newardiscountcalculator (), $ externaldiscountcalculator); self :: assertsame ($ wed, $ sut-> gettotalpricewithdiscount ($ totalprice, $ vipdaysfrom)); } публичная функция OrderDatapRovIder (): Array {return [ [1000, 0, 920], [500, 1, 410], [644, 5, 270], ]; } }
Примечание
Необходимость издеваться над конкретным классом для замены части его поведения означает, что этот класс, вероятно, слишком сложен и нарушает принцип единой ответственности.
Окончательный класс orderitem {public function __construct (public Readonly int $ total) {} }
Окончательный класс {/** * @param orderitem [] $ items * @param int $ transportcost */public function __construct (Private Array $ ements, private int $ transportcost) {} public function getTotal (): int {return $ this-> getItemstotal () + $ this-> transportcost; } частная функция getItemStotal (): int {return array_reduce (array_map (fn (orderitem $ item) => $ item-> total, $ this-> item), fn (int $ sum, int $ total) => $ sum + = $ total, 0); } }
[!ПРЕДУПРЕЖДЕНИЕ|стиль:плоский|метка:ПЛОХО]
Окончательный класс Invalidtest Extends Testcase {/** * @test * @dataprovider ordersdataprovider */public function get_total_returns_a_total_cost_of_a_whole_order (order $ order, int $ wedtotal): void {assertsame ($ wedertotal, $ order-> getTotal ()); }/** * @Test * @DatapRovider OrderItemsDatapRovider */public function get_items_total_returns_a_total_cost_of_all_items (заряд $ order, int $ wedtotal): void {assertasame ($ wedertotal, $ this-> invivivatemethodettemettotttottottottottottottottottottottottottottottottottottottottottottottottottottottot } public function ordersdataprovider (): array {return [ [Новый заказ ([New OrderItem (20), New OrderItem (20), New OrderItem (20)], 15), 75], [Новый заказ ([New OrderItem (20), New OrderItem (30), New OrderItem (40)], 0), 90], [Новый заказ ([New OrderItem (99), New OrderItem (99), New OrderItem (99)], 9), 306] ]; } public function orderitemsdataprovider (): array {return [ [Новый заказ ([новый OrderItem (20), New OrderItem (20), New OrderItem (20)], 15), 60], [Новый заказ ([New OrderItem (20), New OrderItem (30), New OrderItem (40)], 0), 90], [Новый заказ ([New OrderItem (99), New OrderItem (99), New OrderItem (99)], 9), 297] ]; } частная функция invokeprivatemethodgetiTemstotal (order & $ order): int {$ urfretect = new ReflectionClass (get_class ($ order)); $ method = $ ufferuction-> getMethod ('getItemstotal'); $ method-> setAccessible (true); return $ method-> indokeargs ($ order, []); } }
[!СОВЕТ|стиль:плоский|метка:ХОРОШО]
Окончательный класс ValueStest Extends Testcase {/** * @test * @dataprovider ordersdataprovider */public function get_total_returns_a_total_cost_of_a_whole_order (order $ order, int $ wedtotal): void {assertsame ($ wedertotal, $ order-> getTotal ()); } public function ordersdataprovider (): array {return [ [Новый заказ ([New OrderItem (20), New OrderItem (20), New OrderItem (20)], 15), 75], [Новый заказ ([New OrderItem (20), New OrderItem (30), New OrderItem (40)], 0), 90], [Новый заказ ([New OrderItem (99), New OrderItem (99), New OrderItem (99)], 9), 306] ]; } }
[!ВНИМАНИЕ] Тесты должны проверять только общедоступный API.
Время является нестабильной зависимостью, поскольку оно недетерминировано. Каждый вызов возвращает другой результат.
[!ПРЕДУПРЕЖДЕНИЕ|стиль:плоский|метка:ПЛОХО]
Последний класс {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; } }
Заключительный клиент {private dateTime $ canectiont; public function __construct () {$ this-> cenectat = chock :: getCurrentDateTime (); } public function isvip (): bool {return $ this-> ceneletat-> diff (clock :: getCurrentDateTime ())-> y> = 1; } }
Окончательный класс 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 (); Часы :: reset (); // Вы должны помнить о сбросе общих государств :: 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 (); Часы :: reset (); // Вы должны помнить о сбросе общих государств :: assertfalse ($ sut-> isvip ()); } }
[!СОВЕТ|стиль:плоский|метка:ХОРОШО]
Интерфейс ClockInterface {public function getCurrentTime (): dateTimeImmutable; }
Окончательный класс. {частная функция __construct () { } public static function create (): self {return new self (); } public function getCurrentTime (): dateTimeImmutable {return new DateTimeImmutable (); } }
Окончательный класс FixClock реализует ClockInterface {частная функция __construct (private readonly dateTimeimatable $ fixdDate) {} public static function create (dateTimeimutable $ fixdDate): self {return new self ($ fixdate); } public function getCurrentTime (): dateTimeImmutable {return $ this-> fixdDate; } }
Заключительный клиент {public function __construct (private readonly dateTimeImmutable $ createAt) {} public function isvip (dateTimeImmutable $ currentDate): bool {return $ this-> censue-> diff ($ currentDate)-> y> = 1; } }
Окончательный класс ValueStest Extends Testcase {/** * @test */public function a_customer_registered_more_than_a_one_year_ago_is_a_vip (): void {$ sut = new Customer (fixdClock :: create (new DateTimeImmutable ('2019-01-01'))-> getCurrentTime ()); asserttrue ($ sut-> isvip (fixclock :: 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 (fixclock :: create (new DateTimeImimutable ('2019-01-01'))-> getCurrentTime ()); Self :: assertFalse ($ sut-> isvip (fixclock :: create (new DateTimeImutable ('2019-05-02'))-> getCurrentTime ( ))); } }
Примечание
Время и случайные числа не должны генерироваться непосредственно в коде домена. Чтобы протестировать поведение, мы должны иметь детерминированные результаты, поэтому нам нужно внедрить эти значения в объект домена, как в примере выше.
100%-ное покрытие не является целью или даже нежелательно, потому что при 100%-м покрытии тесты, вероятно, будут очень хрупкими, а это означает, что рефакторинг будет очень трудным. Мутационное тестирование дает лучшую информацию о качестве тестов. Читать далее
Тестовая разработка: примером / Кент Бек - Классика
Принципы, практики и шаблоны юнит-тестирования / Владимир Хориков — лучшая книга о тестах, которую я когда-либо читал
Камиль Ручиньски
Твиттер: https://twitter.com/Sarvendev
Блог: https://sarvendev.com/
LinkedIn: https://www.linkedin.com/in/kamilruczynski/