Работа с частичными моками в PHPUnit 10

PHPUnit 10 должен выйти в этом году (релиз был запланирован на 2 апреля 2021 года, но был отложен). Если вы посмотрите на список изменений, то увидите, что было удалено много устаревшего кода. Одним из таких изменений является удаление метода MockBuilder::setMethods(), который активно использовался при работе с частичными моками. Этот метод устарел, начиная с PHPUnit 8.0, но он по-прежнему задокументирован без каких-либо альтернатив или ссылок на его нежелательность. Если вы прочитаете исходный код PHPUnit, проблемы и запросы на включение на GitHub, вы поймете, почему это так и какие есть альтернативы.

В этой статье я освещаю этот нюанс для тех, кто, возможно, не задумывался об этом раньше: рассказываю о частичных моках, проблемах с setMethods и способах их решения, а также о том, как перенести тесты на PHPUnit 10.

Что такое частичные макеты?

Программный код, который мы пишем чаще всего, имеет определенные зависимости.

Когда мы пишем модульные тесты, мы изолируем эти зависимости, заменяя объекты-заглушки предопределенными состояниями, а не реальными объектами. Это позволяет нам тестировать только один бит кода за раз. Эти заглушки чаще всего реализуются с помощью макетов.

Суть мока в том, что вместо объекта-зависимости вы используете специальный объект, в котором заменены все методы исходного класса. Для такого объекта можно настроить возвращаемые методами результаты и добавить проверки вызовов методов.

PHPUnit имеет встроенный механизм для работы с моками. Одной из его особенностей является создание так называемых частичных моков, где исходное поведение класса заменяется не полностью, а только для отдельных методов. Такие макеты чрезвычайно полезны, когда вы хотите написать тест для одного конкретного метода и в процессе вызвать другие методы (которые вы не хотите тестировать).

Давайте посмотрим на пример, где такие макеты могут быть полезны.

Вот код базового класса, реализующего шаблон «команда»:

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() добавляет поведение, общее для всех команд (в данном случае делает исключение кода безопасным и регистрирует ошибки). .

Если мы хотим написать тест для метода 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 из монолога 2.2.0

Какие проблемы возникают при использовании setMethods?

Две обязанности setMethods() могут привести к проблемам.

Если в моке есть переопределенные методы без значений, то при их переименовании или удалении тест продолжается (хотя метода уже нет и добавлять его в мок нет смысла).

Вот краткая демонстрация. К коду нашего командного класса добавим, сколько времени потребовалось для его выполнения:

--- 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
+    }
 }

"Исходный код"

Давайте теперь добавим в тестовый код новые методы в моке, но не будем проверять вызовы через ожидания:

--- 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)

Конечно, это очень простой пример, в который несложно добавить ожидания для новых методов. В реальном коде все может быть сложнее, и я не раз натыкался на несуществующие методы в моках.

Еще сложнее отслеживать такие проблемы при использовании MockBuilder::setMethodsExcept, который переопределяет все методы класса, кроме указанных.

Как эта проблема решается в PHPUnit 10?

Движение к решению этой проблемы неявного переопределения несуществующих методов началось в 2019 году в pull request #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.

Вот пример из библиотеки PhpAmqpLib.

Предположим, вам нужен макет для класса \PhpAmqpLib\Channel\AMQPChannel.

В версии 2.4 был метод __destruct, который отправлял внешний запрос (поэтому его нужно было имитировать).

В версии 2.5 этот метод был удален, и вам больше не нужно издеваться над ним.

Если зависимость в composer.json написана так: “php-amqplib/php-amqplib”: “~2.4”, то подойдут обе версии (но для них нужны разные макеты), и вы нужно будет определить, какой из двух используется.

Это можно сделать несколькими способами:

  • Максимально исправив версию библиотеки (например, в приведенном выше примере можно было использовать ~2.4.0 — и тогда разница была бы только в версиях патчей)
  • Проверяя версию библиотеки или доступность метода (но это плохой способ, потому что требует внимательного изучения изменений кода всех используемых библиотек, и очень похоже на какой-то хак)
  • Используя полные моки для классов из внешних библиотек, а не частичные моки (но это не всегда возможно).

Вывод

Частичные моки — очень полезный инструмент для написания модульных тестов. К сожалению, выяснить их изменения в документации PHPUnit совсем непросто. Я надеюсь, что эта статья поможет вам и немного облегчит ваш будущий переход на новую версию.

Также проверьте: