Статьи

Повторное представление PHPUnit — начало работы с TDD в PHP

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

Эта статья направлена ​​на то, чтобы повторно представить инструмент современным способом, современной аудитории, в современной среде PHP — если вы не знакомы с PHPUnit или тестированием, этот пост для вас.

Иллюстрация манекена краш-теста перед монитором с графиками

Здесь мы предполагаем, что вы знакомы с объектно-ориентированным PHP и используете PHP версии 7 и выше. Для запуска и запуска среды, в которой предустановлен PHP 7, и чтобы можно было следовать инструкциям, приведенным в этом сообщении, без каких-либо ошибок, мы рекомендуем использовать Homestead Improved . Также обратите внимание, что ожидается некоторое использование командной строки, но вы будете руководствоваться всем этим. Не бойтесь этого — это инструмент более мощный, чем вы можете себе представить.

Если вам интересно, почему мы рекомендуем всем использовать Vagrant, я подробно расскажу об этом в среде PHP Start Start , но это введение Vagrant также объяснит все адекватно.

Что такое разработка через тестирование?

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

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

Например, утверждение, что 2 + 2 = 4 является правильным. Но если мы утверждаем, что 2 + 3 = 4, среда тестирования (например, PHPUnit) пометит это утверждение как ложное. Это называется «проваленный тест». Мы проверили 2 + 3 4, и не удалось. Очевидно, что в вашем приложении вы не будете проверять суммы скалярных значений — вместо этого будут переменные, которые язык заменит во время выполнения на реальные значения и подтвердит это, но вы поняли идею.

Что такое PHPUnit?

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

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

Код, который мы заканчиваем в конце этого урока, можно скачать с Github .

Начальная загрузка примера приложения

Чтобы привести примеры домой, мы создадим простой пакет командной строки, который позволит пользователям превращать файл JSON в файл PHP. Этот файл PHP будет содержать данные JSON в виде ассоциативного массива PHP. Это только мой личный пример использования — я часто использую Diffbot, и результат может быть огромным — слишком большой, чтобы его можно было проверить вручную, поэтому более удобная обработка с помощью PHP может оказаться очень полезной.

Впредь предполагается, что вы используете полностью PHP 7-совместимую среду с установленным Composer и можете следовать за ней. Если вы загрузили Homestead Improved , пожалуйста, используйте SSH сейчас с помощью vagrant ssh , и давайте начнем.

Сначала мы перейдем в папку, где живут наши проекты. В случае Homestead Improved это Code .

 cd Code 

Затем мы создадим новый проект на основе PDS-Skeleton и установим внутри него PHPUnit с помощью Composer .

 git clone https://github.com/php-pds/skeleton converter cd converter composer require phpunit/phpunit --dev 

Обратите внимание, что мы использовали флаг --dev чтобы установить PHPUnit только в качестве зависимости dev — это означает, что он не нужен в производственной --dev что --dev наш развернутый проект легким. Также обратите внимание, что тот факт, что мы начали с PDS-Skeleton, означает, что наша папка tests уже создана для нас с двумя демонстрационными файлами, которые мы будем удалять.

Далее нам нужен фронт-контроллер для нашего приложения — файл, через который перенаправляются все запросы. В converter/public создайте index.php со следующим содержимым:

 <?php echo "Hello world"; 

Вы должны быть знакомы со всем вышеуказанным содержанием. С нашим содержимым «Hello World», давайте удостоверимся, что мы можем получить к нему доступ из браузера.

Если вы используете Homestead Improved , я надеюсь, что вы следовали инструкциям и настраивали виртуальный хост или обращаетесь к приложению через IP-адрес виртуальной машины.

Экран проекта Hello World

Давайте удалим лишние файлы сейчас. Либо сделайте это вручную, либо выполните следующее:

 rm bin/* src/* docs/* tests/* 

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

Комплекты и конфигурации

Нам нужен конфигурационный файл PHPUnit, который сообщает PHPUnit, где искать тесты, какие подготовительные шаги нужно выполнить перед тестированием и как тестировать. В корне проекта создайте файл phpunit.xml со следующим содержимым:

 <phpunit bootstrap="tests/autoload.php"> <testsuites> <testsuite name="converter"> <directory suffix="Test.php">tests</directory> </testsuite> </testsuites> </phpunit> во <phpunit bootstrap="tests/autoload.php"> <testsuites> <testsuite name="converter"> <directory suffix="Test.php">tests</directory> </testsuite> </testsuites> </phpunit> во <phpunit bootstrap="tests/autoload.php"> <testsuites> <testsuite name="converter"> <directory suffix="Test.php">tests</directory> </testsuite> </testsuites> </phpunit> 

phpunit.xml

Проект может иметь несколько наборов тестов, в зависимости от контекста. Например, все, что связано с учетной записью пользователя, может быть сгруппировано в набор, называемый «пользователи», и он может иметь свои собственные правила или другую папку для тестирования этой функциональности. В нашем случае, проект очень маленький, поэтому одного набора более чем достаточно, ориентированного на каталог tests . Мы определили suffix аргумент — это означает, что PHPUnit будет запускать только те файлы, которые заканчиваются на Test.php . Полезно, когда мы хотим, чтобы в tests другие файлы, но мы не хотим, чтобы они запускались, за исключением случаев, когда мы вызываем их из реальных файлов Test.

О других подобных аргументах вы можете прочитать здесь .

Значение bootstrap сообщает PHPUnit, какой файл PHP загружать перед тестированием. Это полезно при настройке переменных автозагрузки или тестирования проекта, даже базы данных тестирования и т. Д. — всего, что вам не нужно или не нужно в рабочем режиме. Давайте создадим tests/autoload.php :

 <?php require_once __DIR__.'/../vendor/autoload.php'; 

tests/autoload.php

В этом случае мы просто загружаем автозагрузчик Composer по умолчанию, поскольку PDS-Skeleton уже настроил для нас пространство имен Tests в composer.json . Если мы заменим значения шаблона в этом файле нашими собственными, мы получим файл composer.json который выглядит следующим образом:

 { "name": "sitepoint/jsonconverter", "type": "standard", "description": "A converter from JSON files to PHP array files.", "homepage": "https://github.com/php-pds/skeleton", "license": "MIT", "autoload": { "psr-4": { "SitePoint\\": "src/SitePoint" } }, "autoload-dev": { "psr-4": { "SitePoint\\": "tests/SitePoint" } }, "bin": ["bin/converter"], "require-dev": { "phpunit/phpunit": "^6.2" } } 

После этого мы запускаем composer du (сокращение от dump-autoload ), чтобы обновить сценарии автозагрузки.

 composer du 

Первый тест

Помните, что TDD — это искусство сначала делать ошибки, а затем вносить изменения в код, чтобы они перестали быть ошибками, а не наоборот. Имея это в виду, давайте создадим наш первый тест.

 <?php namespace SitePoint\Converter; use PHPUnit\Framework\TestCase; class ConverterTest extends TestCase { public function testHello() { $this->assertEquals('Hello', 'Hell' . 'o'); } } 

tests/SitePoint/Converter/ConverterTest.php

Лучше всего, если тесты будут следовать той же структуре, что и наш проект. Имея это в виду, мы даем им одинаковые пространства имен и одинаковое расположение дерева каталогов. Таким образом, наш файл ConverterTest.php находится в tests , подпапка SitePoint , подпапка Converter .

Расширяемый файл является самой базовой версией класса Test, которую предлагает PHPUnit. В большинстве случаев этого будет достаточно. Когда нет, это прекрасно, чтобы расширить его, а затем основываться на этом. Помните — тесты не должны следовать правилам хорошего проектирования программного обеспечения, поэтому глубокое наследование и повторение кода хороши — до тех пор, пока они тестируют то, что нужно тестировать!

В этом примере «контрольный пример» утверждает, что строка Hello равна объединению Hell и o . Если мы сейчас запустим этот пакет с php vendor/bin/phpunit , мы получим положительный результат.

Пример теста PHPUnit положительный

PHPUnit запускает каждый метод, начиная с test в Test файле, если не указано иное. Вот почему нам не нужно было явно указывать при запуске набора тестов — все это автоматически.

Наш текущий тест не является ни полезным, ни реалистичным. Мы использовали это просто, чтобы проверить, работает ли наша установка. Давайте напишем правильный сейчас. Перепишите файл ConverterTest.php следующим образом:

 <?php namespace SitePoint\Converter; use PHPUnit\Framework\TestCase; class ConverterTest extends TestCase { public function testSimpleConversion() { $input = '{"key":"value","key2":"value2"}'; $output = [ 'key' => 'value', 'key2' => 'value2' ]; $converter = new \SitePoint\Converter\Converter(); $this->assertEquals($output, $converter->convertString($input)); } } 

tests/SitePoint/Converter/ConverterTest.php

Хорошо, так что здесь происходит?

Мы тестируем «простое» преобразование. Входные данные представляют собой строку JSON, строковый объект, а ожидаемый результат — его версию массива PHP. Наш тест утверждает, что наш класс Converter при обработке $input с convertString метода convertString производит желаемый $output , как определено.

Перезапустите набор.

Неудачный тест PHPUnit

Неудачный тест! Ожидаемый, так как класс даже еще не существует.

Давайте сделаем вещи немного более драматичными — с цветом! Отредактируйте файл phpunit.xml так, <phpunit тег <phpunit содержал colors="true" , например так:

 <phpunit colors="true" bootstrap="tests/autoload.php"> 

Теперь, если мы запустим php vendor/bin/phpunit , мы получим более впечатляющий результат:

Красные ошибки

Прохождение теста

Теперь мы начинаем процесс прохождения этого теста.

Наша первая ошибка: «Класс« SitePoint \ Converter \ Converter »не найден». Давайте это исправим.

 <?php namespace SitePoint\Converter; class Converter { } 

src/SitePoint/Converter/Converter.php ;

Теперь, если мы повторно запустим пакет …

Отсутствует метод в ошибке класса

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

 <?php namespace SitePoint\Converter; class Converter { public function convertString(string $input): ?array { } } 

src/SitePoint/Converter/Converter.php ;

Мы определили метод, который принимает входные данные типа строки и возвращает либо массив, либо ноль, если не удалось. Если вы не знакомы со скалярными типами ( string $input ), узнайте больше здесь , а о типах возвращаемых значений ( ?array ) смотрите здесь .

Перезапустите тесты.

Неверный метод в ошибке класса

Это ошибка возвращаемого типа — функция ничего не возвращает (void), потому что она пустая, и ожидается, что она возвратит либо ноль, либо массив. Давайте завершим метод. Мы будем использовать встроенную в json_decode функцию json_decode для декодирования строки JSON.

  public function convertString(string $input): ?array { $output = json_decode($input); return $output; } 

src/SitePoint/Converter/Converter.php ;

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

Объект возвращен вместо ассоциированного массива

Ооо Функция возвращает объект, а не массив. Ах, ха! Это потому, что мы не активировали режим «ассоциативный массив» в функции json_decode . Функция превращает массивы JSON в экземпляры stdClass по умолчанию, если не указано иное. Измените это так:

  public function convertString(string $input): ?array { $output = json_decode($input, true); return $output; } 

src/SitePoint/Converter/Converter.php ;

Перезапустите набор.

Проходной тест!

Ура! Наш тест сейчас проходит! Он получит точно такой же результат, какой мы ожидаем от него в тесте!

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

  { $input = '{"key":"value","key2":"value2","some-array":[1,2,3,4,5]}'; $output = [ 'key' => 'value', 'key2' => 'value2', 'some-array' => [1, 2, 3, 4, 5], ]; $converter = new \SitePoint\Converter\Converter(); $this->assertEquals($output, $converter->convertString($input)); } public function testMoreComplexConversion() { $input = '{"key":"value","key2":"value2","some-array":[1,2,3,4,5],"new-object":{"key":"value","key2":"value2"}}'; $output = [ 'key' => 'value', 'key2' => 'value2', 'some-array' => [1, 2, 3, 4, 5], 'new-object' => [ 'key' => 'value', 'key2' => 'value2', ], ]; $converter = new \SitePoint\Converter\Converter(); $this->assertEquals($output, $converter->convertString($input)); } public function testMostComplexConversion() { $input = '[{"key":"value","key2":"value2","some-array":[1,2,3,4,5],"new-object":{"key":"value","key2":"value2"}},{"key":"value","key2":"value2","some-array":[1,2,3,4,5],"new-object":{"key":"value","key2":"value2"}},{"key":"value","key2":"value2","some-array":[1,2,3,4,5],"new-object":{"key":"value","key2":"value2"}}]'; $output = [ [ 'key' => 'value', 'key2' => 'value2', 'some-array' => [1, 2, 3, 4, 5], 'new-object' => [ 'key' => 'value', 'key2' => 'value2', ], ], [ 'key' => 'value', 'key2' => 'value2', 'some-array' => [1, 2, 3, 4, 5], 'new-object' => [ 'key' => 'value', 'key2' => 'value2', ], ], [ 'key' => 'value', 'key2' => 'value2', 'some-array' => [1, 2, 3, 4, 5], 'new-object' => [ 'key' => 'value', 'key2' => 'value2', ], ], ]; $converter = new \SitePoint\Converter\Converter(); $this->assertEquals($output, $converter->convertString($input)); } 

tests/SitePoint/Converter/ConverterTest.php

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

Успешный тестовый прогон

… но что-то не так, не так ли? Здесь очень много повторений, и если мы когда-нибудь изменим API класса, нам придется внести изменения в 4 местах (пока). Преимущества DRY начинают проявляться даже в тестах. Ну, есть возможность помочь с этим.

Поставщики данных

Поставщики данных — это специальные функции в тестовых классах, которые имеют одну конкретную цель: предоставить набор данных для тестовой функции, чтобы вам не нужно было повторять ее логику для нескольких тестовых функций, как мы это делали. Это лучше всего объяснить на примере. Давайте сделаем рефакторинг нашего класса ConverterTest следующим образом:

 <?php namespace SitePoint\Converter; use PHPUnit\Framework\TestCase; class ConverterTest extends TestCase { public function conversionSuccessfulProvider() { return [ [ '{"key":"value","key2":"value2"}', [ 'key' => 'value', 'key2' => 'value2', ], ], [ '{"key":"value","key2":"value2","some-array":[1,2,3,4,5]}', [ 'key' => 'value', 'key2' => 'value2', 'some-array' => [1, 2, 3, 4, 5], ], ], [ '{"key":"value","key2":"value2","some-array":[1,2,3,4,5],"new-object":{"key":"value","key2":"value2"}}', [ 'key' => 'value', 'key2' => 'value2', 'some-array' => [1, 2, 3, 4, 5], 'new-object' => [ 'key' => 'value', 'key2' => 'value2', ], ], ], [ '[{"key":"value","key2":"value2","some-array":[1,2,3,4,5],"new-object":{"key":"value","key2":"value2"}},{"key":"value","key2":"value2","some-array":[1,2,3,4,5],"new-object":{"key":"value","key2":"value2"}},{"key":"value","key2":"value2","some-array":[1,2,3,4,5],"new-object":{"key":"value","key2":"value2"}}]', [ [ 'key' => 'value', 'key2' => 'value2', 'some-array' => [1, 2, 3, 4, 5], 'new-object' => [ 'key' => 'value', 'key2' => 'value2', ], ], [ 'key' => 'value', 'key2' => 'value2', 'some-array' => [1, 2, 3, 4, 5], 'new-object' => [ 'key' => 'value', 'key2' => 'value2', ], ], [ 'key' => 'value', 'key2' => 'value2', 'some-array' => [1, 2, 3, 4, 5], 'new-object' => [ 'key' => 'value', 'key2' => 'value2', ], ], ], ], ]; } /** * @param $input * @param $output * @dataProvider conversionSuccessfulProvider */ public function testStringConversionSuccess($input, $output) { $converter = new \SitePoint\Converter\Converter(); $this->assertEquals($output, $converter->convertString($input)); } } 

tests/SitePoint/Converter/ConverterTest.php

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

Затем мы объединили тестовые функции в одну с более общим именем, что указывает на то, что ожидается: testStringConversionSuccess . Этот метод теста принимает два аргумента: вход и выход. Остальная логика идентична той, что была раньше. Кроме того, чтобы убедиться, что метод использует провайдер данных, мы объявляем провайдера в @dataProvider conversionSuccessfulProvider метода с помощью @dataProvider conversionSuccessfulProvider .

Это все, что нужно сделать — мы теперь получаем точно такой же результат.

Сюита еще проезжает, но теперь с провайдером данных за кадром

Если мы теперь хотим добавить больше тестовых случаев, нам нужно только добавить больше пар ввода-вывода в провайдера. Не нужно придумывать новые имена методов или повторять логику. Удобно, правда?

Введение в покрытие кода

Прежде чем мы подпишем эту часть и позволим вам освоить все, что мы уже рассмотрели, давайте кратко обсудим покрытие кода.

Покрытие кода — это показатель, показывающий, насколько наш код покрыт тестами. Если в нашем классе есть два метода, но в тестах тестируется только один, то охват кода составляет не более 50% — в зависимости от того, сколько логических вилок (IF, переключателей, циклов и т. Д.) Имеют методы (каждый форк) должен быть покрыт отдельным тестом). PHPUnit имеет возможность генерировать отчеты о покрытии кода автоматически после запуска данного набора тестов.

Давайте быстро настроим это. Мы расширим phpunit.xml , добавив phpunit.xml <logging> и <filter> как элементы непосредственно внутри <phpunit> , так же как элементы уровня 1 (если <phpunit> имеет уровень 0 или root):

 <phpunit ...> <filter> <whitelist> <directory suffix=".php">src/</directory> </whitelist> </filter> <logging> <log type="tap" target="tests/build/report.tap"/> <log type="junit" target="tests/build/report.junit.xml"/> <log type="coverage-html" target="tests/build/coverage" charset="UTF-8" yui="true" highlight="true"/> <log type="coverage-text" target="tests/build/coverage.txt"/> <log type="coverage-clover" target="tests/build/logs/clover.xml"/> </logging> 

Фильтр устанавливает белый список, сообщающий PHPUnit, на какие файлы обращать внимание при тестировании. Этот переводит для всех файлов .php внутри / src, на любом уровне . Ведение журнала сообщает PHPUnit, какие отчеты нужно генерировать — различные инструменты могут читать различные отчеты, поэтому не повредит генерировать больше форматов, чем может потребоваться. В нашем случае нас действительно просто интересует HTML.

Прежде чем это сработает, нам нужно активировать XDebug, так как это расширение PHP, которое PHPUnit использует для проверки классов, через которые он проходит. Homestead Improved поставляется с инструментом phpenmod для активации и деактивации расширений PHP на лету:

 sudo phpenmod xdebug 

Если вы не используете HI, следуйте инструкциям по установке XDebug, относящимся к вашему дистрибутиву ОС. Эта статья должна помочь.

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

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

Код покрытия файлов в дереве каталогов

Давайте откроем файл index.html в браузере. Прямое перетаскивание в любой современный браузер должно работать просто отлично — не нужно виртуальных хостов или работающих серверов — это просто статический файл.

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

Тестирование приборной панели

Конвертер класса покрытия

Подсказка по методу convertString

Мы углубимся в охват кода в следующем посте по мере дальнейшего развития нашего инструмента.

Вывод

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

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

В последующем посте мы рассмотрим некоторые промежуточные методы и продолжим разработку нашего приложения.

Пожалуйста, оставьте свои комментарии и вопросы ниже!