[Разработка веб-сайтов, Тестирование IT-систем, PHP, Тестирование веб-сервисов] Юнит-тестирование на PHP в примерах (перевод)

Автор Сообщение
news_bot ®

Стаж: 6 лет 3 месяца
Сообщений: 27286

Создавать темы news_bot ® написал(а)
30-Мар-2021 20:31

Сегодня юнит-тесты невероятно полезны. Думаю, они есть в большинстве из недавно созданных проектов. Юнит-тесты являются важнейшими в enterprise-приложениях с обилием бизнес-логики, потому что они быстрые и могут сразу сказать нам, корректна ли наша реализация. Однако я часто сталкиваюсь с проблемами, которые связаны с хорошими тестами, хотя те и крайне полезны. Я дам вам несколько советов с примерами, как писать хорошие юнит-тесты.
Содержание

Тестовые дубли
Это фальшивые зависимости, используемые в тестах.
Заглушки (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()));
    }
}

В коде, относящемся к предметной области, нельзя напрямую генерировать время и случайные числа. Для проверки поведения нужны детерминистские результаты, поэтому нужно внедрять эти значения в объект, относящийся к предметной области, как в примере выше.
Не гонитесь за полным покрытием
Полное покрытие не является целью, или даже не желательно, потому что в противном случае тесты наверняка будут очень хрупкими, а рефакторинг — очень сложным. Мутационное тестирование даёт более полезную обратную связь о качестве тестов. Подробнее.
Рекомендуемые книги

===========
Источник:
habr.com
===========

===========
Автор оригинала: Kamil Ruczyński
===========
Похожие новости: Теги для поиска: #_razrabotka_vebsajtov (Разработка веб-сайтов), #_testirovanie_itsistem (Тестирование IT-систем), #_php, #_testirovanie_vebservisov (Тестирование веб-сервисов), #_php, #_unit_testing, #_tests, #_nikto_ne_chitaet_tegi (никто не читает теги), #_blog_kompanii_mail.ru_group (
Блог компании Mail.ru Group
)
, #_razrabotka_vebsajtov (
Разработка веб-сайтов
)
, #_testirovanie_itsistem (
Тестирование IT-систем
)
, #_php, #_testirovanie_vebservisov (
Тестирование веб-сервисов
)
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 21-Май 00:20
Часовой пояс: UTC + 5