Статьи

Как писать тестовые скрипты в стиле JavaScript на PHP

Эта статья была рецензирована Юнесом Рафи . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!


Я не начал писать тесты для своего кода. Как и многие до и после, мое «тестирование» заключалось в написании кода и обновлении страницы. «Это выглядит правильно?» – спрашиваю я себя. Если бы я так думал, я бы пошел дальше.

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

Векторный icon с глазом

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

Вот отличное видеоурок премиум-класса о тестовой разработке NodeJS!

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

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

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

Код для этого урока можно найти на Github . Я проверил это с PHP 7.1 .

Настройка проекта

С тех пор, как я начал работать над этими проектами, я начал настраивать аналогичную вещь для PHPUnit. Фактически, первым проектом, на котором я установил скрипт наблюдателя PHPUnit, был проект PHP, который также предварительно обрабатывает файлы.

Все началось после того, как я добавил сценарии предварительной обработки в свой проект:

 composer require pre/short-closures 

Эти конкретные сценарии предварительной обработки позволяют мне переименовывать классы автозагрузки PSR-4 (из path/to/file.phppath/to/file.pre ), чтобы path/to/file.pre их функциональность. Поэтому я добавил следующее в мой файл composer.json :

 "autoload": { "psr-4": { "App\\": "src" } }, "autoload-dev": { "psr-4": { "App\\Tests\\": "tests" } } 

Это из composer.json

Затем я добавил класс для генерации функций с подробностями текущего сеанса пользователя:

 namespace App; use Closure; class Session { private $user; public function __construct(array $user) { $this->user = $user; } public function closureWithUser(Closure $closure) { return () => { $closure($this->user); }; } } 

Это из src/Session.pre

Чтобы проверить, работает ли это, я настроил небольшой пример сценария:

 require_once __DIR__ . "/vendor/autoload.php"; $session = new App\Session(["id" => 1]); $closure = ($user) => { print "user: " . $user["id"] . PHP_EOL; }; $closureWithUser = $session->closureWithUser($closure); $closureWithUser(); 

Это из example.pre

… И поскольку я хочу использовать короткие замыкания в классе, отличном от PSR-4, мне также нужно настроить загрузчик:

 require_once __DIR__ . "/vendor/autoload.php"; Pre\Plugin\process(__DIR__ . "/example.pre"); 

Это из loader.php

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

Чтобы запустить все это, введите в терминал:

 php loader.php 

Как примечание, допустимый синтаксис PHP, сгенерированный этими препроцессорами, прекрасен. Это выглядит так:

 $closure = function ($user) { print "user: " . $user["id"] . PHP_EOL; }; 

…и

 public function closureWithUser(Closure $closure) { return [$closure = $closure ?? null, "fn" => function () use (&$closure) { $closure($this->user); }]["fn"]; } 

Вы, вероятно, не хотите фиксировать как php и pre файлы в репозиторий. .gitignore этой причине я добавил app/**/*.php и examples.php в .gitignore .

Настройка тестов

Итак, как мы можем это проверить? Начнем с установки PHPUnit:

 composer require --dev phpunit/phpunit 

Затем мы должны создать файл конфигурации:

 <?xml version="1.0" encoding="UTF-8"?> <phpunit backupGlobals="false" backupStaticAttributes="false" bootstrap="vendor/autoload.php" colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="false" processIsolation="false" stopOnFailure="false" syntaxCheck="false" > <testsuites> <testsuite> <directory suffix="Test.php">tests</directory> </testsuite> </testsuites> <filter> <whitelist addUncoveredFilesFromWhitelist="true"> <directory suffix=".php">src</directory> </whitelist> </filter> </phpunit> во <?xml version="1.0" encoding="UTF-8"?> <phpunit backupGlobals="false" backupStaticAttributes="false" bootstrap="vendor/autoload.php" colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="false" processIsolation="false" stopOnFailure="false" syntaxCheck="false" > <testsuites> <testsuite> <directory suffix="Test.php">tests</directory> </testsuite> </testsuites> <filter> <whitelist addUncoveredFilesFromWhitelist="true"> <directory suffix=".php">src</directory> </whitelist> </filter> </phpunit> во <?xml version="1.0" encoding="UTF-8"?> <phpunit backupGlobals="false" backupStaticAttributes="false" bootstrap="vendor/autoload.php" colors="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="false" processIsolation="false" stopOnFailure="false" syntaxCheck="false" > <testsuites> <testsuite> <directory suffix="Test.php">tests</directory> </testsuite> </testsuites> <filter> <whitelist addUncoveredFilesFromWhitelist="true"> <directory suffix=".php">src</directory> </whitelist> </filter> </phpunit> 

Это из phpunit.xml

Если бы мы запустили vendor/bin/phpunit , это бы сработало. Но у нас пока нет тестов. Давайте сделаем один:

 namespace App\Tests; use App\Session; use PHPUnit\Framework\TestCase; class SessionTest extends TestCase { public function testClosureIsDecorated() { $user = ["id" => 1]; $session = new Session($user); $expected = null; $closure = function($user) use (&$expected) { $expected = "user: " . $user["id"]; }; $closureWithUser = $session ->closureWithUser($closure); $closureWithUser(); $this->assertEquals("user: 1", $expected); } } 

Это из тестов / SessionTest.php

Когда мы запускаем vendor/bin/phpunit , одиночный тест проходит. Ура!

Что нам не хватает?

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

Проблемы начинаются, когда мы пытаемся проверить покрытие кода:

 vendor/bin/phpunit --coverage-html coverage 

Поскольку у нас есть тест на Session , покрытие будет сообщено. Это простой класс, поэтому у нас уже есть 100% охват. Но если мы добавим еще один класс:

 namespace App; class BlackBox { public function get($key) { return $GLOBALS[$key]; } } 

Это из src/BlackBox.pre

Что происходит, когда мы проверяем покрытие? Еще 100%.

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

Сборка всех файлов перед тестированием

Давайте создадим новый скрипт для сборки всех файлов Pre, прежде чем пытаться запустить тесты:

 require_once __DIR__ . "/../vendor/autoload.php"; function getFileIteratorFromPath($path) { return new RecursiveIteratorIterator( new RecursiveDirectoryIterator($path), RecursiveIteratorIterator::SELF_FIRST ); } function deleteFilesBeforeTests($path) { foreach (getFileIteratorFromPath($path) as $file) { if ($file->getExtension() === "php") { unlink($file->getPathname()); } } } function compileFilesBeforeTests($path) { foreach (getFileIteratorFromPath($path) as $file) { if ($file->getExtension() === "pre") { $pre = $file->getPathname(); $php = preg_replace("/pre$/", "php", $pre); Pre\Plugin\compile($pre, $php, true, true); print "."; } } } print "Building files" . PHP_EOL; deleteFilesBeforeTests(__DIR__ . "/../src"); compileFilesBeforeTests(__DIR__ . "/../src"); print PHP_EOL; 

Это из tests/bootstrap.php

Здесь мы создаем 3 функции; один для получения рекурсивного файлового итератора (из пути), один для удаления файлов этого итератора и один для повторной компиляции файлов Pre.

Нам нужно заменить текущий файл начальной загрузки в phpunit.xml :

 <phpunit bootstrap="tests/bootstrap.php" ... > 

Это из phpunit.xml

Теперь, когда мы запускаем тесты, этот скрипт сначала очищает и перестраивает все файлы Pre в файлы PHP. Об освещении сообщается правильно, и мы можем быть на нашем веселом пути …

За исключением этой другой вещи …

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

В этом проекте я упомянул, у меня есть 101 Pre файлов. Это большая предварительная обработка, чтобы запустить мой (надеюсь быстрый) набор модульных тестов. Нам нужен способ следить за изменениями и перестраивать только те кусочки, которые имеют значение. Для начала давайте установим наблюдатель файла:

 composer require --dev yosymfony/resource-watcher 

Затем давайте создадим тестовый скрипт:

 #!/usr/bin/env php <?php require_once __DIR__ . "/../tests/bootstrap.php"; use Symfony\Component\Finder\Finder; use Yosymfony\ResourceWatcher\ResourceWatcher; use Yosymfony\ResourceWatcher\ResourceCacheFile; $finder = new Finder(); $finder->files() ->name("*.pre") ->in([ __DIR__ . "/../src", __DIR__ . "/../tests", ]); $cache = new ResourceCacheFile( __DIR__ . "/.test-changes.php" ); $watcher = new ResourceWatcher($cache); $watcher->setFinder($finder); while (true) { $watcher->findChanges(); if ($watcher->hasChanges()) { // ...do some rebuilding } usleep(100000); } 

Это из scripts/watch-test

Скрипт создает искатель Symfony (для сканирования наших папок src и tests ). Мы определяем временный файл изменений, но он не является строго обязательным для того, что мы делаем. Мы продолжаем это бесконечным циклом. ResourceWatcher имеет метод, который мы можем использовать, чтобы увидеть, были ли какие-либо файлы созданы, изменены или удалены.

Новое, давайте найдем, какие файлы изменились, и перестроим их:

 if ($watcher->hasChanges()) { $resources = array_merge( $watcher->getNewResources(), $watcher->getDeletedResources(), $watcher->getUpdatedResources() ); foreach ($resources as $resource) { $pre = realpath($resource); $php = preg_replace("/pre$/", "php", $pre); print "Rebuilding {$pre}" . PHP_EOL; Pre\Plugin\compile($pre, $php, true, true); } // ...re-run tests } 

Это из scripts/watch-test

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

 if (empty(getenv("APP_COVER"))) { passthru("APP_REBUILD=0 composer run test"); } else { passthru("APP_REBUILD=0 composer run test:coverage"); } 

Это из scripts/watch-test

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

 "scripts": { "test": "vendor/bin/phpunit", "test:cover": "vendor/bin/phpunit --coverage-html cover", "watch:test": "APP_COVER=0 scripts/watch-test", "watch:test:cover": "APP_COVER=1 scripts/watch-test", }, 

Это из composer.json

APP_COVER не так уж и важен. Это просто говорит сценарию наблюдателя, включать ли покрытие кода. APP_REBUILD играет более важную роль: он контролирует, перестраиваются ли файлы Pre при tests/bootstrap.php файла tests/bootstrap.php . Нам нужно изменить этот файл, чтобы файлы перестраивались только по запросу:

 if (!empty(getenv("APP_REBUILD"))) { print "Building files" . PHP_EOL; deleteFilesBeforeTests(__DIR__ . "/../src"); compileFilesBeforeTests(__DIR__ . "/../src"); print PHP_EOL; } 

Это из tests/bootstrap.php

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

 #!/usr/bin/env php <?php putenv("APP_REBUILD=1"); require_once __DIR__ . "/../tests/bootstrap.php"; use Symfony\Component\Finder\Finder; use Yosymfony\ResourceWatcher\ResourceWatcher; use Yosymfony\ResourceWatcher\ResourceCacheFile; $finder = new Finder(); $finder->files() ->name("*.pre") ->in([ __DIR__ . "/../src", __DIR__ . "/../tests", ]); $cache = new ResourceCacheFile( __DIR__ . "/.test-changes.php" ); $watcher = new ResourceWatcher($cache); $watcher->setFinder($finder); while (true) { $watcher->findChanges(); if ($watcher->hasChanges()) { $resources = array_merge( $watcher->getNewResources(), $watcher->getDeletedResources(), $watcher->getUpdatedResources() ); foreach ($resources as $resource) { $pre = realpath($resource); $php = preg_replace("/pre$/", "php", $pre); print "Rebuilding {$pre}" . PHP_EOL; Pre\Plugin\compile($pre, $php, true, true); } if (empty(getenv("APP_COVER"))) { passthru("APP_REBUILD=0 composer run test"); } else { passthru("APP_REBUILD=0 composer run test:cover"); } } usleep(100000); } 

Это из scripts/watch-test

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

https://www.sitepoint.com/wp-content/uploads/2017/07/1500534190watcher.gif

Есть несколько вещей, которые нужно иметь в виду (rawr). Во-первых, вам понадобится chmod +x scripts/* чтобы иметь возможность запускать скрипт watcher. Во-вторых, вам нужно установить config: {process-timeout: 0}composer.json ), иначе наблюдатель умрет через 300 секунд.

Бонусный раунд!

Этот наблюдатель за тестированием также дает отличный побочный эффект: возможность использовать препроцессоры / преобразования в наших тестах PHPUnit. Если мы добавим немного кода в tests/bootstrap.php :

 if (!empty(getenv("APP_REBUILD"))) { print "Building files" . PHP_EOL; deleteFilesBeforeTests(__DIR__ . "/../src"); compileFilesBeforeTests(__DIR__ . "/../src"); deleteFilesBeforeTests(__DIR__ . "/../tests"); compileFilesBeforeTests(__DIR__ . "/../tests"); print PHP_EOL; } 

Это из tests/bootstrap.php

… И мы включаем предварительную обработку в наших тестовых файлах (для Pre это означает переименование их в .pre ). Тогда мы можем начать использовать те же препроцессоры в наших тестовых файлах:

 namespace App\Tests; use App\Session; use PHPUnit\Framework\TestCase; class SessionTest extends TestCase { public function testClosureIsDecorated() { $user = ["id" => 1]; $session = new Session($user); $expected = null; $closure = ($user) => { $expected = "user: " . $user["id"]; }; $closureWithUser = $session ->closureWithUser($closure); $closureWithUser(); $this->assertEquals("user: 1", $expected); } } 

Это из tests/SessionTest.pre

Вывод

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

Этот подход хорошо сработал для вас? Он может быть адаптирован для работы с асинхронными HTTP-серверами или другими длительными процессами. Дайте нам знать, что вы думаете в комментариях.