Подписывайся на Telegram-канал

Пример unit-тестирования на php

Пример unit-тестирования на php

https://t.me/it_programmist

Мы уже познакомились с тестированием. Узнали о видах и даже разобрались что к чему? Но это всё была теория. Без примеров всё это не то.

Предыдущие статьи:

https://mynrg.ru/zachem-nuzhno-testirovanie

https://mynrg.ru/testirovanie-vidy

Часто привожу и буду приводить примеры на php, потому что язык прост и понятен. И поняв что-то на php, перенести это на другой язык не составит труда.

Я уже касался TDD - Test Driven Development.

Что такое Test Driven Development?

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

Что такое PHPUnit?

PHPUnit - это фреймворк для тестирования на php.

Это набор утилит (классы PHP и исполняемые файлы), который не только упрощает сложный процесс создания тестов (частое написание тестов влечет за собой написание большего количества кода, чем на самом деле оно того стоит), но также позволяет видеть процесс тестирования в наглядном виде и позволяет узнать

  • о качестве кода (например, возможно, в классе слишком много условий IF), что отмечено как плохое качество, поскольку для изменения одного условия часто требуется переписывать столько тестов, сколько есть IF)
  • охват кода (сколько данного класса или функции были покрыты тестами и сколько осталось непроверенными)
  • и ещё много всего интересного

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

Каркас приложения

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

Да там понятна суть тестирования, синтаксис и инструменты, но в таком виде тяжело понять зачем оно надо. Хотя у меня и есть на эту тему целая статья.

Для того чтобы воссоздать структуру реального php-приложения возьмём https://github.com/php-pds/skeleton.

Пойдём в папку с нашими проектами и склонируем этот каркас:

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

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

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

<?php
echo "Hello world";

Если ты повторяешь все эти действия, то я предполагаю, что ты знаешь php, ООП, composer и как установить и запустить php(хотя бы у себя на компе, хотя бы используя denwer).

Открыв проект в браузере, мы должны увидеть

Нужно удалить лишние файлы. Это можно сделать либо руками, либо из командной строки:

rm -rf bin/* src/* docs/* tests/*

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

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

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

<directory suffix="Test.php">tests</directory>

В phpunit.xmlмы определили аргумент для тэга directory - suffix - это означает, что PHPUnit будет запускать только те файлы, которые заканчиваются Test.php. Полезно, когда мы хотим использовать и другие файлы из папки tests, но не хотим, чтобы они запускались автоматически. Например вспомогательные файлы для тестов.

Прочитать о других аргументах для конфигурации можно здесь .

Значение аругмента bootstrap указывает PHPUnit , какой файл PHP загрузить перед тестированием. Это полезно при настройке параметров автоматической загрузки или загрузки тестовых переменных, даже тестовой базы данных и т. д. То есть убрать, то что не нужно для тестов, а нужно только на продакшене или добавить то, что не нужно на продакшене, но нужно здесь.

Давайте создадим tests/autoload.php:

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

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

{
"name": "mynrg.ru/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": {
"MyNrg\\": "src/MyNrg"
}
},
"autoload-dev": {
"psr-4": {
"MyNrg\\": "tests/MyNrg"
}
},
"bin": ["bin/converter"],
"require-dev": {
"phpunit/phpunit": "^6.2"
}
}

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

composer du

Первый тест

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

tests/MyNrg/Converter/ConverterTest.php

<?php

namespace MyNrg\Converter;

use PHPUnit\Framework\TestCase;

class ConverterTest extends TestCase {

 public function testHello() {
  $this->assertEquals('Hello', 'Hell' . 'o');
 }

}

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

В этом примере «тестовый пример» утверждается, что строка Hello равна конкатенации Hell и o

Если  выполнить:

php vendor/bin/phpunit

запустится тест и мы получим положительный результат.

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

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

<?php

namespace MyNrg\Converter;
use PHPUnit\Framework\TestCase;

class ConverterTest extends TestCase
{

 public function testSimpleConversion()
 {
  $input = '{"key":"value","key2":"value2"}';
  $output = [
   'key' => 'value',
   'key2' => 'value2'
  ];
  $converter = new \MyNrg\Converter\Converter();
  $this->assertEquals($output, $converter->convertString($input));
 }
}
}

Мы тестируем простое преобразование(testSimpleConversion). Входные данные $input представляют собой JSON объект, который приведён к типу строки (JSON.stringify в JS), а ожидаемый вывод($output) - это массив PHP.  Наш тест утверждает , что наш класс конвертер, при обработке с $inputиспользованием convertStringметода, дает желаемое $output, так же , как это определено здесь.

Если мы сейчас запустим тест, то, конечно же, будет ошибка.

 Естественно, класс же еще не существует.

Отредактируем phpunit.xml файл, добавив к phpunitатрибут colors="true":

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

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

Создание тестового прохода

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

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

Создадим src/MyNrg/Converter/Converter.php:

<?php

namespace MyNrg\Converter;

class Converter
{

}

Теперь, если мы перезапустим тест

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

<?php

namespace MyNrg\Converter;

class Converter
{
  public function convertString(string $input): ?array
  {

  }
}

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

Перезапустим тест

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

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

Ну, вроде, всё должно работать, разве нет?

Функция возвращает объект, а не массив. Ах, ха! Это потому, что мы не активировали режим «ассоциативного массива» для функции json_decode.

Функция превращает объект JSON в экземпляр класса stdClassпо умолчанию, если не указано иное. Изменим это так:

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

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

Давайте добавим еще несколько тестовых примеров, чтобы убедиться, что наш метод действительно работает по назначению. Давайте сделаем это немного сложнее, чем простой пример, с которого мы начали. Добавьте следующие методы 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 \MyNrg\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 \MyNrg\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 \MyNrg\Converter\Converter();
$this->assertEquals($output, $converter->convertString($input));
}

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

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

Data Providers

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

<?php

namespace MyNrg\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 \MyNrg\Converter\Converter();
  $this->assertEquals($output, $converter->convertString($input));
 }

}

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

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

Вот и все. И, запустив, мы получаем тот же результат.

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

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

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

Давайте быстро это настроим. Мы будем расширять phpunit.xml, добавляя тэги и  как элементы сразу внутри , так что они получаются элементами первого уровня (если принять, что это коневой элемент и его уровень 0):

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

Тэг filter настраивает белый список, указывающий PHPUnit, на какие файлы обратить внимание при тестировании. У нас будут проверяться все .php-файлы внутри папки /src на любом уровне .

Logging  сообщает PHPUnit какие отчёт(логи) генерировать - различные инструменты могут читать различные логи, поэтому ничего страшного, что мы создаём больше форматов, чем может потребоваться. В нашем случае нас действительно интересует только HTML.

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

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

Давайте откроем index.htmlфайл в браузере. 

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

Мы поняли принцип TDD и внедрили тесты на простейшем php-приложении.

Ещё