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

Phpunit

Практики при работе с PHPUnit

26.04.2021 06:12:41 | Автор: admin

Не секрет, что чем больше проект, тем с большим количеством проблем он сталкивается даже в самых элементарных аспектах. В продукте Plesk, над которым я работаю, PHP является одним из основных языков, и количество кода на нем превышает 1 миллион строк. Соответственно, мы активно используем PHPUnit для тестирования. Кроме большого объема кода, поддержка двух платформ (Linux и Windows) доставляет нюансы, как и тот факт, что поддерживается несколько бранчей с приличной разницей возраста (крупные релизы), а активно вносят правки несколько десятков инженеров. В статье я хочу поделиться некоторыми практиками, которые мы используем при работе с PHPUnit.

Унификация

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

В мире PHP принято, чтобы зависимости устанавливались с помощью composer install, а команда composer test прогоняла набор тестов. В контексте PHPUnit это означает следующее. Зависимость на PHPUnit должна присутствовать в разделе "require-dev" в composer.json:

  "require-dev": {    ...    "phpunit/phpunit": "^9.5",

В разделе scripts, соответственно, должно присутствовать описание для команды test:

  "scripts": {    ...    "test": "phpunit",

Различные линтеры и статические анализаторы, если используются, тоже должны оказаться частью этой команды:

  "scripts": {    ...    "test": [      "@phpcs",      "@phpstan",      "@psalm",      "@phpunit"    ],

Далее конфигурацию для PHPUnit нужно определить в phpunit.xml.dist, а файл phpunit.xml занести в .gitignore. Тем самым мы унифицируем опции запуска PHPUnit, оставляя возможность локального оверрайда для каких-то экспериментов. Репозиторий после клонирования, прогона composer install и запуска composer test не должен требовать каких-то дополнительных манипуляций. Поэтому в phpunit.xml.dist определяем, где искать тесты, что исключать, какие опции использовать и т.п.

<?xml version="1.0"?><phpunit  xmlns:xsi="http://personeltest.ru/away/www.w3.org/2001/XMLSchema-instance"  bootstrap="common/php/tests/bootstrap.php"  executionOrder="random"  ...>  <php>    <ini name="memory_limit" value="-1"/>    <ini name="display_errors" value="true"/>    ...  </php>  <testsuites>    <testsuite name="Plesk Common TestSuite">      <directory>common/php/tests</directory>      <exclude>common/php/tests/stubs</exclude>      ...    </testsuite>  </testsuites>  <coverage includeUncoveredFiles="true">    ...  </coverage></phpunit> 

Осталось определиться с версией PHP, необходимыми расширениями и занести эту информацию в composer.json:

  "require": {    "php": "^7.4",    "ext-fileinfo": "*",    "ext-intl": "*",    "ext-json": "*",    "ext-mbstring": "*",    ...  }

Базовая подготовка закончена. С одной стороны, все просто. С другой стороны, регулярно попадаются проекты, где какой-то из моментов выше оказался проигнорирован.

Docker

А куда же без него? Раз уж мы заговорили об унификации, то неоценимую помощь оказывает и использование Dockerа. Речь не только о его необходимости для запуска тестов в рамках CI-процесса. Для тех, кто не использует PHP в ежедневной работе, например, для QA-инженера, может быть удобным запуск тестов в Docker. Удобным в первую очередь тем, что снимает необходимость в установке нужной версии PHP со всеми расширениями на локальную машину. Кроме того, это если в разных релизах использовалась разная версия PHP, то использование Dockerа облегчает бэкпорт патчей и прогон тестов в соответствующих бранчах.

Организовать все это можно в виде отдельного Dockerfileа, например, Dockerfile-test со следующим содержанием:

FROM php:7.4-cliRUN apt-get update \    && apt-get install -y libxslt1-dev libzip-dev \    && docker-php-ext-install xsl \    && docker-php-ext-install intl \    && docker-php-ext-install zip \    && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

Далее создаем инструкции для Docker Compose (в моем случае в файле docker-compose.test.yml):

version: '3'services:  tests:    build:      context: .      dockerfile: Dockerfile-test    command: bash -c "cd /opt/plesk && composer install && composer test"    volumes:      - .:/opt/plesk

В итоге получается достаточно идиоматический запуск тестов:

docker-compose -f docker-compose.test.yml run tests

Разница по времени между между локальным прогоном и прогоном в Dockerе в моем конкретном случае составляет 3 раза. То есть примерно 30 секунд против 10 секунд для локального прогона.

PhpStorm

Для написания PHP кода обычно используется PhpStorm. Есть в нем и удобные инструменты по работе с PHPUnit.

Во-первых, это запуск тестов, выбирая конфигурацию из меню Run (или контекстного меню) phpunit.xml.dist или директорию, где расположены тесты. Накладные расходы на дополнительную визуализацию в PhpStorm на моей локальной машине в конкретном проекте (~4500 тестов.) плавают в диапазоне 10-30%, но в абсолютных цифрах это 13 секунд, против 10 секунд при запуске в терминале, что совершенно несущественно.

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

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

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

Внешнее наблюдение за тестами

Функционал наблюдения за тестами в PhpStorm существует уже года 3. До этого задача решалась с помощью внешнего наблюдателя. Однако и сейчас по определенным причинам внешний наблюдатель может быть полезен (например, вы правите код в vimе или VSCode).

Наиболее популярный и живой проект по данной теме это phpunit-watcher. Добавляем его с помощью composer и определяем phpunit-watcher.yml примерно следующего содержания:

watch:  directories:    - common/php    - ...  fileMask: '*.php'phpunit:  binaryPath: common/php/plib/vendor/bin/phpunit  arguments: '--stop-on-failure'

Также в composer.json в раздел scripts добавляем еще одну команду:

"scripts": {    ...    "test:watch": "phpunit-watcher watch",    ...

Таким образом, для того, чтобы запустить тесты под наблюдением, используется команда composer test:watch Отправляем ее жить в окошко терминала на отдельный монитор и получаем удобство наблюдения, аналогичное PhpStormу.

Контроль уровня покрытия

При росте кодовой базы и количества тестов возникает желание удерживать code coverage хотя бы на уже существующем уровне (а в идеале увеличивать его). Можно сколько угодно пытаться контролировать это руками (например, силами какого-то конкретного инженера), а можно поручить эту задачу роботу.

Схема выглядит следующим образом. Сначала выполняем подсчет code coverage, смотрим процент покрытия и устанавливаем его как отправную точку. Далее создаем скрипт, который будет возвращать ненулевой код возврата (определяя падение), если текущий процент code coverage стал ниже отправной точки. Данный скрипт используется в проверках на pull requestах, таким образом не давая замержить изменения, если процент code coverage упал. Добавил новый код? Нужно добавить тесты. С роботом-ревьювером уже нельзя договориться, мол, я чуть позже их добавлю. Он беспристрастно поставит блокировку.

Для подсчета code coverage используется расширение Xdebug. На данный момент, на версии 3.0 на всех проектах, которых смотрел, дело заканчивается segfaultом (есть как плавающие баги, так и стабильно повторяемые проблемы), поэтому продолжаем пока использовать 2.9.0. Подключение расширения с настройками по умолчанию (xdebug.mode=develop) даже без подсчета code coverage приводит к 2-3 кратному замедлению прогона тестов. В конкретном случае с ~4500 тестами на моей локальной машине процесс замедляется с 10 секунд до 27 секунд. Пока еще не сильно критично, но уже довольно заметно. Если запустить прогон тестов вместе с подсчетом code coverage, то он займет в моем случае больше 30 минут. Если процент code coverage упал, вы добавляете новые тесты и несколько раз выполняете их прогон, то ждать несколько раз по 30 минут это довольно долго.

Анализ показывает, что больше всего времени требуется для генерации отчета в HTML. Так как сам отчет нас не особо интересует, то можно воспользоваться опцией --coverage-php, а далее полученный файл проанализировать собственным скриптом. В итоге проверка текущего процента code coverage из 30 минут превращается в 2 минуты на прогон тестов и еще примерно 2,5 минуты на анализ репорта (напомню, что проект довольно большой, и файл занимает более 60 Мб). Есть еще поле для оптимизации, но текущий вариант уже устраивает. Например, сократить первую фазу с 2 минут до 1 минуты можно с помощью pcov.

В phpunit.dist.xml нужно определиться с секцией coverage. Также важно указать опцию includeUncoveredFiles, потому что процент покрытия нужно считать от всех файлов, а не только тех, которых касались тесты.

 <coverage includeUncoveredFiles="true">    <include>      <directory suffix=".php">common/php</directory>      ...    </include>    <exclude>      <directory>common/php/plib/locales</directory>      <directory>common/php/plib/vendor</directory>      <directory>common/php/tests</directory>      ...    </exclude>  </coverage>

В composer.json формируем команду для проверки с учетом всего вышесказанного:

 "scripts": {    ...    "test-coverage-threshold": [      "@php -dzend_extension=xdebug.so -dxdebug.mode=coverage common/php/plib/vendor/bin/phpunit --coverage-php .phpunit.coverage.php",      "@php -dzend_extension=xdebug.so common/tools/coverage-threshold.php .phpunit.coverage.php 12.49"    ],    ...

Где магическая цифра 12.49 это текущая отправная точка процент code coverage, ниже которого опускаться нельзя. Она не должна сильно отставать от текущего состояния, и периодически при добавлении новых тестов нужно не забывать ее подкручивать.

Повышение качества кода тестов

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

Один из стресс-методов для проверки является запуск тестов командой composer test -- --process-isolation. В таком режиме каждый тест будет запускаться в рамках отдельного PHP-процесса. Изоляция это прекрасно, но на практике в таком режиме возникает сразу несколько нюансов. Во-первых, работает это все крайне медленно. Вместо 10 секунд будет уже порядка 14 минут в моей конкретной ситуации. Во-вторых, не все вещи будут работать в такой конфигурации. Например, в data providerах можно использовать только сериализуемые структуры (а коллеги-программисты могли надобавлять туда уже замыканий, моков и других динамических радостей). С первой проблемой можно пытаться бороться с помощью ParaTest, однако у него есть еще дополнительные ограничения.

Относительной альтернативой опции --process-isolation является запуск тестов в случайном порядке. Для этого можно использовать опцию командной строки --order-by=random, либо указать в phpunit.xml.dist для корневого тега атрибут executionOrder="random". Локализовывать и отлаживать проблемы заметно сложнее, чем в случае с --process-isolation, но вполне реально. Обращаем внимание на сгенерированный random seed в начале вывода от PHPUnit и повторяем прогон командой ниже:

composer test -- --order-by=random --random-order-seed=1617073223

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

Еще один момент, на который стоит обратить внимание, это скорость выполнения каждого отдельного теста. Один из вариантов ее узнать это использование опции --log-junit. В результате будет получен XML-файл с информацией о времени, затраченном на каждый тест. Можно написать простенький скрипт для анализа, а можно воспользоваться встроенным функционалом в PhpStorm и сортировкой по времени:

Код тестов это тоже код, поэтому не стоит игнорировать проверки на соответствие принятого стиля или следования общепринятым практикам написания хорошего кода. Автоматизированные линтеры также должны проверять и директорию с тестами. На самом деле, тесты с низким качеством кода довольно часто в итоге оказываются моргающими (flaky) тестами.

Поддержка двух платформ (Linux и Windows)

Если вам посчастливилось писать продукт под две платформы (Linux и Windows), то в рамках тестов нужно учитывать то, каким образом проверяется платформозависимый код. Вилки по константе PHP_OS, использование PHP_EOL все это обязательно создаст проблемы, а перебить их не получится даже с помощью runkitа. В идеале, прогон тестов на PHP код для Windows должен иметь возможность сделать и разработчик, у которого рабочая машина под Linux или Mac. Поэтому механизм определения платформы лучше сразу сделать конфигурируемым. На поздних этапах вкручивать его довольно тяжело. Если платформозависимого кода довольно много, может оказаться проще использовать два запуска тестов, указывая платформу через переменную окружения:

PHP_OS=WINNT composer testPHP_OS=Linux composer test

Поле для экспериментов

Периодически выходят новые мажорные версии PHP, и самый первый шаг и довольно быстрый способ проверки и поиска проблем это прогон тестов. В этом сильно помогает Docker и упомянутый выше Dockerfile, чтобы не влиять на локальную машину. Ведь для большого проекта момент готовности кодовой базы к новой версии PHP и сам момент перехода на новую версию довольно разнесенные по времени события. Соответственно, в первую очередь делаются forward compatible изменения, и проверяется работоспособность тестов на двух версиях (старой и новой версии PHP).

Проверку новых фичей языка и синтаксиса также очень удобно делать в рамках кода тестов. Это довольно безопасно, так как код тестов не идет в релиз, и при этом вы получаете возможность прочувствовать нововведения. На самом деле, это могут быть любые нововведения в рамках кодовой базы (необязательно новые фичи языка). Допустим, решили, например, следовать PSR-2 и убрать символ нижнего подчеркивания из имен protected и private переменных и методов. Первое, где можно попробовать обкатать скрипты замены и рефакторинга, это код тестов.

Заключение

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

Как говорится, да прибудут с вами всегда зеленые тесты :)

Подробнее..

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

См. также

Подробнее..

Новости Yii 2020, выпуск 7

12.11.2020 02:22:21 | Автор: admin

Новости Yii 2020, выпуск 7


Всем привет! Это очередной выпуск новостей Yii. Как обычно, в выпуске вас ждут релизы Yii 2, прогресс Yii 3, важные вести о Yii 1 и другие новости. Приятного чтения и будьте здоровы. Александр Макаров


Фонд


С прошлого выпуска пришлось прилично отвлечься на фонд, а именно на то, как средства перебрасываются из GitHub Sponsors в OpenCollective. С GitHub они уходили нормально, а вот куда большой вопрос. Потребовалось время, чтобы разобраться, но проблему удалось решить.


Ещё одна новость, частично связанная с фондом. Автор httpsoft/http-message, Евгений Зюбин, вероятно присоединится к команде фулл-тайм если/когда это позволит пополнение фонда. Если вы или ваша компания хотите получить Yii 3 раньше, можете помочь.


Инфраструктура


Мы постоянно улучшаем процесс тестирования пакетов:


  • В пакеты со стабильной версией добавлена проверка Roave backwards compatibility.
    Она проверяет что публичный API не сломан по-сравнению с предыдущим стабильным релизом.
  • Мы продолжили перевод тестов с Travis на GitHub actions как для Yii 2, так и для Yii 3. Actions классные, а Travis не так давно порезал поддержку OpenSource. Хорошо что мы начали переход заранее.
  • Мы решили не собирать покрытие кода через PHPUnit с последующей отсылкой его в Scrutinizer CI и теперь генерируем отчёт о покрытии средствами Scrutinizer. Это значительно быстрее, а результат тот же.
  • Отлично себя показал Psalm. Рекомендуем, в том числе, для ваших проектов.
  • В консоль GitHub actions теперь всё выводится в цвете. Выглядит значительно лучше!

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


Патчи для совместимости с PHPUnit для Yii 2 и Yii 1 переехали в отдельный репозиторий. Если вдруг вам понадобится тестировать приложение на версиях PHP с 5.3 по 8, репозиторий будет определённо полезен.


Yii 1



Yii 2


Был выпущен Yii 2.0.39. В нём есть улучшения DI-контейнера и дополнительные исправления для работы с PHP 8.


Чуть менее заметное улучшение коснулось способа генерации аннотаций для магических свойств. Теперь некоторые IDE, включая PhpStorm, будут отличать свойства только для чтения и только для записи.


Были выпущены новые версии следующих расширений:



Yii 3


С прошлого выпуска были сделаны следующие релизы:



На данный момент мы готовим пакеты из списка в карточке Trello.


Был принят ряд интересных решений:


  • Все стабильные релизы будут начинаться с версии 1.0.0. Ранее рассматривался вариант начинать с 3.0.0.
  • Пакеты Yii 3.0 буду поддерживать PHP 7.4.
  • В большинство пакетов добавлена конфигурация по-умолчанию. Они будут работать сразу после установки без дополнительной конфигурации или с очень минимальной конфигурацией.
  • Провайдеры конфигурации были удалены почти из всех пакетов и приложений.

В Trello есть доска с задачами, над которыми мы работаем, включая не отражённые в GitHub issue.


Почти каждый из пакетов был серьёзно почищен, получил совместимость с PHP 8 и исправления. Ниже представлено самое интересное.


Новые пакеты


Был создан ряд новых пакетов. Часть из них появились в результате выделения общего кода из других пакетов, а часть нет.



Инструменты для разработки


  • Были актуализированы зависимости и добавлен Dockerfile.
  • Реализована возможность полу-автоматического выпуска релизов.

Composer config plugin


Была добавлена временная поддержка PHP 8. Она не заменяет вариант с переписыванием плагина на AST и нужна для того, чтобы облегчить тестирование под PHP 8 в то время как мы занимаемся версией с AST.


Контейнер и фабрика



Кеш



Bulma


  • Больше документации, улучшено именование.
  • Добавлена возможность использовать значки в выпадающем меню.
  • Все виджеты сделаны иммутабельными.

Роутер


  • Внутренности и конфигурация упрощены путём выделения коллекции маршрутов в отдельный класс.
  • Метод UrlMatcherInterface::getLastMatchedRequest() удалён, добавлен getCurrentUri().
  • UrlMatcher теперь является опциональным, что хорошо сочетается с консольными приложениями.

Шаблоны приложений и демо


  • Больше не требуется NodeJs. Ресурсы забираются через asset packagist.
  • Конфиги значительно почищены. В app мы поделили их по разным пакетам.
  • Убрана ссылка контейнера на себя.
  • В yii-demo добавлен Swagger. Открывается через /swagger.
  • yii-demo подвергся рефакторингу.
  • Заменили в yii-demo реализацию PSR-7 на httpsoft/http-message.

Var dumper



Files



Cycle


  • В файловую схему теперь можно писать. Также в неё добавлена поддержка чтения из нескольких файлов.
  • Был задействованы наши DI контейнер / фабрика, так что интеграция с Cycle теперь работает на PHP 8.

Data



DBAL и ActiveRecord


Как DBAL, так и ActiveRecord, портированные с Yii 2, ещё рефакторить и рефакторить несмотря на то, что их серьёзно почистили и они, по большей части, работают.


Arrays



HTML



Debugger



Очереди



Translator


Пакеты i18n помечены как устаревшие, добавлены пакеты translator с новым дизайном.


Новая и изменённая документация



Рекомендации к чтению и другие новости



Спасибо!


Хочу сказать спасибо всем спонсорам и разработчикам, благодаря которым стала возможна разработка Yii 3. Вместе у нас всё получится.


Отдельное спасибо тем, кто помог Yii 3 кодом:


Подробнее..
Категории: Php , Framework , Yii , Php 8 , Yii 2 , Yii 3 , Phpunit

Так как же не страдать от функциональных тестов?

22.04.2021 18:21:21 | Автор: admin

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

Эта статья ориентирована скорее на новичков в программировании, но возможно кто-то сможет вдохновиться одним из описанных далее подходов.

Я думаю, все знают принципы хороших тестов:

  • Тест должен быть атомарным, т.е. проверять единицу логики (например, один HTTP-метод или один метод класса)

  • Тест должен быть изолированным, т.е. прохождение тестов не должно зависеть от порядка их выполнения

  • Тест должен быть повторяемым, т.е. выполнение теста локально и в CI должно приводить к одному результату

Проблема возникает тогда, когда вы начинаете пытаться реализовывать подобные тесты и сталкиваетесь с реальностью: pipeline всего из 3000 функциональных тестов проходит около двух часов!


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

Любой запуск функциональных тестов для API можно разделить на следующие этапы:

  1. Применить миграции (Структура БД)

  2. Применить фикстуры (Тестовые данные в БД)

  3. Выполнить HTTP-запрос

  4. Выполнить необходимые проверки

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

Для обеспечения требования повторяемости (а также, чтобы тесты в принципе были близки к реальности) в ходе тестирования используется настоящая СУБД той же версии, которая используется на промышленной среде. Для каналов передачи данных, по возможности, тоже не делаются заглушки: поднимаются и другие зависимости вроде Redis/RabbitMQ и отдельное HTTP приложение, содержащее моки для имитации вызовов сторонних сервисов.

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

В итоге получается примерно следующий интерфейс:

Пример описания запроса
{  "method": "patch",  "uri": "/v2/project/17558/admin/items/physical_good/sku/not_existing_sku",  "headers": {    "Authorization": "Basic MTc1NTg6MTIzNDVxd2VydA=="  },  "data": {    "name": {      "en-US": "Updated name",      "ru-RU": "Обновленное название"    }  }}
Пример описания запроса
{  "status": 404,  "data": {    "errorCode": 4001,    "errorMessage": "[0401-4001]: Can not find item with urlSku = not_existing_sku and project_id = 17558",    "statusCode": 404,    "transactionId": "x-x-x-x-transactionId-mock-x-x-x"  }}
Пример описания самого теста
<?php declare(strict_types=1);namespace Tests\Functional\Controller\Version2\PhysicalGood\AdminPhysicalGoodPatchController;use Tests\Functional\Controller\ControllerTestCase;class AdminPhysicalGoodPatchControllerTest extends ControllerTestCase{    public function dataTestMethod(): array    {              return [                // Negative cases                'Patch -- item doesn\'t exist' => [                        '001_patch_not_exist'                ],            ];    }}

Структура директории с тестами:

TestFolder Fixtures    store       item.yml Request    001_patch_not_exist.json Response    001_patch_not_exist.json   Tables    001_patch_not_exist        store            item.yml AdminPhysicalGoodPatchControllerTest.php

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

...

Теперь, после того, как я описал контекст, в котором осуществлялись работы по оптимизации, давайте же наконец перейдем непосредственно к приемам.

1. Применять миграции единожды

Миграция это скрипт, который позволяет перевести текущую структуру БД из одного консистентного состояния в другое.

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

Кроме миграций в приложении используются также фикстуры набор тестовых данных для инициализации БД.

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

Суть в том, что если фикстуры накатываются только 1 раз, то порядок выполнения тестов начинает играть роль, ведь изменения, выполненные командами, не сбрасываются! Но некоторое компромиссное решение будет описано ниже.

2. Кэшировать миграции

Чем дольше живет приложение, тем больше миграций он несет вместе с собой. Усугубляться все может сценарием, в котором несколько приложений работают с одной БД (соответственно и миграции общие).

Для одной из наших самых старых схем в БД существует около 667 миграций на текущий момент. А таких схем не один десяток. Надо ли говорить, что каждый раз применять все миграции может оказаться достаточно расточительным?

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

Пример скрипта для сборки миграций
#!/usr/bin/env bashif [[ ! -f "dump-cache.sql" ]]; then    echo 'Generating dump'    # Загрузка миграций из удаленного репозитория    migrations_dir="./migrations" sh ./scripts/helpers/fetch_migrations.sh    # Применение миграций к БД    migrations_dir="./migrations" host="percona" sh ./scripts/helpers/migrate.sh    # Генерируется дамп только для интересующих нас схем (store, delivery)    mysqldump --host=percona --user=root --password=root \      --databases store delivery \      --single-transaction \      --no-data --routines > dump.sql    cp dump.sql dump-cache.sqlelse    echo 'Extracting dump from cache'    cp dump-cache.sql dump.sqlfi

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

Пример CI-job (gitlab)
build migrations:  stage: build  image: php72:1.4  services:    - name: percona:5.7  cache:    key:      files:        - scripts/helpers/fetch_migrations.sh    paths:      - dump-cache.sql  script:    - bash ./scripts/ci/prepare_ci_db.sh  artifacts:    name: "$CI_PROJECT_NAME-$CI_COMMIT_REF_NAME"    paths:      - dump.sql    when: on_success    expire_in: 30min

3. Использовать транзакции БД при применении фикстур

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

  1. Применить фикстуры

  2. В цикле для каждого теста:

    1. Начать транзакцию

    2. Выполнить тест

    3. Проверить результат

    4. Откатить транзакцию

При локальном запуске 19 тестов (каждый из которых заполняет 27 таблиц) по 10 раз были получены результаты (в среднем): 10 секунд при использовании данного подхода и 18 секунд без него.

Что необходимо учесть:

  • У вас должно использоваться одно соединение внутри приложения, а также для инициации транзакции внутри теста. Соответственно, необходимо достать инстанс соединения из DI-контейнера.

  • При откате транзакции счетчики AUTO INCREAMENT не будут сбрасываться, как это происходит при применении TRUNCATE. Могут возникнуть проблемы в тестах, в которых в ответе возвращается идентификатор созданной сущности.

Пример кода
public static function setUpBeforeClass(): void{        parent::setUpBeforeClass();        foreach (self::$onSetUpCommandArray as $command) {            self::getClient()->$command(self::getFixtures());        }}.../** * @dataProvider dataTestMethod */public function testMethod(string $caseName): void{        /** @var Connection $connection */        $connection = self::$app->getContainer()->get('doctrine.dbal.prodConnection');        $connection->beginTransaction();                $this->traitTestMethod($caseName);        $this->assertTables(\glob($this->getCurrentDirectory() . '/Tables/' . $caseName . '/**/*.yml'));                $connection->rollBack();}

4. Разделить тесты по типу операции

В любой API всегда есть ряд методов, которые не изменяют состояние базы, т.е. только читают и возвращают данные. Имеет смысл выделить тесты для таких методов в отдельные классы/методы и так же, как и в предыдущем варианте, накатывать фикстуры единожды (отличие в том, что не требуются доработки с транзакцией).

Что необходимо учесть:

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

Подобный функционал был доступен в том числе в библиотеке dbunit, однако сейчас библиотека не поддерживается. Поэтому у нас в компании был написан некоторый велосипед, которые реализует подобные функции.

Пример кода
public function tearDown(): void{        parent::tearDown();        // После первого выполненного теста массив команд DB-клиента будет обнулен        // Поэтому в последующие разы фикстуры не будут применяться        self::$onSetUpCommandArray = [];}public static function tearDownAfterClass(): void{        parent::tearDownAfterClass();        self::$onSetUpCommandArray = [            Client::COMMAND_TRUNCATE,            Client::COMMAND_INSERT        ];}

5. Распараллелить выполнение тестов

Тесты это операция, которая явно напрашивается на распараллеливание. И да, в действительности распараллеливание потенциально дает всегда наибольший прирост к производительности. Однако, здесь есть ряд проблем.

Распараллелить выполнение тестов можно как в рамках целого pipelineа, так и в рамках конкретной джобы.

При распараллеливании в рамках pipelineа необходимо просто создать отдельные джобы для каждого набора тестов (Используя testsuite у phpunit). У нас тесты разделены по версии контроллера.

Пример кода
<testsuite name="functional-v2">        <directory>./../../tests/Functional/Controller/Version2</directory></testsuite>
functional-v2:  extends: .template_test  services:    - name: percona:5.7  script:    - sh ./scripts/ci/migrations_dump_load.sh    - ./vendor/phpunit/phpunit/phpunit --testsuite functional-v2 --configuration config/test/phpunit.ci.v2.xml --verbose

Альтернативно можно распараллелить тесты в пределах одного джоба, используя, например, paratest. Библиотека позволяет запускать несколько процессов для выполнения тестов.

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

Если подвести итог:

  • Сделать несколько отдельных джоб на уровне CI самый простой способ ускорить прохождение тестов

  • Распараллелить функциональные тесты в рамках одной джобы сложно, но подобный подход может быть приемлем для юнит-тестов

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

...

6. Не пересоздавать экземпляр приложения

В общем случае для того, чтобы полностью сбросить все локальное состояние приложения между тестами достаточно просто создать новый экземпляр. Это решение в лоб и его будет достаточно в большинстве случаев. Однако наше приложение разрабатывается достаточно давно, поэтому в нем накопилось много логики, которая выполняется при старте. Из-за этого bootstrap между тестами может занимать ощутимое время, когда тестов становится много.

Решением может быть использовать всегда один и тот же инстанс приложения (сохранять его в статическое свойство класса тестов). В этом случае, опять же, тесты перестанут быть изолированными друг от друга, т.к. множество служб из DI-контейнера могут сохранять локальный стейт (например, какое-то кэширование для быстродействия, открытые соединения с БД и т.п.).

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

Пример кода
interface StateResetInterface{    public function resetState();}
$container = self::$app->getContainer();foreach ($container->getKnownEntryNames() as $dependency) {        $service = $container->get($dependency);        if ($service instanceof StateResetInterface) {                $service->resetState();        }}

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

После всех оптимизаций время прохождения в CI для функциональных тестов уменьшилось до 12-15 минут. Я, конечно, сомневаюсь, что описанные выше приемы в их изначальном виде окажутся полезны, но надеюсь, что они вдохновили и натолкнули на собственные идеи!

А какой подход к написанию тестов используете вы? Приходится ли их оптимизировать, и какие способы использовали вы?

Подробнее..

Перевод Не мокайте то, чем вы не владеете

25.04.2021 22:21:50 | Автор: admin

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

Веб-приложения зачастую созданы для обработки 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'а для всего этого, пока вы не владеете интерфейсом, мешает вам выполнить работу. Определение собственного интерфейса значительно упрощает задачу.

Подробнее..

Категории

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

  • Имя: Макс
    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