Работа с частичными моками в PHPUnit 10
В этом году должен выйти PHPUnit 10 (релиз планировался на 2 апреля 2021 года, но был отложен). Если посмотреть на список изменений, то бросается в глаза большое количество удалений устаревшего кода. Одним из таких изменений является удаление метода MockBuilder::setMethods()
, который активно использовался при работе с частичными моками. Этот метод не рекомендуется использовать с версии 8.0, но тем не менее он описан в документации без каких-либо альтернатив и упоминания о его нежелательности. Если почитать исходники PHPUnit, issues и пул-реквесты на GitHub, то станет понятно, почему так и какие есть альтернативы.
В этой статье я освещу этот нюанс для тех, кто не обращал на него внимания раньше: расскажу про частичные моки, проблемы, возникающие при работе с setMethods, пути их решения, а также затрону вопрос миграции тестов на PHPUnit 10.
Что такое частичные моки?
У программного кода, который мы пишем, чаще всего есть какие-то зависимости.
При написании юнит-тестов мы изолируем эти зависимости, подставляя вместо реальных объектов какие-то заглушки с заранее известным состоянием. Это позволяет проверять работу только одного кусочка кода в один момент времени. Эти заглушки чаще всего реализуются с помощью моков.
Про название «мок»У этого термина в русском языке есть несколько обозначений: мок, mock-объект, подставной объект, имитация. Я буду пользоваться калькой английского слова mock (мок).
Суть мока заключается в том, что вместо объекта-зависимости вы используете специальный объект, в котором заменены все методы оригинального класса. Для такого объекта можно сконфигурировать результаты, возвращаемые методами, а также добавить проверки на наличие вызовов методов.
PHPUnit содержит встроенный механизм для работы с моками. Одной из его возможностей является создание так называемых частичных моков (partial mocks), когда исходное поведение класса заменяется не полностью, а только для отдельных методов. Такие моки очень удобно использовать, когда вам нужно написать тест, который будет проверять работу конкретного метода и в процессе своей работы вызывать другие методы (которые вы проверять не хотите).
Приведу небольшой пример того, где могут быть полезны такие моки.
Вот код базового класса, реализующий паттерн «команда»:
abstract class AbstractCommand
{
/**
* @throws \PhpUnitMockDemo\CommandException
* @return void
*/
abstract protected function execute(): void;
public function run(): bool
{
$success = true;
try {
$this->execute();
} catch (\Exception $e) {
$success = false;
$this->logException($e);
}
return $success;
}
protected function logException(\Exception $e)
{
// Logging
}
}
Реальное поведение команды задаётся в методе execute классов-наследников, а метод run()
добавляет общее для всех команд поведение (в данном случае делает код exception safe и логирует ошибки).
Если мы хотим написать тест для метода run
, мы можем воспользоваться частичными моками, функционал которых предоставляет класс PHPUnit\Framework\MockObject\MockBuilder
, доступ к которому предоставляется через вспомогательные методы класса TestCase (в примере это getMockBuilder
и createPartialMock
):
use PHPUnit\Framework\TestCase;
class AbstractCommandTest extends TestCase
{
public function testRunOnSuccess()
{
// Arrange
$command = $this->getMockBuilder(AbstractCommand::class)
->setMethods(['execute', 'logException'])
->getMock();
$command->expects($this->once())->method('execute');
$command->expects($this->never())->method('logException');
// Act
$result = $command->run();
// Assert
$this->assertTrue($result, "True result is expected in the success case");
}
public function testRunOnFailure()
{
// Arrange
$runException = new CommandException();
// It's an analogue of $this->getMockBuilder(...)->setMethods([...])->getMock()
$command = $this->createPartialMock(AbstractCommand::class, ['execute', 'logException']);
$command->expects($this->once())
->method('execute')
->will($this->throwException($runException));
$command->expects($this->once())
->method('logException')
->with($runException);
// Act
$result = $command->run();
// Assert
$this->assertFalse($result, "False result is expected in the failure case");
}
}
Исходный код, результаты прогона тестов
В методе testRunOnSuccess
с помощью MockBuilder::setMethods()
мы задаём список методов оригинального класса, которые мы заменяем (вызовы которых хотим проверить или результаты которых нужно зафиксировать). Все остальные методы сохраняют свою реализацию из оригинального класса AbstractCommand
(и их логику можно тестировать). В testRunOnFailure
через метод createPartialMock
мы делаем то же самое, но явно.
В этом примере всё достаточно просто: мы задаём мокаемые методы и в тесте проверяем их вызов или невызов через expects
. В реальном коде бывают и другие случаи, которые требуют переопределения методов:
подготовка или освобождение каких-то ресурсов (например, соединения с базой данных);
внешние обращения, которые замедляют тесты и загрязняют окружение (отправка запросов к базе данных, чтение из кеша или запись в него и т. д.);
отправка какой-то отладочной информации или статистики.
Часто для таких случаев проверок вызова просто нет (поскольку они не всегда нужны и делают тесты хрупкими при изменениях кода).
Кроме переопределения существующих методов, MockBulder::setMethods()
позволяет добавлять в класс мока новые методы, которых нет в оригинальном классе. Это может быть полезно при использовании в тестируемом коде «магического» метода __call
.
Возьмём в качестве примера класс \Predis\Client. Он использует метод __call
для обработки передаваемых клиенту команд. При этом во внешнем коде это выглядит как вызов конкретного метода и кажется естественным переопределить в создаваемом моке этот вызываемый в коде метод, а не переопределять __call
, вдаваясь в детали реализации.
Пример:
public function testRedisHandle()
{
if (!class_exists('Redis')) {
$this->markTestSkipped('The redis ext is required to run this test');
}
$redis = $this->createPartialMock('Redis', ['rPush']);
// Redis uses rPush
$redis->expects($this->once())
->method('rPush')
->with('key', 'test');
$record = $this->getRecord(Logger::WARNING, 'test', ['data' => new \stdClass, 'foo' => 34]);
$handler = new RedisHandler($redis, 'key');
$handler->setFormatter(new LineFormatter("%message%"));
$handler->handle($record);
}
Источник: тест RedisHandlerTest из monolog 2.2.0
Какие проблемы возникают при использовании setMethods?
Двойственное поведение может приводить к проблемам.
Если в моках есть переопределённые методы без expectations, то при их переименовании или удалении тест продолжает проходить (хотя метода уже нет и в его добавлении к моку нет смысла).
Небольшая демонстрация. Давайте добавим в код нашего класса команды измерение времени, которое потребовалось для её выполнения:
--- a/src/AbstractCommand.php
+++ b/src/AbstractCommand.php
@@ -13,6 +13,7 @@ abstract class AbstractCommand
public function run(): bool
{
+ $this->timerStart();
$success = true;
try {
$this->execute();
@@ -21,6 +22,7 @@ abstract class AbstractCommand
$this->logException($e);
}
+ $this->timerStop();
return $success;
}
@@ -28,4 +30,14 @@ abstract class AbstractCommand
{
// Logging
}
+
+ protected function timerStart()
+ {
+ // Timer implementation
+ }
+
+ protected function timerStop()
+ {
+ // Timer implementation
+ }
}
Исходный код
В код тестов добавим в мок новые методы, но не будем проверять вызовы через expectations:
--- a/tests/AbstractCommandTest.php
+++ b/tests/AbstractCommandTest.php
@@ -11,7 +11,7 @@ class AbstractCommandTest extends TestCase
{
// Arrange
$command = $this->getMockBuilder(AbstractCommand::class)
- ->setMethods(['execute', 'logException'])
+ ->setMethods(['execute', 'logException', 'timerStart', 'timerStopt']) // timerStopt is a typo
->getMock();
$command->expects($this->once())->method('execute');
$command->expects($this->never())->method('logException');
Исходный код, результаты прогона тестов
Если прогнать этот тест в PHPUnit версий 8.5 или 9.5, то он успешно пройдёт без каких-то предупреждений:
PHPUnit 9.5.0 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 00:00.233, Memory: 6.00 MB
OK (1 test, 2 assertions)
Конечно, это совсем простой пример, в который несложно добавить expectations для новых методов. В реальном коде всё может быть сложнее, и мне не раз приходилось натыкаться на несуществующие методы в моках.
Ещё сложнее отслеживать подобные проблемы при использовании
MockBuilder::setMethodsExcept
, который переопределяет все методы класса, кроме заданных.
Как эта проблема решена в PHPUnit 10?
Начало решению этой проблемы «молчаливого» переопределения несуществующих методов было положено в 2019 году в пул-реквесте #3687, который вошёл в релиз PHPUnit 8.
В MockBuilder появились два новых метода — onlyMethods()
и addMethods()
— которые делят ответственность setMethods()
на части. onlyMethods()
может только заменять методы, существующие в оригинальном классе, а addMethods()
— только добавлять новые (которых в оригинальном классе нет).
В том же PHPUnit 8 setMethods
был помечен устаревшим и появилось предупреждение при передаче несуществующих методов в TestCase::createPartialMock()
.
Если взять предыдущий пример с некорректным названием метода и использовать createPartialMock
вместо вызовов getMockBuilder(...)->setMethods(...)
, то тест пройдёт, но появится предупреждение о будущем изменении этого поведения:
createPartialMock() called with method(s) timerStopt that do not exist
in PhpUnitMockDemo\AbstractCommand. This will not be allowed
in future versions of PHPUnit.
К сожалению, это изменение никак не было отражено в документации — там по по-прежнему была описана только работа setMethods()
, а всё остальное было скрыто в недрах кода и GitHub.
В PHPUnit 10 проблема setMethods()
решена радикально: setMethods
и setMethodsExcept
окончательно удалены. Это означает, что если вы используете их в своих тестах и хотите перейти на новую версию PHPUnit, то вам нужно убрать все использования этих методов и заменить их на onlyMethods
и addMethods
.
Как мигрировать частичные моки из старых тестов на PHPUnit 10?
В этой части я дам несколько советов о том, как это можно сделать.
Сразу скажу, что для использования этих советов не обязательно ждать выхода PHPUnit 10 и переходить на него. Всё это можно делать в процессе работы с тестами, которые запускаются в PHPUnit 8 или 9.
Везде, где возможно, замените вызовы MockBuilder: setMethods () на onlyMethods ()
Это кажется совсем очевидным, но во многих случаях этого будет достаточно. Я рекомендую заменить все вхождения и разбираться с падениями. Частично они могут быть вызваны проблемами, описанными выше (и тогда нужно либо удалить метод из мока, либо использовать его актуальное название), а частично — использованием «магии» в мокаемом классе.
Используйте MockBuilder: addMethods () для классов с «магией»
Если метод, который вы хотите переопределить в моке, работает через «магический» метод __call
, то используйте MockBuilder::addMethods()
.
Если раньше для классов с «магией» вы использовали TestCase::createPartialMock()
и это работало, то в PHPUnit 10 это сломается. Теперь createPartialMock умеет заменять только существующие методы мокаемого класса, и нужно заменить использование createPartialMock
на getMockBuilder()->addMethods()
.
Если вы создаёте моки для внешних библиотек, то изучите их изменения или максимально конкретно задавайте версию
В тестах, использующих моки классов из внешних библиотек, всё может быть сложнее из-за того, что там может меняться версия зависимости. Особенно актуально это, если в CI вы используете lowest версии зависимостей вместе со стабильными.
Приведу пример из библиотеки PhpAmqpLib.
Допустим, вам нужен мок для класса \PhpAmqpLib\Channel\AMQPChannel
.
В версии 2.4 там был метод __destruct
, который отправлял внешний запрос (и поэтому его стоит замокать).
В версии 2.5 этот метод был удалён и мокать его уже не нужно.
Если в composer.json зависимость прописана подобным образом: "php-amqplib/php-amqplib": "~2.4"
, то обе версии буду подходить (но моки для них нужны разные) и нужно будет смотреть, какая из них используется.
Решать это можно несколькими способами:
максимально фиксировать версию библиотеки (например, в приведённом примере можно использовать
~2.4.0
— и тогда разница будет только в patch-версиях);завязываться на версию библиотеки или наличие метода (но это плохой способ, так как для этого нужно внимательно изучать изменения кода всех используемых библиотек, да и очень похоже это на какой-то хак);
использовать для классов из внешних библиотек полные моки, а не частичные (но это не всегда возможно).
Заключение
Частичные моки — очень полезный инструмент для написания модульных тестов. К сожалению, разобраться с их изменениями в документации PHPUnit совсем не просто. Надеюсь, что этой статьёй мне удалось как-то это исправить и сделать вашу миграцию на новую версию немного проще.