В unit-тестах нужно тестировать конкретные объекты и их конкретное поведение.
На этом можно было бы закончить, ведь этот постулат лежит в основе тестирования, однако, как показывает практика, далеко не у всех есть понимание, что же тестируют тесты.
На самом деле мне было очень сложно придумать пример с наследованием, чтобы он был хоть как-то не похож на автомобили с двигателями и кошечек с собачками. Все-таки обычно наследованию предпочитают декорацию или композицию.
Давайте посмотрим на следующий код:
abstract class AbstractOrder
{
/**
* @psalm-readonly-allow-private-mutation
*/
public OrderStatus $status;
public function __construct(
private readonly OrderId $id,
) {
$this->status = OrderStatus::Created;
}
public final function cancel(): void
{
if (!$this->status->isCreated()) {
throw new DomainException();
}
$this->status = OrderStatus::Canceled;
}
}
Предположим, что у нас есть доменное ядро (domain core) с абстрактным классом заказа, который содержит в себе юзкейс отмены. Так как у нашего теоретического бизнеса заказ всегда отменяется одинаково вне зависимости от того, какой именно это заказ, этот юзкейс исключает любые попытки переопределения в наследниках.
С точки зрения DRY мы все сделали верно: вынесли это знание о бизнес-логике отмены заказа в одно единственное место — в абстрактный заказ, внутри сделали все необходимые бизнес-проверки, чтобы случайно не нарушить инвариант.
Класс абстрактный, а значит предполагается, что у него есть наследники:
final class OnlineOrder extends AbstractOrder {}
final class RetailOrder extends AbstractOrder {}
Первый создается в случае покупки товаров через сайт, второй через розничный магазин. Стоит понимать, что эти два заказа отличаются своими свойствами, бизнес-процессами и имеют разный набор юзкейсов, но для простоты демонстрации примера я их вынес за рамки.
Оба этих, вполне конкретных, заказа наследуют из суперкласса тот самый юзкейс отмены. Вне зависимости от того, пишутся ли unit-тесты по факту уже созданного кода, или же по TDD сначала определяется контракт без реализации, unit-тест на доменный уровень обязан быть, так как домен — это самое сердце приложения. Если домен не покрывает потребности бизнеса, значит и все, что вокруг этого домена, тоже не покрывает потребности бизнеса.
И вот тут разработчик рассуждает, что если юзкейс отмены для всех наследников идентичен, значит можно взять любого наследника и проверить один раз, поэтому было решено написать два теста на два сценария:
final class OrderTest extends UnitTest
{
#[TestDox('Отмена')]
public function testCancel(): void
{
$order = OnlineOrderFactory::createNew(
id: OrderId::fromInt(10001),
);
$order->cancel();
$this->assertSame(OrderStatus::Cancel, $order->status);
}
#[TestDox('Недопустимая отмена')]
public function testInvalidCancellation(): void
{
$order = OnlineOrderFactory::createCancelled(
id: OrderId::fromInt(10001),
);
$this->expectsException(DomainException::class);
$order->cancel();
}
}
По моему демонстрационному примеру эти два теста покрывают оба возможных сценария использования — и позитивный, и негативный. Довольный разработчик с чувством выполненного долга уходит на другие задачи.
Однако, разработчик не учитывает, что такие unit-тесты пишутся не на какой-то там код, не на какой-то метод, даже не на какой-то класс, а на вполне себе конкретный объект и его поведение. В таких тестах всегда тестируется некий конкретный сценарий использования черного ящика в виде определенного объекта.
Каждый тест не тестирует абстракцию, он всегда тестирует конкретное поведение в конкретном контексте. Моки (макет, mock), заглушки (stub) и шпионы (spy) не про само поведение, даже не про подмену поведения, а про заглушку для определенной внешней зависимости. А при классическом подходе к тестированию заглушками заменяются только внепроцессные зависимости, но сейчас речь не об этом.
Если бы это знание было у разработчика, он бы понял, что его тест написан хорошо, но написан исключительно для создаваемого через сайт заказа. Этот тест с точки зрения тестируемых сценариев вообще никак не покрывает заказ, сделанный в рознице.
Да, сейчас архитектурно у каждого заказа есть абстрактный суперкласс с непереопределяемым методом отмены. Но нет вообще никаких гарантий, что запрет на переопределение сохранится на всем протяжении эволюции системы. Нет никаких гарантий, что суперкласс останется востребованным до конца. Того набора тестов, который написал разработчик, в будущем может не хватить. И те тесты, которые вводились на этапе расширения домена ранее, уже не будут покрывать все сценарии использования. Тесты, которые должны были быть подстраховкой для будущих рефакторингов, просто не написаны. И повезет, если разработчик догадается это проверить, откажется от своей лени и добавит недостающие тесты.
Так как любой тест покрывает определенный сценарий использования, это также позволяет иметь косвенную документацию к процессам. Это же, к слову, заставляет относиться к тестам не как к еще одному месту, где мы пишем код.
Если бы разработчик изначально понимал, что он тестирует объекты и их поведение, а не код в виде иерархии классов с методами, он бы сразу написал к тестам интернет-заказа и тесты на розничный заказ.