Новый фреймворк тестирования Testo готов к испытаниям.


Полгода назад я начал разработку Testo по причине того, что PHPUnit меня не просто не устраивал, а сильно мешал:

  • не давал нужной гибкости и расширяемости;

  • вызывал много проблем на дистанции, ломая совместимость каждые два года;

  • задеревенел и, крмое повышения требований к PHP, никак не развивался.

В обычном случае это можно было бы поправить каким-то вкладом в репозиторий, но у PHPUnit свой путь. Я отслеживал и участвовал в Issues на гитхабе, но всё, что я там видел, выглядело примерно так:

Из статьи «PHPUnit»
Из статьи «PHPUnit»

Собственно, это и стало отправной точкой для Testo.

Автор статьи «PHPUnit: рабочий нерабочий covers» тоже на это наткнулся.

Фрагмент

Но главная сложность - это упорное нежелание автора библиотеки добавлять этот флаг. Хотя в phpunit уже есть схожие решения (--disable-coverage-ignore--no-coverage).

Я честно пробовал провернуть этот финт. Вот даже PR-ы в оба репозитория:

К сожалению, спустя какое-то (весьма продолжительное) время все они были закрыты Себастьяном Бергманном с излюбленной формулировкой:

Thank you for your contribution. I appreciate the time you invested in preparing this pull request. However, I have decided not to merge it.

Примерный перевод: Мне настолько плевать на этот PR, что я закрою его даже не объясняя причины.

От жеж ..! Ладно, бывает.

Но постойте, есть же ещё PEST и Codeception.
Эти фреймворки хоть и предоставляют много дополнительных фичей, но всё-же тоже базируются на PHPUnit и наследуют проблемные гены родителя. Кроме того, нетрадиционный синтаксис PEST из Jest лично меня отталкивает.

Pest
Из журнала «В мире PHP #1»

Что предлагает Testo

Testo уживается с любыми библиотеками и инструментами, не вызывая проблем.

  • Не зависит от PHPUnit. Это не очередная обертка над ним, а полноценный фреймворк с нуля.

  • Не патчит nikic/php-parser и даже его не использует. Конфликтов не будет.

  • PHP 8.2+ — самая широкая поддержка версий PHP.

AI-агенты легко сгенерируют Testo-тесты, просто скормите им llms.txt (дока).

Благодаря системе плагинов вы можете слепить из Testo именно то, что нужно вам.
Ограничений, кроме иммутабельности, нет.

  • Все фичи Testo — это плагины, которые можно включать и отключать по желанию.

  • Написать свой плагин? Пара десятков строк кода, и он уже в деле.

  • Каждый Test Suite может иметь индивидуальный набор плагинов.

Выходите за рамки привычного тестирования:

  • Надо тестировать прямо в src? Для этого уже есть встроенные тесты и бенчмарки.

  • Сделать свой атрибут с крутой логикой? Проще простого, плагин #[Retry], состоящий из двух файлов — отличный пример [1][2].

  • Система пайплайнов и мидлварей, система событий и плагины дают безграничные возможности для расширения и кастомизации.

Сделано разработчиком для разработчиков.

  • Минимум боилерплейта благодаря атрибутам.

  • Типизация даже в проверках.

  • Привычный синтаксис ООП и PHP, без магии и DSL.

Полноценный плагин для PHPStorm тоже имеется.

Ну что, попробуем?


Установка и настройка

Всего 3 шага:

  1. Установите Testo через Composer:

    composer require --dev testo/testo
  2. Создайте testo.php в корне проекта:

    <?php
    
    declare(strict_types=1);
    
    use Testo\Application\Config\ApplicationConfig;
    use Testo\Application\Config\SuiteConfig;
    
    return new ApplicationConfig(
        suites: [
            new SuiteConfig(
                name: 'Sources',
                location: ['src'],
            ),
            new SuiteConfig(
                name: 'Tests',
                location: ['tests'],
            ),
        ],
    );
    Что это за файл?

    Testo конфигурируется PHP-файлом, который возвращает объект ApplicationConfig.
    Если файла нет, Testo попытается запустить тесты из папки tests с дефолтными настройками.

    Здесь мы определили два набора тестов:
    - Sources для встроенных тестов и бенчмарков прямо в папке src;
    - Tests для привычных Unit-тестов в папке tests.

  3. Установите плагин для PHPStorm

Запускать тесты можно прямо из PHPStorm или через CLI:

./vendor/bin/testo

Первые тесты

Unit-тест

В папке tests/ создаём обычный класс с методами, помеченными атрибутом #[Test]. Обратите внимание: никакого наследования от базовых классов.

final class OrderTest
{
    #[Test]
    public function calculatesTotal(): void
    {
        $order = new Order();
        $order->addItem('Book', price: 15.0, quantity: 2);
        $order->addItem('Pen', price: 3.0, quantity: 5);

        Assert::same($order->total(), 45.0);
    }

    #[Test]
    #[DataSet([100.0, 10, 90.0], '10% off')]
    #[DataSet([100.0, 0, 100.0], 'no discount')]
    #[DataSet([0.0, 50, 0.0], 'zero price')]
    public function appliesDiscount(float $price, int $percent, float $expected): void
    {
        $result = Order::applyDiscount($price, $percent);

        Assert::same($result, $expected);
    }

    #[Test]
    #[ExpectException(InsufficientFundsException::class)]
    public function cannotOverdraw(): never
    {
        new Account(balance: 100)->withdraw(200);
    }
}
Дополнительно
  • Атрибут #[Test] можно повесить на класс, тогда все методы с возвращаемым типом void или never автоматически будут считаться тестами.

  • Тесты можно описывать вне класса, просто в функциях. Это ещё меньше кода, но атрибуты жизненного цикл будут недоступны

Методы фасада Assert принимают интуитивно понятный прямой порядок аргументов: сначала $actual (проверяемое значение), затем $expected (ожидаемое значение). Это отличается от устаревшего подхода xUnit.

А вот как выглядят цепочки типизированных проверок:

Assert::string($email)->contains('@');

Assert::int($age)->greaterThan(0)->lessThan(150);

Assert::array($items)
    ->hasKeys('id', 'name')
    ->isList()
    ->notEmpty();

Assert::json($response->body())
    ->isObject()
    ->hasKeys('data', 'meta');

Встроенные тесты

Тестируйте методы прямо там, где они объявлены. Отдельный тестовый файл не нужен — атрибут #[TestInline] запускает метод с заданными аргументами и проверяет результат. Работает даже с приватными методами:

// src/Money.php
final class Money
{
    #[TestInline(['price' => 100.0, 'discount' => 0.1, 'tax' => 0.2], 108.0)]
    #[TestInline(['price' => 50.0, 'discount' => 0.0, 'tax' => 0.1], 55.0)]
    private static function calculateFinalPrice(
        float $price,
        float $discount,
        float $tax,
    ): float {
        return $price * (1 - $discount) * (1 + $tax);
    }
}

Идеально для чистых функций и быстрого прототипирования — тест живёт рядом с кодом и обновляется вместе с ним.

Бенчмарки

Моментально сравнивайте производительность функций и методов не отвлекаясь на обвязку: достаточно повесить атрибут #[Bench] на функцию и бенчи уже полетели:

#[Bench(
    callables: [
        'multiply' => 'viaMultiply',
        'shift'    => 'viaShift',
    ],
    arguments: [1, 5_000],
    calls: 2_000_000,
)]
function viaDivision(int $a, int $b): int
{
    $d = $b - $a + 1;
    return (int) (($d - 1) * $d / 2) + $a * $d;
}

function viaMultiply(int $a, int $b): int
{
    $d = $b - $a + 1;
    return (int) (($d - 1) * $d * 0.5) + $a * $d;
}

function viaShift(int $a, int $b): int
{
    $d = $b - $a + 1;
    return ((($d - 1) * $d) >> 1) + $a * $d;
}
+---+----------+-------+---------+------------------+--------+
| # | Name     | Iters | Calls   | Avg Time         | RStDev |
+---+----------+-------+---------+------------------+--------+
| 2 | current  | 10    | 2000000 | 75.890µs         | ±0.79% |
| 3 | multiply | 10    | 2000000 | 78.821µs (+3.9%) | ±0.47% |
| 1 | shift    | 10    | 2000000 | 70.559µs (-7.0%) | ±0.70% |
+---+----------+-------+---------+------------------+--------+

Интересно?

Если вас заинтересовал Testo, но вы хотите узнать о нём чуть больше, обязательно ознакомьтесь с этими статьями:

Поставьте звёздочку на GitHub и оценку PHPStorm плагину — это очень поможет Testo стать более заметным.


Что дальше?

Сейчас идёт бета-тестирование и мы двигаемся к релизу. Пользовательский API стабилизировался, однако есть ещё несколько вещей, которые нужно доделать:

  • Причесать вывод отчётов в CLI и PHPStorm, добавить diff.

  • Всякие мелочи, вроде перехвата STDOUT и PHP-ошибок.

  • Параллельный запуск тестов и изолированный запуск в отдельном процессе.

  • Допилить незначительные вещи в бенчах и internal, чтобы было совсем хорошо.

  • Всякие организационные моменты, вроде "раскидать монорепу" и дописать документацию.

Codecov и Mocks, возможно, тоже подъедут к релизу, но это уже не точно.

Вы же, в свою очередь, можете помочь с тестированием и фидбеком, чтобы релиз был максимально гладким и безболезненным. Приходите в GitHub Issues или Telegram-чат с идеями, вопросами и проблемами — будем разбираться вместе!