Юнит-тесты для WordPress с unitest-wp-copy
Чтобы проверить небольшой класс плагина, не всегда нужно устанавливать WordPress и поднимать тестовую базу данных. Если код очищает строки, форматирует HTML, парсит блоки или обрабатывает массивы, его можно тестировать отдельно от сайта.
Проблема возникает, когда такой класс использует функции WordPress: обычный PHPUnit ничего не знает о sanitize_text_field(), wpautop() или esc_html().
Пакет doiftrue/unitest-wp-copy решает эту задачу: загружает выбранные функции и классы WordPress без полного запуска CMS. Вместе с ним можно использовать WP_Mock, чтобы задавать нужное поведение в отдельных тест-кейсах.
В этой статье настроим тестовое окружение с нуля и проверим класс, который формирует HTML-карточку.
Сравнение с другими пакетами
WP_Mock и Brain Monkey полезны для проверки того, что код вызвал функцию WordPress с ожидаемыми аргументами. Они заменяют эту функцию тестовым двойником, поэтому не проверяют её реальное поведение в WordPress.
Unitest WP Copy использует оригинальные функции и классы WordPress. Ему не нужны установка WordPress, база данных или веб-сервер. В тестах можно использовать настоящую очистку и экранирование данных, разбор блоков, реестры ресурсов, хуки и обработку REST-запросов. См. примеры ниже.
-
WP_Mock или Brain Monkey - проверяют вызовы и позволяют подменять взаимодействие с WordPress.
-
Unitest WP Copy - проверяет результаты работы отдельных частей оригинального кода WordPress.
- WordPress PHPUnit Test Suite - позволяет тестировать код, которому нужны база данных, файловая система или полный жизненный цикл WordPress.
Эти инструменты можно использовать вместе: выполнять реальную логику там, где это возможно, подменять внешние зависимости и использовать интеграционные тесты, когда требуется полноценная установка.
Что будем тестировать
Юнит-тест проверяет небольшой участок кода: передаёт ему данные, вызывает функцию или метод и сравнивает результат с ожидаемым.
Например, метод получает заголовок с HTML-разметкой:
<b>Weekly</b> update
А тест проверяет, что после очистки и обработки результат должен выглядеть так:
<h2>Weekly update</h2>
Для такого сценария НЕ нужны запросы к БД, активная тема, плагины или запрос к сайту.
Для этого будем использовать три инструмента:
- PHPUnit - Ядро тестов.
- unitest-wp-copy - Предоставляет большой набор функций и классов WordPress в их исходном виде.
- WP_Mock - Позволяет удобно мокать php функции.
Читайте документацию unitest-wp-copy
Подготовка проекта
Как и в исходном руководстве, используем линейку пакета для WordPress 7.1 и PHPUnit 9.6.
Для локального запуска понадобятся PHP и Composer. Ниже также будет вариант запуска через Docker и Make.
Создадим плагин в папке content-card. В нём будут следующие файлы:
content-card/ ├── content-card.php # Главный файл плагина ├── src/ │ └── ContentCard.php # Класс, который будем тестировать ├── composer.json # Зависимости и автозагрузка ├── phpunit.xml # Настройки PHPUnit ├── tests/ │ ├── bootstrap.php # Подготовка тестового окружения │ └── ContentCardTest.php # Тесты класса ├── .gitignore # Исключения для Git └── Makefile # Необязательные команды для Docker
Все команды далее выполняются из каталога плагина content-card.
Код, который будем проверять
src/ContentCard.php:
<?php
namespace Example\ContentCard;
final class ContentCard {
public function render( string $title, string $content ): string {
$title = sanitize_text_field( $title );
$body = wpautop( make_clickable( wp_kses_post( $content ) ) );
$class = is_multisite()
? 'content-card content-card--network'
: 'content-card';
return strtr(
'<article class="{class}">
<h2>{title}</h2>
<div class="content-card__body">{body}</div>
</article>',
[
'{class}' => esc_attr( $class ),
'{title}' => esc_html( $title ),
'{body}' => $body,
]
);
}
}
Метод render():
- Очищает заголовок через sanitize_text_field().
- Удаляет недопустимые HTML-теги из содержимого через wp_kses_post().
- Превращает текстовые URL в ссылки через make_clickable().
- Добавляет абзацы через wpautop().
- Добавляет CSS-класс
content-card--networkв режиме Multisite. - Экранирует заголовок и значение атрибута
classпри сборке HTML.
Именно это поведение мы и проверим.
Все используемые функции есть в списке функций и классов unitest-wp-copy.
Главный файл content-card.php:
<?php /** * Plugin Name: Content Card */ namespace Example\ContentCard; defined( 'ABSPATH' ) || exit; require_once __DIR__ . '/vendor/autoload.php';
Этот файл нужен лишь для полной картины. Для тестирования класса активировать плагин на сайте не требуется.
Установка зависимостей и автозагрузка
composer.json:
{
"name": "example/content-card",
"description": "Example WordPress plugin with isolated unit tests.",
"type": "wordpress-plugin",
"require": {
"php": ">=8.1"
},
"require-dev": {
"doiftrue/unitest-wp-copy": "7.1.*",
"phpunit/phpunit": "^9.6",
"10up/wp_mock": "*"
},
"autoload": {
"psr-4": {
"Example\\ContentCard\\": "src/"
}
},
"scripts": {
"phpunit": "phpunit"
}
}
Выполним установку:
composer install
Composer создаст каталог vendor, файл автозагрузки vendor/autoload.php и composer.lock (при первой установке).
Пояснения к composer.json
В разделе require-dev находятся зависимости для разработки и тестов. Подключать unitest-wp-copy на рабочем сайте не нужно.
Секция autoload связывает пространство имён Example\ContentCard\ с каталогом src/. Благодаря этому плагин автоматически сможет загрузить класс Example\ContentCard\ContentCard из файла src/ContentCard.php когда класс будет вызван в коде.
Как работают версии doiftrue/unitest-wp-copy
У пакета есть отдельные линейки для разных версий WordPress. Например, ограничение 7.1.* выбирает линейку для WordPress 7.1, а 6.9.* - для WordPress 6.9.
О формате версий описано в документации пакета.
Также читайте про то как работает версионность в composer.json.
Настройка PHPUnit
phpunit.xml:
<?xml version="1.0" encoding="UTF-8"?> <phpunit bootstrap="tests/bootstrap.php" colors="true" cacheResultFile=".phpunit.cache/test-results" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd" > <testsuites> <testsuite name="Content Card"> <directory suffix="Test.php">tests</directory> </testsuite> </testsuites> </phpunit>
Здесь указано, что PHPUnit должен:
tests/bootstrap.phpбудет запускатсья перед запуском тестового окружения.- Искать тесты в каталоге
tests, в файлах с окончаниемTest.php. - Сохранять кеш результатов в
.phpunit.cache.
Добавим .gitignore:
/vendor/ /.phpunit.cache/ /tmp/
Тестовое окружение
tests/bootstrap.php:
<?php require_once dirname( __DIR__ ) . '/vendor/autoload.php'; define( 'WP_ENVIRONMENT_TYPE', 'development' ); define( 'WP_DEBUG', true ); \Unitest_WP_Copy\WP_Runtime::boot(); \WP_Mock::bootstrap();
Сначала автозагрузчик Composer. Затем задаются константы, загружается окружение unitest-wp-copy и запускается WP_Mock.
Порядок важен:
WP_Runtime::boot() должен вызываться до WP_Mock::bootstrap(). Это нужно чтобы WP_Mock не объявил заранее функции, которые объявляет unitest-wp-copy. Читайте про возможность замены.
Константы, влияющие на подготовку окружения, задавайте до WP_Runtime::boot(). Подробнее об этом рассказано в разделах Runtime и Constants.
Загружать wp-load.php здесь не нужно. Мы также не подключаем главный файл плагина: Composer загрузит тестируемый класс при первом обращении к нему.
Первые два теста
Создадим tests/ContentCardTest.php:
<?php
namespace Example\ContentCard\Tests;
use Example\ContentCard\ContentCard;
use WP_Mock\Tools\TestCase;
final class ContentCardTest extends TestCase {
public function test__render__content_with_real_wordpress_formatting(): void {
$html = ( new ContentCard() )->render(
' <b>Weekly</b> update ',
'Visit https://example.com <script>alert(1)</script> <strong>today</strong>'
);
self::assertStringContainsString( '<h2>Weekly update</h2>', $html );
self::assertStringContainsString( '<a href="https://example.com"', $html );
self::assertStringContainsString( '<strong>today</strong>', $html );
self::assertStringNotContainsString( '<script>', $html );
self::assertStringContainsString( 'class="content-card"', $html );
}
public function test__render__adds_network_class_on_multisite(): void {
\WP_Mock::userFunction( 'is_multisite' )->andReturn( true );
$html = ( new ContentCard() )->render( 'Network news', 'Shared content' );
self::assertStringContainsString(
'class="content-card content-card--network"',
$html
);
}
}
Описание того что происходит:
-
Класс наследуется от
WP_Mock\Tools\TestCase. Этот базовый класс выполняет подготовку и очистку WP_Mock между тестами. -
Методы тестов начинаются с
test_, поэтому PHPUnit распознаёт их как тестовые. -
Тест-кейс 1: Проверка реального форматирования
Первый тест вызывает
render()и проверяет несколько свойств результата:- В заголовке нет тега
<b>и лишних пробелов. - Текстовый URL превратился в ссылку.
- Разрешённый тег
<strong>сохранился. - Тег
<script>удалён. - Используется обычный CSS-класс карточки.
assertStringContainsString()проверяет наличие фрагмента, аassertStringNotContainsString()- его отсутствие. Такая проверка подходит, когда нас интересует конкретное свойство результата, а не точное совпадение всей HTML-строки.Здесь выполняются реализации функций форматирования из unitest-wp-copy. Мы не мокаем поведение
wp_kses_post()илиmake_clickable()(как это обычно делается в юнит тестах), поэтому тест отрабатывает точнотакже как это делает WP и способен заметить ошибку в том, как наш метод использует эти функции. - В заголовке нет тега
-
Тест-кейс 2: Проверяем ветку Multisite
Во втором тест-кейсе перед вызовом метода задаётся подмена (мок):
\WP_Mock::userFunction( 'is_multisite' )->andReturn( true );
Для этого теста вызов
is_multisite()вернётtrue. Так мы проверяем ветку с дополнительным CSS-классом без установки настоящей сети сайтов.Это и есть роль подмены в данном примере: задать условие окружения, от которого зависит поведение нашего класса. Остальная обработка содержимого остаётся прежней.
В unitest-wp-copy через WP_Mock можно подменять только функции, отмеченные как mockable.
Общие правила подмен описаны в документации.
Запуск тестов
Выполним в терминале в папке плагина (проекта):
composer run phpunit // или vendor/bin/phpunit
При успешном выполнении PHPUnit выведет OK и количество тестов и проверок.
Чтобы запустить только тест ветки Multisite:
composer run phpunit -- --filter test_adds_network_class_on_multisite vendor/bin/phpunit --filter test_adds_network_class_on_multisite
Запуск через Docker и Make
В исходном руководстве unitest-wp-copy команды запускаются в контейнере Composer. Для нашего примера достаточно такого Makefile:
define php_run @mkdir -p "$(CURDIR)/tmp/composer-cache" docker run --rm --user "$$(id -u):$$(id -g)" \ -v "$(CURDIR):/app" -w /app \ -v "$(CURDIR)/tmp/composer-cache:/tmp/composer-cache" \ -e COMPOSER_CACHE_DIR=/tmp/composer-cache \ composer:2 sh -c "$(1)" endef composer.install: $(call php_run,composer install) phpunit: $(call php_run,composer run phpunit -- --colors=always)
Теперь можем использовать команды:
make composer.install make phpunit
Этот вариант удобен тем что все будет работать внутри докер контейнера и поэтмоу не нужны установленные PHP в системе. Он подходит только под Linux, macOS или WSL (windows).
Заметки по расширению тестов
У unitest-wp-copy есть ограничения. Не все функции WordPress доступны в тестовом окружении.
Перед использованием:
- Найдите функцию или класс в SYMBOLS-INFO.md.
- Используйте
WP_Mock, когда нужно управлять поведением функции (она должна быть mockable). - Восстанавливайте изменённое состояние между тестами.
- Используйте другой подход, если проверка требует настоящей базы данных или полного запуска WordPress.
AI инструкция
Добавьте слудующую инструкцию в AGENTS.md файл, чтобы ИИ знал что для юнит-тестов есть своя среда WP:
### Tests Runtime This project uses `doiftrue/unitest-wp-copy` with `WP_Mock` for PHPUnit tests. Before writing or changing tests: 1. Read `vendor/doiftrue/unitest-wp-copy/README.md` to understand the test runtime. 2. Check `vendor/doiftrue/unitest-wp-copy/SYMBOLS-INFO.md` for the WordPress functions and classes available in the runtime. Its first section lists runtime-adapted classes (like `\Unitest_WP_Copy\wpdb__Runtime`) with their public methods — use or extend them instead of WP_Mock. 3. Use `WP_Mock` when a runtime function listed as mockable needs to be mocked.
Опции и общее состояние
В unitest-wp-copy опции хранятся в памяти. Для установки значения предусмотрен WP_Options::set():
\Unitest_WP_Copy\WP_Options::set( 'my_plugin_title', 'Test title' ); self::assertSame( 'Test title', get_option( 'my_plugin_title' ) );
Этот фрагмент можно использовать внутри тестового метода после подготовки окружения.
Если тесты изменяют опции, сохраняйте состояние через WP_Options::save_state() в setUp() и восстанавливайте через WP_Options::restore_state() в tearDown(). При переопределении этих методов сохраняйте вызовы родительских методов; при наследовании от WP_Mock\Tools\TestCase используйте публичную видимость методов, как у родителя.
Хранилище опций имеет приоритет перед подменами WP_Mock: если опция уже есть в хранилище, менять её следует через WP_Options::set(). Порядок получения значений описан в разделе Options.
Сохранение опций не сохраняет автоматически константы, хуки и все глобальные переменные. Если тест меняет их, очистку или восстановление нужно предусмотреть отдельно. Повторный вызов WP_Runtime::boot() также не создаёт новое окружение: оно инициализируется один раз на PHP-процесс.
Когда нужен полноценный WordPress
Если нужно убедиться, что запись действительно сохраняется в базу, плагин правильно активируется или код взаимодействует с активной темой, понадобится интеграционное тестирование в полноценном окружении WordPress.
Подмена функции может проверить реакцию вашего класса на заданный результат. Она не подтверждает, что реальная база данных, файловая система или другой плагин поведут себя именно так.
Эта граница подробно описана в ограничениях unitest-wp-copy.
Еще примеры использования
Следующие примеры показывают, что можно тестировать с помощью Unitest WP Copy.
Пример 1: Проверка очистки, экранирования и защиты от XSS
Приложение выводит данные с помощью функций WordPress:
function render_profile_card( string $name, string $bio, string $url ): string {
return sprintf(
'<article data-user="%s"><h2>%s</h2><div>%s</div><a href="%s">Website</a></article>',
esc_attr( sanitize_key( $name ) ),
esc_html( sanitize_text_field( $name ) ),
wp_kses_post( $bio ),
esc_url( $url )
);
}
Тест проверяет итоговый HTML, а не только вызовы функций. Здесь проверяется реальная работа очистки данных, KSES, экранирования и фильтрации протоколов URL:
public function test_renders_safe_profile_card(): void {
$html = render_profile_card(
' Alice <script>alert(1)</script> ',
'<strong>Developer</strong><script>alert(2)</script>',
'javascript:alert(3)'
);
self::assertStringContainsString( '<h2>Alice</h2>', $html );
self::assertStringContainsString( '<strong>Developer</strong>', $html );
self::assertStringNotContainsString( '<script', $html );
self::assertStringNotContainsString( 'javascript:', $html );
}
Пример 2: Проверка разметки блоков без редактора блоков
Приложение считывает структуру блока с выделенным сообщением:
function get_callout_structure( string $content ): array {
$group = parse_blocks( $content )[0];
return [
'name' => $group['blockName'],
'class_name' => $group['attrs']['className'] ?? '',
'children' => array_column( $group['innerBlocks'], 'blockName' ),
];
}
Тест проверяет имена, атрибуты и вложенность, которые возвращает парсер блоков WordPress:
public function test_reads_the_callout_structure(): void {
$structure = get_callout_structure( <<<'HTML'
<!-- wp:group {"className":"callout"} -->
<!-- wp:heading {"level":3} --><h3>Important</h3><!-- /wp:heading -->
<!-- wp:paragraph --><p>Read this first.</p><!-- /wp:paragraph -->
<!-- /wp:group -->
HTML
);
self::assertSame( 'core/group', $structure['name'] );
self::assertSame( 'callout', $structure['class_name'] );
self::assertSame( [ 'core/heading', 'core/paragraph' ], $structure['children'] );
}
Это полезно при миграции, преобразовании и проверке блоков.
Пример 3: Проверка регистрации ресурсов и состояния очереди
Приложение регистрирует скрипт редактора и добавляет его в очередь:
function enqueue_editor_assets(): void {
wp_register_script(
'acme-editor',
'/assets/editor.js',
[ 'wp-element', 'wp-i18n' ],
'1.4.0',
[ 'in_footer' => true, 'strategy' => 'defer' ]
);
wp_enqueue_script( 'acme-editor' );
}
Тест проверяет реестр скриптов WordPress в памяти:
public function test_registers_the_editor_script(): void {
enqueue_editor_assets();
$script = wp_scripts()->registered['acme-editor'];
self::assertTrue( wp_script_is( 'acme-editor', 'registered' ) );
self::assertTrue( wp_script_is( 'acme-editor', 'enqueued' ) );
self::assertSame( [ 'wp-element', 'wp-i18n' ], $script->deps );
self::assertSame( 'defer', wp_scripts()->get_data( 'acme-editor', 'strategy' ) );
}
Такой же подход работает со стилями и модулями скриптов.
Пример 4: Проверка REST-маршрута без HTTP-запроса
Приложение регистрирует маршрут с цисловым аргументом:
function register_items_route(): void {
register_rest_route(
'acme/v1',
'/items/(?P<id>[^/]+)',
[
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => static fn( WP_REST_Request $request ) => [
'id' => $request['id'],
],
'args' => [
'id' => [
'type' => 'integer',
'validate_callback' => 'rest_validate_request_arg',
],
],
]
);
}
Тест выполняет запрос к маршруту в памяти и проверяет реальную валидацию аргумента:
public function test_rejects_an_invalid_rest_item_id(): void {
register_items_route();
$response = rest_do_request(
new WP_REST_Request( 'GET', '/acme/v1/items/not-a-number' )
);
self::assertSame( 400, $response->get_status() );
self::assertSame( 'rest_invalid_param', $response->get_data()['code'] );
}
protected function tearDown(): void {
unset( $GLOBALS['wp_rest_server'] );
parent::tearDown();
}
Учитывайте ограничения
Доступны только функции, классы и другие символы, перечисленные в SYMBOLS-INFO.md.
Используйте интеграционные тесты WordPress, когда поведение кода зависит от реальной базы данных, файловой системы, установленных плагинов или тем либо полного жизненного цикла WordPress.