[Перевод] Не мокайте то, чем вы не владеете
Прим. переводчика: само правило достаточно старое, да и пример, приведенный в статье — на мой взгляд самый простой. Поэтому статья подойдет скорее для новичков, люди с хорошим опытом написания автотестов, возможно, не найдут для себя ничего нового. UPD. Я все же не думаю, что автор предлагает оборачивать своим слоем все апи фреймворка, с которым вы работаете (ну это было бы крайне странно), тут скорее речь о слабо структурированных/типизированных классах общего назначения, вроде HttpRequest из примера в статье.
Веб-приложения зачастую созданы для обработки HTTP-запросов. Обычно объекты используются для инкапсуляции данных запроса. В зависимости от фреймворка у нас может быть такой интерфейс, как
interface HttpRequest
{
public function get(string $name): string;
// ...
}
или даже конкретный класс, такой как
class HttpRequest
{
public function get(string $name): string
{
// ...
}
// ...
}
которые мы можем (и должны) использовать для доступа к данным запроса.
В symfony, например, есть Symfony\Component\HttpFoundation\Request: get (). В качестве примера мы не будем беспокоиться о том, какой тип HTTP-запроса мы обрабатываем (GET, POST или другой). Вместо этого давайте сосредоточимся на неявных API, таких как HttpRequest: get (), и проблемах, которые они создают.
Когда нам нужно получить данные запроса, например, в контроллере, нам нужно использовать один и тот же метод get () для любого параметра, который мы хотим получить. Не существует специального метода с явным именем для отдельной части данных запроса. Вместо этого имя параметра передается только как строковый аргумент универсальному методу get ():
class SomeController
{
public function execute(HttpRequest $request): HttpResponse
{
$id = $request->get('id');
$amount = $request->get('amount');
$price = $request->get('price');
// ...
}
}
Мы не будем спорить о том, должен ли контроллер иметь один action-метод или несколько (подсказка: у него должен быть только один (eng видео)). Дело в том, что контроллеру необходимо извлекать и обрабатывать данные из HTTP-запроса.
Когда мы заменяем объект HttpRequest на тестовую заглушку (stub) или mock-объект для тестирования SomeController изолированно от сети и от фреймворка, мы сталкиваемся с проблемой множественных вызовов одного и того же метода get () с разными аргументами, которые представляют собой просто строки: 'id', 'amount' и 'price'.
Мы должны обеспечить осмысленные возвращаемые значения для каждого вызова, иначе данные не пройдут проверку, и мы не пройдем по позитивному пути нашего action-метода контроллера.
Для тестирования SomeController изолированно от реального объекта HttpRequest мы можем использовать тестовую заглушку (stub) в unit тесте с PHPUnit примерно так:
$request = $this->createStub(HttpRequest::class);
$request->method('get')
->willReturnOnConsecutiveCalls(
'1',
'2',
'3',
);
$controller = new SomeController;
$controller->execute($request);
Если мы также хотим проверить связь между SomeController и объектом HttpRequest, нам понадобится mock-объект, для которого мы должны настроить ожидаемые значения в нашем тесте:
$request = $this->createMock(HttpRequest::class);
$request->expects($this->exactly(3))
->method('get')
->withConsecutive(
['id'],
['amount'],
['price']
)
->willReturnOnConsecutiveCalls(
'1',
'2',
'3',
);
$controller = new SomeController;
$controller->execute($request);
Код, показанный выше, немного трудно читать, это запах кода (прим пер. на русском почитать можно тут).
Мы заявляем, что HttpRequest: get () необходимо вызывать три раза: сначала с аргументом «id», затем с «amount» и, наконец, с «price».
Если мы изменим реализацию SomeController: execute (), например изменим порядок вызовов HttpRequest: get (), наш тест завершится ошибкой. Это говорит нам о том, что мы слишком сильно связали наш тестовый код с рабочим кодом. Это еще один запах.
Настоящая проблема заключается в том, что мы работаем с HTTP-запросом, используя неявный API, где мы передаем строковый аргумент, определяющий имя параметра HTTP, в общий метод get (). И, что еще хуже, мы имитируем тип, которым не владеем: HttpRequest предоставляется фреймворком, а не находится под нашим контролем.
Мудрость «не мокайте то, что вам не принадлежит» берет свое начало в сообществе «Лондонской школы разработки, основанной на тестировании». Как написали Стив Фриман и Нат Прайс в 2009 году в статье «Развитие объектно-ориентированного программного обеспечения с помощью тестов»:
«Мы обнаружили, что тесты, мокающие внешние библиотеки, часто должны быть сложными, чтобы привести код в правильное состояние для функциональности, которая нам нужна. Беспорядок в таких тестах говорит нам, что дизайн неправильный, но вместо того, чтобы исправить проблему улучшением кода, мы должны вносить дополнительную сложность как в код, так и в тесты».
Но если мы не должны мокать то, что нам не принадлежит, то как нам изолировать наш код от стороннего кода? Стив Фриман и Нат Прайс продолжили:
«Мы […] проектируем интерфейсы для сервисов, которые нужны для наших объектов, — интерфейсов, которые будут определяться в терминах домена наших объектов, а не внешней библиотеки. Мы пишем слой адаптера […], который использует третье-сторонний API для реализации этих интерфейсов […] »
Давайте применим это к нашему коду:
interface SomeRequestInterface
{
public function getId(): string;
public function getAmount(): string;
public function getPrice(): string;
}
Вместо того, чтобы просто возвращать строку, теперь мы можем использовать конкретные типы или даже value-объекты. Однако в этом примере мы будем придерживаться строк.
Создать тестового двойника для SomeRequestInterface очень просто:
$request = $this->createStub(SomeRequestInterface::class);
$request->method('getId')
->willReturn(1);
$request->method('getAmount')
->willReturn(2);
$request->method('getPrice')
->willReturn(3);
С точки зрения фреймворка, стандартный объект HTTP-запроса является правильной абстракцией, потому что это работа фреймворка — представлять входящий HTTP-запрос в виде объекта. Однако это не должно мешать нам поступать правильно. Мы можем сопоставить общий объект HTTP-запроса фреймворка с нашим конкретным объектом запроса. Нам даже не нужен отдельный маппер. Мы можем просто обернуть общий запрос:
class SomeRequest implements SomeRequestInterface
{
private HttpRequest $request;
public function __construct(HttpRequest $request)
{
$this->request = $request;
}
public function getId(): string
{
return $this->request->get('id');
}
public function getAmount(): string
{
return $this->request->get('amount');
}
public function getPrice(): string
{
return $this->request->get('price');
}
}
И вот как мы заставляем этот код работать вместе:
class SomeController
{
public function execute(HttpRequest $request)
{
return $this->executable->execute(
new SomeRequest($request)
)
}
}
Даже если SomeController является подклассом базового класса контроллера, предоставляемого фреймворком, ваш фактический код остаётся независимым от HTTP абстракции фреймворка.
Вы, конечно, должны будете делать свою обертку request’a, специфичную для каждого контроллера. Вашему коду нужны определенные заголовки? Создайте метод, чтобы просто получить их. Вашему коду нужен загруженный файл? Создайте метод для получения именно этого.
Полный HTTP-запрос может содержать заголовки, значения, возможно, загруженные файлы, тело POST и т. д. Настройка тестовой заглушки или mock’а для всего этого, пока вы не владеете интерфейсом, мешает вам выполнить работу. Определение собственного интерфейса значительно упрощает задачу.