modxkit/testbench - тесты дополнений MODX 3 на живом ядре, в том числе в CI
Юнит-тесты на класс-хелпер пишут все. А то, ради чего вообще существует дополнение — xPDO-модели, процессоры, плагины на системных событиях, настройки, права — не покрыто почти нигде. И дело не в лени: чтобы проверить, что модель сохраняется, а процессор ругается на кривой ввод, нужен живой MODX. Поднять его руками дорого, а в CI и вовсе никак — браузерный инсталлятор из GitHub Actions не запустишь.
Получается ритуал вместо теста: поставил MODX локально, потыкал, посмотрел глазами, забыл. А через полгода правка в схеме тихо ломает процессор, и узнаёт об этом пользователь.
modxkit/testbench закрывает ровно эту дыру. Для Laravel такое давно есть — orchestra/testbench; это то же самое для MODX Revolution 3.
Что он делает
Подключаешь в require-dev — и получаешь окружение, которое собирается само:
Быстрый старт
Дальше phpunit.xml:
Два уровня, и второй нужен не всегда
Уровень 2 — ModxKit\Testbench\TestCase: живое ядро и живая база. Модели, схема, процессоры, плагины, права.
Уровень 1 — ModxKit\Testbench\Unit\UnitTestCase: работает с выключенной СУБД. Классы modX и xPDO там настоящие, а вместо базы заглушки. Это секунды вместо минут, и туда переносится всё, чему живая база на самом деле не нужна:
Каждый тест идёт внутри транзакции и откатывается. Беда в том, что транзакцию в MODX потерять легко и молча: любой DDL делает неявный commit, установка транспортного пакета — тоже, а таблица MyISAM не откатывается в принципе.
Пакет за этим следит и говорит вслух. Детектор ловит четыре способа потерять изоляцию: снятый флаг SERVER_STATUS_IN_TRANS после DDL, сырые START TRANSACTION и BEGIN, commit() с новым beginTransaction() — по сторожевой метке, пережившей откат, — и таблицы MyISAM проверкой движка до теста. Поймав, он не молчит и не «чинит» втихую, а показывает на трейт RefreshesDatabase, который восстанавливает базу из снимка, снятого сразу после установки ядра.
Кроме базы между тестами возвращаются файловый кеш ядра и сессионные переменные MySQL.
Честная оговорка, она же написана и в документации: запись, сделанную с другого соединения или из подпроцесса, детектор не видит и увидеть не может — она транзакции теста не подчинена в принципе.
Процессоры, события, фабрики
Уровень 2: runProcessor(), assertProcessorSuccess(), assertProcessorFailure(), assertObjectExists(), assertObjectMissing(), assertSettingEquals(), фабрики createResource(), createUser(), createChunk(), createSnippet(), плюс setSetting(), triggerEvent() и actingAs() для проверки прав.
Уровень 1: assertEventInvoked(), assertLogged(), assertLexiconUsed() и stubOptions() для подмены системных настроек. Процессоры сюда не заезжают — им нужна живая база.
Отдельно про грабли, на которые пакет наступил сам и теперь предупреждает: строковое имя процессора твоего дополнения требует третьим аргументом путь к твоим процессорам. Иначе modX::runProcessor() ищет его среди ядровых, не находит, и отказ выглядит как пройденный тест.
Командная строка
CI одной строкой
В поставке есть переиспользуемый workflow для GitHub Actions:
И сразу оговорка, которую мы добавили в документацию после того, как сами на неё налетели: если ключ test у тебя уже занят, блок из доки его заместит, и твой собственный сьют исчезнет из CI молча. Составляй, а не замещай:
Чего он не делает
Без прикрас, потому что узнать это лучше сейчас, чем после установки.
Перед публикацией пакет обкатали на настоящем дополнении — не на игрушечном примере, а на проекте с собственным конвейером качества и 1879 своими тестами. Проверяли не «работает ли пакет», а другое: доводит ли документация стороннего разработчика до работающего теста, если он читает только README и гайд и не заглядывает в исходники.
Довела — на обоих уровнях. Причём строгий phpunit.xml того проекта (failOnWarning, failOnRisky, requireCoverageMetadata) не пришлось ослаблять ни одним флагом, а его 1879 тестов как были зелёными, так и остались.
Нашлось при этом и то, что чинили: констрейнт зависимостей исключал вышедшую Symfony 8, и у разработчика с современным тулчейном установка откатывалась. Починили до релиза — вместе с той самой ловушкой composer test, о которой выше.
Ссылки
Получается ритуал вместо теста: поставил MODX локально, потыкал, посмотрел глазами, забыл. А через полгода правка в схеме тихо ломает процессор, и узнаёт об этом пользователь.
modxkit/testbench закрывает ровно эту дыру. Для Laravel такое давно есть — orchestra/testbench; это то же самое для MODX Revolution 3.
Что он делает
Подключаешь в require-dev — и получаешь окружение, которое собирается само:
- скачивает ядро MODX нужной версии (или берёт из кеша);
- ставит его неинтерактивно, без браузера;
- поднимает в MODX_API_MODE и отдаёт тебе $this->modx;
- откатывает состояние между тестами, чтобы порядок тестов ничего не решал;
- даёт декларативно зарегистрировать твоё дополнение — модели, таблицы, настройки.
Быстрый старт
composer require --dev modxkit/testbenchPHPUnit отдельно требовать не надо, он приезжает вместе с пакетом.Дальше phpunit.xml:
<phpunit bootstrap="vendor/modxkit/testbench/bootstrap.php"
cacheDirectory=".phpunit.cache"
beStrictAboutOutputDuringTests="true">
<testsuites>
<testsuite name="unit"><directory>tests/Unit</directory></testsuite>
<testsuite name="integration"><directory>tests/Integration</directory></testsuite>
</testsuites>
</phpunit>Интеграционный тест целиком:use ModxKit\Testbench\Concerns\RefreshesDatabase;
use ModxKit\Testbench\Package\PackageDefinition;
use ModxKit\Testbench\TestCase;
use MyVendor\MyExtra\Model\Job;
final class JobTest extends TestCase
{
// Обязателен, если дополнение объявляет свои таблицы: их создание — DDL,
// а DDL в MySQL делает неявный commit и рвёт транзакцию теста.
use RefreshesDatabase;
protected function packageDefinition(): PackageDefinition
{
$core = dirname(__DIR__) . '/';
return PackageDefinition::make('myextra')
->corePath($core)
->model('MyVendor\\MyExtra\\Model', $core . 'src/', 'mex_', 'MyVendor\\MyExtra\\')
->tables(Job::class)
->settings(['myextra_chunk_size' => 500]);
}
public function testJobPersists(): void
{
$job = $this->modx->newObject(Job::class);
$job->set('name', 'nightly');
self::assertTrue($job->save());
$this->assertObjectExists(Job::class, ['name' => 'nightly']);
}
}Перед запуском — переменные подключения к БД:export MODX_TESTBENCH_DB_HOST=127.0.0.1
export MODX_TESTBENCH_DB_USER=root
export MODX_TESTBENCH_DB_PASS=secretВсё. Первый прогон скачает и поставит MODX сам.Два уровня, и второй нужен не всегда
Уровень 2 — ModxKit\Testbench\TestCase: живое ядро и живая база. Модели, схема, процессоры, плагины, права.
Уровень 1 — ModxKit\Testbench\Unit\UnitTestCase: работает с выключенной СУБД. Классы modX и xPDO там настоящие, а вместо базы заглушки. Это секунды вместо минут, и туда переносится всё, чему живая база на самом деле не нужна:
use ModxKit\Testbench\Unit\UnitTestCase;
final class PriceFormatterTest extends UnitTestCase
{
public function testEmitsEvent(): void
{
(new PriceFormatter($this->modx))->format(100.0);
$this->assertEventInvoked('OnMyExtraPriceFormatted');
}
}Изоляция состояния — то, на чём обычно всё и разваливаетсяКаждый тест идёт внутри транзакции и откатывается. Беда в том, что транзакцию в MODX потерять легко и молча: любой DDL делает неявный commit, установка транспортного пакета — тоже, а таблица MyISAM не откатывается в принципе.
Пакет за этим следит и говорит вслух. Детектор ловит четыре способа потерять изоляцию: снятый флаг SERVER_STATUS_IN_TRANS после DDL, сырые START TRANSACTION и BEGIN, commit() с новым beginTransaction() — по сторожевой метке, пережившей откат, — и таблицы MyISAM проверкой движка до теста. Поймав, он не молчит и не «чинит» втихую, а показывает на трейт RefreshesDatabase, который восстанавливает базу из снимка, снятого сразу после установки ядра.
Кроме базы между тестами возвращаются файловый кеш ядра и сессионные переменные MySQL.
Честная оговорка, она же написана и в документации: запись, сделанную с другого соединения или из подпроцесса, детектор не видит и увидеть не может — она транзакции теста не подчинена в принципе.
Процессоры, события, фабрики
$response = $this->runProcessor(Create::class, ['name' => 'nightly']);
$this->assertProcessorSuccess($response);Набор помощников у каждого уровня свой, и это стоит запомнить сразу, чтобы не искать метод не там.Уровень 2: runProcessor(), assertProcessorSuccess(), assertProcessorFailure(), assertObjectExists(), assertObjectMissing(), assertSettingEquals(), фабрики createResource(), createUser(), createChunk(), createSnippet(), плюс setSetting(), triggerEvent() и actingAs() для проверки прав.
Уровень 1: assertEventInvoked(), assertLogged(), assertLexiconUsed() и stubOptions() для подмены системных настроек. Процессоры сюда не заезжают — им нужна живая база.
Отдельно про грабли, на которые пакет наступил сам и теперь предупреждает: строковое имя процессора твоего дополнения требует третьим аргументом путь к твоим процессорам. Иначе modX::runProcessor() ищет его среди ядровых, не находит, и отказ выглядит как пройденный тест.
Командная строка
vendor/bin/modx-testbench install # поставить окружение
vendor/bin/modx-testbench status # где оно, какая версия, цела ли база
vendor/bin/modx-testbench snapshot # снять или восстановить базовый снимок
vendor/bin/modx-testbench destroy # удалить окружениеCI одной строкой
В поставке есть переиспользуемый workflow для GitHub Actions:
jobs:
tests:
uses: modxkit/testbench/.github/workflows/testbench.yml@v1
with:
php-versions: '["8.2","8.3","8.4"]'
modx-versions: '["3.1.2-pl","3.2.3-pl"]'
working-directory: core/components/myextraОн поднимает MySQL, ставит нужный PHP, кеширует дистрибутив MODX и запускает у тебя composer test. Значит, скрипты test, test:unit и test:integration должны быть в твоём composer.json.И сразу оговорка, которую мы добавили в документацию после того, как сами на неё налетели: если ключ test у тебя уже занят, блок из доки его заместит, и твой собственный сьют исчезнет из CI молча. Составляй, а не замещай:
"scripts": {
"test": ["@test:default", "@test:unit", "@test:integration"],
"test:default": "phpunit --testsuite default",
"test:unit": "phpunit --testsuite unit",
"test:integration": "phpunit --testsuite integration"
}Чего он не делает
Без прикрас, потому что узнать это лучше сейчас, чем после установки.
- MODX 3.0.x не поддерживается. Не «руки не дошли», а измеренная причина: ядро 3.0.x не поднимается в API-режиме полноценно. modX::reloadConfig() двумя соседними строками дважды включает один и тот же файл модели, и процесс падает с Cannot redeclare class; до этого getOption('core_path') отдаёт null. Это внутри ядра, и пакет это не обходит. Проверяются 3.1.2-pl и 3.2.3-pl.
- Только MySQL и MariaDB. Инсталлятор MODX 3 на практике завязан на MySQL.
- PHP 8.2–8.4.
- Это инструмент для разработки дополнений, а не для тестирования боевого сайта.
Перед публикацией пакет обкатали на настоящем дополнении — не на игрушечном примере, а на проекте с собственным конвейером качества и 1879 своими тестами. Проверяли не «работает ли пакет», а другое: доводит ли документация стороннего разработчика до работающего теста, если он читает только README и гайд и не заглядывает в исходники.
Довела — на обоих уровнях. Причём строгий phpunit.xml того проекта (failOnWarning, failOnRisky, requireCoverageMetadata) не пришлось ослаблять ни одним флагом, а его 1879 тестов как были зелёными, так и остались.
Нашлось при этом и то, что чинили: констрейнт зависимостей исключал вышедшую Symfony 8, и у разработчика с современным тулчейном установка откатывалась. Починили до релиза — вместе с той самой ловушкой composer test, о которой выше.
Ссылки
- Пакет: packagist.org/packages/modxkit/testbench
- Исходники: github.com/modxkit/testbench
- README на русском: README.ru.md
- Гайд для разработчика дополнений: docs/DX_GUIDE.md
Техническая поддержка MODX
Сайт лежит, тормозит или остался без разработчика?
Переезд с MODX 2 на 3, PHP 7 на 8, скорость и безопасность. Поддержка со сроками и ответственностью, а не совет в чате.
Подробнее
Реклама
2
Комментарии: 2