Основы модульного тестирования на PHP
После создания кода PHP следующий этап — тестирование. Единственный способ убедиться, что код продолжает работать и не сломается - протестировать его. Существует несколько различных общепринятых способов написания и запуска тестов, каждый из которых имеет свои цели и причины их работы.
- Модульные тесты: тестирование кода отдельно от остальной кодовой базы.
- Интеграционное тестирование: тестирование кода, чтобы убедиться, что он работает должным образом с другими модулями.
- Функциональное тестирование: одновременное тестирование более значительных частей связанных систем.
Модульное тестирование
Начнем с того, что сосредоточимся на модульном тестировании. Модульное тестирование полезно для нескольких разных вещей. Первый тестирует написанный код. Во-вторых, это может помочь определить, имеет ли код смысл, прежде чем мы будем использовать код во многих разных местах. Я регулярно нахожу плохой выбор дизайна в своих классах, используя модульные тесты, потому что, если сложно написать тест, его трудно использовать в реальных ситуациях.
Модульное тестирование — это наименьший вид тестирования, который можно выполнить. Основная идея модульного теста состоит в том, чтобы взять небольшой блок кода, например метод в классе, и протестировать его. Модульный тест гарантирует, что общая логика работает с различными входными данными, независимо от дополнительных реальных зависимостей, таких как база данных.
Рассмотрим блок кода, где у нас есть класс, который превращает строку в ЧПУ (удобный, понятный человеку формат)
<?php // Slugifier.php namespace MyApp; class Slugifier { public function convert(string $text): string { $text = strtolower($text); preg_match('/[a-z0-9 ]+/i', $text, $output); $text = str_replace(' ', '-', $output[0]); return $text; } }Если я передам строку методу convert, я ожидаю получить новую строку со строчными буквами, пробелы заменены дефисами и удалены все знаки препинания. Как мы можем проверить это?
Самый простой способ проверить это — создать файл, который может запускать тесты:
// Slugifier.test.php require_once 'Slugifier.php'; $slugifier = new \MyApp\Slugifier(); if ($slugifier->convert("This was some text!") !== "this-was-some-text") { throw \Exception('Text does not match!'); }Любой может запустить этот скрипт, чтобы убедиться, что метод Slugifier::convert() работает должным образом. Теперь мы можем изменить внутреннюю реализацию метода convert() всякий раз, когда нам нужно, и убедиться, что он не сломается. У нас есть небольшой тест, который проверяет, что convert() делает то, что мы от него ожидаем, и ни больше, ни меньше.
Затем мы можем дополнить этот сценарий и создать дополнительные тестовые сценарии для большей части нашей кодовой базы.
Что-нибудь из этого супер элегантно? Нет, но теперь любой может запустить тест с помощью php Slugifier.test.php, и это лучше, чем ничего. Отсюда вы можете написать средство запуска тестов, которое ищет в вашем исходном коде файлы *.test.php и может выполнять файлы, а также останавливаться при обнаружении ошибки.
Это очень близко к тому, что PHP Test Runner делает для самого основного языка PHP. Большая разница будет заключаться в том, что PHP-тесты (обычно обозначаемые расширением .phpt) проверяют вывод напрямую, а не обязательно условия отказа.
PHPUnit
Есть лучший и более приемлемый способ написания тестов. Самый простой способ, который я показал выше, работает, но в некоторых случаях он может не подходить. Во-первых, модульные тесты должны выполняться в полной изоляции. Код должен выполняться с чистого листа каждый раз в дополнение к тестированию небольших фрагментов кода. Приведенная выше настройка позволяет очень легко сломать это. Неэлегантное решение также не имеет возможности должным образом справиться с зависимостями.
К счастью, существует отличный инструмент PHPUnit, который существует с 2001 года. PHPUnit использует архитектуру xUnit, в свою очередь производную от SUnit Smalltalk. xUnit описывает, как группировать и выполнять тесты и как предоставлять результаты.
Установка PHPUnit
Установка PHPUnit так же проста, как и любая другая библиотека, использующая Composer, но она должна быть объявлена как зависимость разработки, а не как обычная зависимость. Таким образом, PHPUnit устанавливается только при разработке и может быть проигнорирован при установке зависимостей для производства. Мы можем использовать Composer как обычно и передать дополнительный флаг --dev:
$ composer require --dev phpunit/phpunit ^8.5Не устанавливайте PHPUnit на свои рабочие машины. Я собираюсь сказать вам то же самое, что написано в документации PHPUnit:
Если вы загрузите PHPUnit на веб-сервер, процесс развертывания будет нарушен. В более общем плане, если каталог вашего поставщика общедоступен на вашем веб-сервере, ваш процесс развертывания также нарушен.
Написание тестов
Написание тестов с помощью PHPUnit является простым и использует различные соглашения об именах для настройки. Вы определяете класс, который расширяет PHPUnit\Framework\TestCase, называете его [Class]Test и создаете общедоступные методы, начинающиеся со слова test. В нем есть несколько встроенных утверждений или методов для проверки достоверности данных, которые мы можем вызвать, чтобы убедиться, что код работает правильно.
Куда мы поместим этот код? PHPUnit ожидает, что все тесты будут жить в своей папке. В PHP типичное соглашение состоит в том, чтобы иметь каталог test/ в корне вашего проекта, а в нем следовать структуре папок, аналогичной другому исходному коду. Настоящие тестовые файлы — [Class]Test.php.
Мы можем преобразовать приведенный выше тест без особых затруднений
<?php // tests/MyApp/SlugifierTest.php use PHPUnit\Framework\TestCase; use MyApp\Slugifier; class SlugifierTest extends TestCase { public function testConvertWorksProperly() { $slugifier = new Slugifier(); $this->assertSame( "this-was-some-text", $slugifier->convert("This was some text!") ); } }Этот тест выглядит в целом так же, но разница в том, что наш тест теперь заключен в соглашение, которое ожидает PHPUnit. Поскольку теперь мы расширяем TestCase, мы можем использовать метод assertSame(), чтобы проверить, совпадают ли два значения. Мы передаем аргумент 1 как ожидаемую строку, а аргумент 2 - как результат нашего собственного метода convert().
PHPUnit обеспечивает быстрый доступ к различным типам утверждений, включая:
- assertSame(mixed $expected, mixed $actual): Убедитесь, что значения двух параметров совпадают.
- assertEqual(mixed $expected, mixed $actual): Убедитесь, что две вещи совпадают, например, что два объекта являются одним и тем же экземпляром.
- assertTrue(bool $condition) и assertFalse(bool $condition): убедитесь, что значения равны true или false.
- assertArrayHasKey(mixed $key, array $array): Убедитесь, что ключ массива существует.
- assertCount(int $expected, $haystack): Убедитесь, что $haystack имеет правильное количество.
$ vendor/bin/phpunit --bootstrap vendor/autoload.php tests/PHPUnit выполняет поиск в каталоге tests/ файлов PHP для выполнения и любых найденных тестов.
Анатомия хороших тестов
Написание теста - это одно, но написание хорошего теста - это совсем другое. Нужно сопротивляться желанию бросить кучу вещей внутрь метода и покончить с этим — у хорошего модульного теста есть несколько компонентов и идеалов, которые делают тест стоящим. То, что вы не хотите в конечном итоге получить, - это набор тестов, которые просто выполняют минимальный минимум или вообще ничего не тестируют.
Первая идея, которой я стараюсь придерживаться, заключается в том, что каждый тест проверяет одну идею и предпочтительно должен выполнять только одну операцию. Тест не должен проверять как извлечение данных, так и их сохранение; это должны быть два отдельных теста. Код ниже является примером плохого теста.
use PHPUnit\Framework\TestCase; use MyApp\Slugifier; use MyApp\PostService; class SlugifierTest extends TestCase { public function testConvertWorksProperly() { $slugifier = new Slugifier(); $posts = new PostService(); $post = $posts->find(1); $this->assertIsObject($post); $this->assertSame( $post->getSlug(), $slugifier->convert($post->getTitle()) ); } }Название этого теста говорит о том, что он проверяет, работает ли метод convert(). На самом деле этот тест заключается в том, чтобы попасть в базу данных, найти определенную запись, убедиться, что это объект, а затем использовать этот объект $post как часть теста. Выполнение всех этих операций выходит за рамки предполагаемой области модульного тестирования. Мы должны тестировать только одну операцию — в данном случае, чтобы строка превращалась в другую строку. Все остальное - слишком большая работа для теста. Мы должны разделить этот единственный тест по крайней мере на два полных теста:
- один для службы Slugifier,
- а другой для метода PostService::find().
Это не означает, что вы не можете иметь несколько утверждений для каждого теста. У вас должно быть одно логическое утверждение для каждого теста, но его можно разбить на несколько утверждений. Что я имею в виду? Мы могли бы, например, проверить, возвращает ли наш вымышленный класс PostService объект Post. Это наше “логическое” утверждение. Чтобы убедиться в правильности объекта Post, мы можем выполнить несколько небольших утверждений, которые помогут нам достичь нашего “логического” утверждения (код ниже).
use PHPUnit\Framework\TestCase; use MyApp\PostService; use MyApp\Post; class PostServiceTest extends TestCase { public function testPostLooksOKWhenRetrievedWithFind() { $id = 1; $service = new PostService(); $post = $service->find($id); $this->assertIsObject($post); $this->assertInstanceOf(Post::class, $post); $this->assertEqual($id, $post->getId()); $this->assertEqual('Why Testing Is Good', $post->getTitle()); } }Разница между этими идеями заключается в количестве логических утверждений. В плохом тесте мы выполнили несколько операций, прежде чем смогли запустить тесты, и даже частично протестировали объект Post. Во втором тесте мы выполнили одну операцию, нашли сообщение, а затем убедились, что сообщение выглядит правильно. У вас может быть несколько утверждений для каждого теста, но ограничьте количество операций для каждого теста.
Второе, о чем следует подумать, - это не только успешные операции, но и неудачные операции. Для нашего метода Post Service::find() мы убедились, что сможем найти сообщение. Что происходит, когда мы не можем найти сообщение? Должны ли мы это проверить? Да, мы должны! Если мы ожидаем, что будет выдано исключение, мы можем использовать $this->expectException(), чтобы указать, что мы ожидаем, что наш тест выдаст исключение, и что мы ожидаем, что это будет исключение определенного типа, см. следующий код.
use PHPUnit\Framework\TestCase; use MyApp\PostService; use MyApp\Post; use MyApp\PostNotFoundException; class PostServiceTest extends TestCase { public function testExceptionThrownWithBadPostID() { $this->expectException(PostNotFoundException::class); $service = new PostService(); $post = $service->find('asdf'); } }В-третьих, короткий модульный тест более удобочитаем и предпочтительнее длинного модульного теста. Если вы придерживаетесь одной операции для каждого теста, это должно значительно сократить количество материала внутри одного теста. Разработчик должен иметь возможность быстро просмотреть тест и понять, что происходит. Сложные настройки тестирования означают, что либо ваши тесты выполняют слишком много, либо ваши объекты слишком сложны и нуждаются в реструктуризации. Помните, что у каждого класса должна быть одна ответственность.
Обработка большого количества тестовых данных
Наш тестовый пример для нашего метода Slugifier::convert() довольно хорош, но что происходит, когда мы хотим, чтобы модульный тест содержал много данных? У PHPUnit есть то, что он называет “поставщиком данных”, который представляет собой функцию, возвращающую массив массивов, которые передаются в тест. Это особенно полезно, когда вам нужно протестировать множество различных перестановок данных, но один тест может обработать все эти перестановки.
Вы можете добавить аннотацию к Docblock теста с помощью @dataProvider [method], и PHPUnit передаст возвращаемые значения этого метода в ваш тест. Поставщик данных должен возвращать iterable, который выдает массив или массив массивов, причем внутренний массив является аргументами для вашего теста. Если мы расширим наш тест, чтобы принять строку $expected и строку $text, наш поставщик данных должен вернуть массив массивов из двух элементов, где элемент 0 является нашим ожидаемым результатом, а элемент 1 является нашим тестовым текстом для передачи.
// tests/MyApp/SlugifierTest.php use PHPUnit\Framework\TestCase; use MyApp\Slugifier; class SlugifierTest extends TestCase { /** * @dataProvider sluggableStringsProvider */ public function testConvertWorksProperly(string $expected, string $text) { $slugifier = new Slugifier(); $this->assertSame($expected, $slugifier->convert($text)); } public function sluggableStringsProvider() { return [ ['this-is-text', 'This is text'], ['this-is-text', 'This is text!'], ['this-is-text', 'THIS IS TEXT'], ['hello', 'h%$^&e@#l#$l^&o'], ]; } }Поставщики данных - отличное оружие против регрессионного тестирования. По мере того как пользователи и тестировщики сообщают об ошибках, их можно добавлять к поставщику данных, чтобы убедиться, что они никогда больше не будут ломаться.
Работа с зависимостями
Наш простой класс Slugifier не имеет зависимостей, но большая часть кода имеет некоторое взаимодействие между классами. Как я уже обсуждал в предыдущих статьях, это зависимости, и независимо от того, насколько хорошо вы структурируете свой код, у вас всегда есть некоторая связь — или взаимодействие — между различными классами.
Если у нас есть класс Invoice, для которого требуется какая-то зависимость на основе интерфейса Writer, которая генерирует фактический счет-фактуру, у нас есть зависимость.
class Invoice { protected $id; protected $writer; public function __construct(string $id, WriterInterface $writer) { $this->id = $id; $this->writer = $writer; } // ... public function generate(): string { return $this->writer->write($this->getData()); } }Модульные тесты, однако, тестируют вещи изолированно. Мы не должны создавать реальный объект writer для передачи, но мы должны создать поддельную версию, которую мы можем контролировать. Такая конструкция позволяет нам тестировать определенные условия для теста, не беспокоясь о настройке полного средства записи или о побочных эффектах от использования фактического объекта. Наиболее распространенная причина заключается в том, чтобы помочь контролировать выходные данные и поведение зависимости, чтобы убедиться, что все условия, как хорошие, так и плохие, выполняются.
PHPUnit может создавать поддельные объекты, которые правильно называются тестовыми двойниками. PHPUnit может создавать заглушки, которые являются тестовыми двойниками, возвращающими данные, или mocks, которые позволяют провести дополнительный анализ того, как мы вызываем test double. Например, давайте создадим stub WriterInterface, который возвращает предварительно заданную строку, как в коде ниже.
namespace MyAppTest; use PHPUnit\Framework\TestCase; use MyApp\Invoice; class InvoiceTest extends TestCase { public function testWrite() { $expected = 'This was output'; $writer = $this->createStub(\MyApp\WriterInterface::class); $writer->method('write') ->will($this->returnValue($expected)); $invoice = new Invoice('123-abc', $writer); $output = $invoice->generate(); $this->assertSame($expected, $output); } }Мы создаем заглушку, используя $this->createStub([Имя класса]), которая возвращает объект, которым мы можем начать манипулировать. Затем мы говорим ему добавить метод с именем write() и чтобы он всегда возвращал "This was output" всякий раз, когда он вызывается. Этот прямой контроль над возвращаемым значением заглушки позволяет нам видеть, как класс и метод, которые мы тестируем, Invoice:generate(), могут реагировать на разные значения. Вы даже можете заставить метод выдавать исключение для проверки обработки ошибок.
Если вам нужно больше контроля, макетная система PHPUnit позволяет вам проверять данные, поступающие в тестовый дубль. PHPUnit также имеет встроенную поддержку Prophecy. Prophecy чрезвычайно упрощает проверку данных, например, тело JSON, которое мы намереваемся отправить, поступает в наши тестовые дубли, чтобы убедиться, что мы правильно его форматируем. Более подробную информацию о создании заглушек и макетов можно найти в документации PHPUnit.
Есть еще кое-что
PHPUnit обладает большим, богатым набором функциональных возможностей, которые невозможно охватить в одной статье. Я только затронул основы написания модульных тестов, и есть другие типы тестов, которые мы должны изучить. Все вышесказанное должно дать вам хорошую основу для написания тестов. PHPUnit - это невероятно хорошо документированная библиотека.
Автор этого материала - я - Пахолков Юрий. Я оказываю услуги по написанию программ на языках Java, C++, C# (а также консультирую по ним) и созданию сайтов. Работаю с сайтами на CMS OpenCart, WordPress, ModX и самописными. Кроме этого, работаю напрямую с JavaScript, PHP, CSS, HTML - то есть могу доработать ваш сайт или помочь с веб-программированием. Пишите сюда.
Отправляя сообщение я подтверждаю, что ознакомлен и согласен с политикой конфиденциальности данного сайта.