modxkit/testbench - тесты дополнений MODX 3 на живом ядре, в том числе в CI

Юнит-тесты на класс-хелпер пишут все. А то, ради чего вообще существует дополнение — xPDO-модели, процессоры, плагины на системных событиях, настройки, права — не покрыто почти нигде. И дело не в лени: чтобы проверить, что модель сохраняется, а процессор ругается на кривой ввод, нужен живой MODX. Поднять его руками дорого, а в CI и вовсе никак — браузерный инсталлятор из GitHub Actions не запустишь.

Получается ритуал вместо теста: поставил MODX локально, потыкал, посмотрел глазами, забыл. А через полгода правка в схеме тихо ломает процессор, и узнаёт об этом пользователь.

modxkit/testbench закрывает ровно эту дыру. Для Laravel такое давно есть — orchestra/testbench; это то же самое для MODX Revolution 3.

Что он делает

Подключаешь в require-dev — и получаешь окружение, которое собирается само:

  • скачивает ядро MODX нужной версии (или берёт из кеша);
  • ставит его неинтерактивно, без браузера;
  • поднимает в MODX_API_MODE и отдаёт тебе $this->modx;
  • откатывает состояние между тестами, чтобы порядок тестов ничего не решал;
  • даёт декларативно зарегистрировать твоё дополнение — модели, таблицы, настройки.
Ни одного ручного шага. Окружение кешируется в ~/.cache/modx-testbench/workspaces/, так что платишь только за первую установку.

Быстрый старт

composer require --dev modxkit/testbench
PHPUnit отдельно требовать не надо, он приезжает вместе с пакетом.

Дальше 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.
  • Это инструмент для разработки дополнений, а не для тестирования боевого сайта.
Проверено на реальном дополнении, а не на «Hello World»

Перед публикацией пакет обкатали на настоящем дополнении — не на игрушечном примере, а на проекте с собственным конвейером качества и 1879 своими тестами. Проверяли не «работает ли пакет», а другое: доводит ли документация стороннего разработчика до работающего теста, если он читает только README и гайд и не заглядывает в исходники.

Довела — на обоих уровнях. Причём строгий phpunit.xml того проекта (failOnWarning, failOnRisky, requireCoverageMetadata) не пришлось ослаблять ни одним флагом, а его 1879 тестов как были зелёными, так и остались.

Нашлось при этом и то, что чинили: констрейнт зависимостей исключал вышедшую Symfony 8, и у разработчика с современным тулчейном установка откатывалась. Починили до релиза — вместе с той самой ловушкой composer test, о которой выше.

Ссылки

Лицензия MIT. Вопросы и баг-репорты — в issues, буду рад разбору.
Prihod
Prihod
17 минут назад
modx.pro
10

Комментарии: 0

Авторизуйтесь или зарегистрируйтесь, чтобы оставлять комментарии.
0