Русский
Русский
English
Статистика
Реклама

Моки

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

26.04.2021 16:20:03 | Автор: admin

В этом году должен выйти 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 MBOK (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 existin 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 совсем не просто. Надеюсь, что этой статьёй мне удалось как-то это исправить и сделать вашу миграцию на новую версию немного проще.

См. также

Подробнее..

Я сомневался в юнит-тестах, но

13.10.2020 14:23:08 | Автор: admin
Когда я пишу тест, то часто не уверен, что мой дизайн будет на 100% удачным. И хочу, чтобы он давал гибкость в рефакторинге кода например, чтобы затем изменить класс, не меняя код теста.



Но если у меня стандартная пирамида, внизу которой много юнит-тестов, то не получится ли так, что тесты будут знать не про поведение системы, а про то, какие классы там есть?

Всем привет! Это расшифровка подкаста Между скобок моих интервью с интересными людьми из мира разработки на PHP.


Запись, если вам удобнее слушать. В полной аудиоверсии мы также обсуждаем больше вопросов code coverage.

С Владимиром vyants Янцем мы познакомились на февральском PHP-митапе в Ростове: я рассказывал про свой опыт с асинхронностью, он делал доклад про тесты. С того выступления у меня остались вопросы и в период карантина мы созвонились, чтобы обсудить их.

Сергей Жук, Skyeng: Начнем с главного. Нужны ли юнит-тесты? Не рискую ли я получить слишком большую связность теста и кода? Да и проект наверняка будет меняться, почему бы мне не пойти от вершины пирамиды тестирования, с тех же функциональных тестов?

Владимир Янц, Badoo: Это очень хороший вопрос. Давай начнем с того, нужны ли они в принципе. Может, и правда, только функциональные?

Функциональные тесты классные, но закрывают немного другие потребности. Они позволяют убедиться, что приложение или кусок фичи целиком работает. И у них есть своя проблема цена поддержки, написания и запуска.

Например, у нас на данный момент 20 000 приемочных функциональных тестов. Если запустить их в один поток, они будут бежать несколько дней. Соответственно, нам пришлось придумывать, как получить результаты за минуты: отдельные кластеры, куча машин, большая массивная инфраструктура на поддержку всех этих вещей. А еще ваш тест зависит от окружения: других серверов, того, что лежит в базе данных, кэше. И чтобы его починить, нужно тратить много ресурсов, денег и времени разработчиков.

Чем хорош юнит-тест? Ты можешь протестировать всякие безумные кейсы.


Функциональным же не проверишь, как функция будет реагировать на отрицательное значение. Тут же ты можешь проверить все кейсы, чтобы убедиться, что функция работает идеально: дата-провайдеры создают достаточно много вариантов, а прогнать вариант это доли секунды.

Поэтому, отвечая на главный вопрос. Нужны ли хорошие юнит-тесты? Да. Нужны ли плохие? Нет. Один из главных принципов тестирования: тест можно инвестировать в контракт, а не реализацию. Контракт это договоренность, что ваш метод или ваша функция принимает на входе, и что она должна с этим сделать.

Действительно, проекты на начальных этапах могут менять свою архитектуру, хрупкие тесты будут здесь бесполезны. Но я бы начинал писать юнит-тесты на раннем этапе: не покрывая части, которые хрупки и зависимы от архитектуры. Ведь бывают места, по которым сразу сразу понятно, что ты не будешь их рефакторить. Допустим, тебе нужно посчитать сложные вещи, у тебя есть хелпер, который ты много раз переиспользуешь. Вряд ли ты будешь менять логику этих расчетов. А покрыв ее юнит-тестом, будешь уверен, что там все делается правильно.

Сергей Жук, Skyeng: Ок, смотри, у меня какое-то веб-приложение, и я начинаю в нем писать юнит-тесты. Скажем, я знаю, что у этого класса может быть 5 разных input, я меняю реализацию, и просто делаю юнит-тест провайдеру, чтобы не тестить каждый раз. И еще есть какая-то опенсорсная либа тут без юнит-теста тоже никуда. А для каких еще кейсов их нужно и не стоит писать?

Владимир Янц, Badoo: Я бы написал тесты на то, где есть какая-то бизнес-логика: не в базе данных, а именно в PHP-коде. Какие-то хелперы, которые считают что-то и выводят, какие-то бизнес-правила отличные кандидаты для тестирования. Также видел, что пытались тестировать тонкие контроллеры в приложении.

Основное, что должны делать юнит-тесты, спасать чистую функцию.


Сколько раз ты чистую функцию не вызови с входными значениями, она всегда даст тебе одинаковый результат. Она детерминирована. Если есть публичный метод класса, который обладает критериями чистой функции, и ее легко сломать, это очень хорошая история для теста.

Напротив, если у вас какая-то админка с CRUDом и с большим количеством одинаковых форм, юнит-тесты не очень помогут. Логики мало, тестировать такой код в изоляции от базы данных и окружения проблематично. Даже с точки зрения архитектуры. Здесь лучше взять какие-то тесты более высокого уровня.

Сергей Жук, Skyeng: Когда я начинал, мне казалось, что круто юнит-тестировать максимально детально. Вот у меня есть объект, у него есть какой-то метод и несколько зависимостей (например, модель, которая ходит в базу). Я мокал все. Но со временем понял, что ценность таких тестов нулевая. Они тестируют опечатки в коде и также еще больше связывают тесты с ним. Но я до сих пор общаюсь с теми, кто за такой вот тру-подход. Твоя позиция какова: нужно ли так активно мокать? И в каких кейсах оно того точно стоит?

Владимир Янц, Badoo: В целом, мокать полезно. Но одна из самых вредных конструкций, которых, мне кажется, есть в том же PHPUnit, это ожидания.

Например, плохо, когда люди пытаются сделать так, чтобы метод вызвался с определенным набором параметров и определенное количество раз.

Мы пытаемся проверить тестом не контракт. Хороший тест этим не занимается.


Это делает тесты очень хрупкими. Вы будете тратить время на фикс, а вероятность поймать баг очень мала. В один момент вам надоест. Я видел такое: ребята начинали писать юнит-тесты, делали это неправильно, и в какой-то момент отказывались от инструмента, потому что он не приносил пользы.

Возьмем функцию rant. Она принимает на входе два числа от 0 и выше, а должна вернуть случайное число между этими двумя. Это контракт. В этом случае можно потестить, что она работает на граничных значениях и так далее.

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

Сергей Жук, Skyeng: Вот ты начал про вред ожиданий. Моки про них. Нужны ли тогда вообще моки, если есть фейки и стабы?

Владимир Янц, Badoo: Проблема стабов в том, что они не всегда удобны: ты должен их заранее создать, описать. Это удобно, когда есть объект, который часто переиспользуется ты один раз его написал, а затем во всех тестах, где нужно, используешь.

А для объекта, который ты применяешь в одном-двух тестах, писать стаб очень долго.


Поэтому я активно использую моки как стабы. Можно создать мок, замокать все нужные функции и работать с ними. Я использую их только в таком ключе.

Ну а фейки мой любимый подход. Очень помогает тестировать многие вещи. Например, когда ты хочешь протестировать, что все правильно закэшировал. Для Memcached легко создать фейк-объект, он есть в большинстве стандартных поставок фреймворков. В качестве типизации вы используете интерфейс, а не конечный класс, и ваш фейк может быть безопасно передан в качестве аргумента в любую функцию. А внутри, вместо того, чтобы ходить в сам кэш, вы реализуете функционал кэша внутри объекта, делая массив. Это очень здорово облегчает тестирование, потому что вы можете проверить логику с кэшированием иногда это бывает важно.

Сергей Жук, Skyeng: Смотри, еще одна крайность. Я встречал людей, которые говорят: Ок, а как мне протестировать приватный метод/класс?

Владимир Янц, Badoo: Бывает, что не очень правильно структурирован код, и правильный метод может быть отдельной чистой функцией, которая реализовывает функционал. По-хорошему, это должно быть вынесено в отдельный класс, но по каким-то причинам лежит в protected-элементе. В крайнем случае, можно протестировать такое юнит-тестом. Но лучше сделать отдельной сущностью, которая будет иметь свой тест. Что-то вроде своего явного контракта.

Сергей Жук, Skyeng: Вот ты говоришь, есть контракт, а остальное детали реализации. Если мы говорим о веб-приложении с точки зрения интерфейса: у него есть контракт, по которому оно общается с юзерами. В то же время мы формируем реквест, отправляем, инспектируем респонс, сайд-эффекты, БД, еще что-то. Если делать юнит-тесты, то можно вынести логику общения с БД в отдельный слой, завести интерфейс для репозитория, сделать отдельную in memory реализацию репозитория для тестов. Но стоит ли оно того?

Владимир Янц, Badoo: У тебя есть приложение, которое должно правильно работать целиком. И юнит-тесты должны прикрыть какие-то важные вещи, у которых есть большая вариативность ответов, и которые легко выделены в чистую функцию. Вот там они должны быть в первую очередь.

Юнит-тесты могут дать самую быструю обратную связь: запустил, через минуту получил результаты. Если нашелся баг, навряд ли все приложение работает правильно, если что-то не так с его частью.

Умные люди не зря придумали пирамиду тестирования. Юнит-тесты быстрые, их можно написать много. В этом их смысл задешево протестировать очень много участков твоего приложения, чтобы убедиться, что они работают правильно. Чем больше кусочков покрыты юнит-тестами, тем лучше, но это не значит, что надо стремиться к 100-процентному покрытию. Есть много вещей, которые тесты не поймают, потому что не созданы для этого. Но если на их уровне что-то вылезло, нет смысла дальше идти и запускать тяжелые функциональные тесты.

На этапе функциональных или интеграционных тестов тебе не нужно тестировать вариативность ответа. Ты верхний уровень проверил, все компоненты в связке работают так, как ты ожидаешь. Теперь не нужно создавать огромное количество провайдеров и интеграционных тестов на каждый кейс. В этом смысл пирамиды.

Сергей Жук, Skyeng: Давай напоследок поговорим о мутационных тестах. Нужны ли они?

Владимир Янц, Badoo: Нужно внедрять мутационные тесты, как только вы задумались о юнит-тестах. Они поднимут из-под ковра все те проблемы, которые так часто обсуждают бесполезное тестирование, coverage ради coverage.

Там не нужно ничего писать дополнительно. Это просто библиотека: она берет coverage, который у вас где-то есть, идет в исходный код и начнет менять операторы кода на противоположные. После каждой такой мутации она прогоняет тесты, которые, согласно code coverage, эту строчку покрывают. Если ваши тесты не упали, они бесполезны. И, наоборот, можно находить строчки, для которых есть потенциальные мутации, но нет coverage.

Внедрить это на ранних этапах ничего не стоит. А пользы много.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru