Testo. Assert и Expect
Поговорим про грабли велосипедостроения, с которыми я уже познакомился при написании нового фреймворка тестирования Testo.
PHPUnit предоставляет множество вариантов одних и тех же проверок (утверждений) в тестах:
self::assertTrue(...);
$this->assertTrue(...);
assertTrue(...);Все эти вызовы ведут в одно место — в фасад Assert на ~2300 строк.
🤔
Но знаете ли вы, что в PHPUnit нет ни отдельной функции expectException(), ни одноимённого метода в фасаде Assert? В коде теста можно написать только $this->expectException().
Это потому, что в PHPUnit тесты наследуются от TestCase (~2400 строк, расширяет Assert), в котором хранится и обрабатывается всё состояние теста. Мне это напоминает архитектуру Symfony Console, хуже которой я пока не встречал.
Как связаны состояние теста и проверка assertException? Дело в том, что проверка expect (ожидание) немного отличается от assert (утверждения) по семантике и механике:
- Утверждения проверяются здесь и сейчас по принципу "проверил и забыл".
- Ожидания проверяются позже (после завершения теста), т.е. "запомнил, в конце проверил".
В Testo другая политика.
С позиции Testo, тестовый класс — это владение разработчика, а не фреймворка. Вся метаинформация и оперативные данные, нужные фреймворку, хранится и обрабатывается в стороне.
Поэтому тестовым классам не нужно наследоваться от TestCase. В Testo тест-кейс не запускает сам себя и даже не знает своё имя в тестовой среде. Это позволяет писать более чистый код, и мы можем использовать конструктор как угодно.
Тестами могут быть даже обычные пользовательские функции!
#[Test]
function simpleTest(): void
{
// test something
}🧠
Но теперь возникает непростой вопрос с очень широким простором для фантазии: как предоставить удобное API для проверок?
Да, мы можем в будущем сделать сотню функций, трейт и базовый класс с синтаксисом как у PHPUnit... Но эй, давайте сначала попробуем нащупать что-то получше!
Короче и понятнее
Я решил начать как в библиотеке webmozarts/assert: раз нам больше не нужно писать self:: или $this->, будем проще: Assert::same(). Порядок параметров я решил взять привычный, как у PHPUnit: сначала $expected, затем $actual (у webmozart первым параметром указывается проверяемое значение, а потом ожидаемое, что, в принципе, выглядит более логичным).
Пошло-поехало. Сделали ::same(), ::notSame, ::null(), ::true(), ::false(), ::equals(), ::notEquals().
И вот мы дошли до функции Assert::greaterThan(). В PHPUnit порядок аргументов этой функции всё тот же: сначала $expected, затем $actual.
Т.е. если мы хотим сказать $foo больше 42, то надо написать greaterThan(42, $foo).

Выглядит отвратительно, ведь везде используем математическую запись вида $foo > 42.
После некоторых раздумий победил самый понятный, короткий и читабельный вариант. Угадаете какой?
Assert::compare($foo, '>', 42);
Assert::satisfies($foo, '>', 42);
Assert::that($foo)->greaterThan(42);
Assert::true($foo > 42);☝
Это привело нас к стратегическому решению: предоставлять только "сложные" проверки, которые экономят знаки или целые строки кода.
Когда дошло до expectException(), с фасадом Assert стало уже как-то некомфортно. Первое время это выглядело как Assert::exception(). Неудобство вытекает из той разницы, озвученной в начале: семантика (смысл) и механика утверждений (делать проверки "здесь и сейчас").
Что мы тут утверждаем? Утверждаем, что ожидаем, что из теста выпадет исключение?
Долго у меня это сидело в голове и не давало покоя. Хорошо, мол, Бергману с его наследованием — не надо думать про смыслы в именах фасадов — просто сунул всё в $this и нет проблем.
В итоге пришёл к мысли, что нужен второй фасад Expect, в котором будут предоставляться пост-проверки.
Плюсы:
- Программист сходу различает, какая проверка будет сделана опосля.
- Отсутствие диссонанса в именовании.
Минусы:
- Нет автокомплита по фасаду
Assert, и нужно помнить про второй фасад. Ну, к этому надо будет просто привыкнуть.

Пробуем что-то новое
Вместо того, чтобы добавлять кучу сахара типа ::stringContains(), ::stringEndsWith() (и ещё 20 методов string*) в один фасад, мы можем сгруппировать методы по семантике или типу:
// Строки
Assert::string($string)->contains("str");
// Файлы
Assert::file("foo.txt")->notExists();
// Исключения
Expect::exception(Failure::class)
->fromMethod(Service::class, 'process')
->withMessage("foo bar");Тогда в начале пайпа, в методе Assert::string(), сразу проверим, что нам действительно передаётся строка, а в ->contains(...) выполним проверку уже будучи уверенными, что работаем с нужным типом.
Код занимает меньше места, фасады не раздутые. Вот это то, что выглядит действительно элегантно. А юзабельно или нет, покажет практика.

Вот мы сделали несколько таких пайповых ассертов.
#[Test]
public function checkIterableTraitMethods(): void
{
Assert::instanceOf(\DateTimeInterface::class, new \DateTimeImmutable());
// Сокращение конструкции Assert::object($object)->instanceOf($class);
Assert::int(15)->greaterThan(10);
Assert::array([1,2,3])->allOf('int')->contains(3)->hasKeys(0)->sameSizeAs([4,5,6,7]);
}Можем ли мы превратить это в удобный вывод?
В Testo я сразу предусмотрел логирование ассертов на каждый тест. Этот механизм пришлось немного переделать на композиты после появления пайповых ассертов, но речь не об этом.
Давайте попробуем отобразить список ассертов и посмотрим что из этого может получиться.
Компактный вариант. Мне он нравится, но выглядит корявенько и может не зайти тем, кто не любит сокращения в ущерб языковым конструкциям.

Более полный вариант. Здесь все проверки в пайпе перечислены через точку с запятой. Читается как книга, а вложенный элемент дерева содержит полный текст исключения.

Как это всё улучшить — пока непонятно. Может вернуться к компакту?
Также примеряем, как оно будет выглядеть в IDE. Будет ли это полезным?

Есть ещё вариант выводить каждый ассерт как вложенная галочка в дереве тестов (как DataSet), но думаю, это будет сильно перегружено.
☝
Может показаться, что открытых вопросов становится только больше. Но со временем появляются не только вопросы, но также растёт экспертиза: каждый ответ на закрытый вопрос подкреплён ментальными или практическими опытами.
Точка в assert/expect не поставлена. Но пока Testo не релизнулся в стабильный тег, мы можем позволить себе любые эксперименты.
Не удивлюсь, если в будущем мы всё-таки решим, что $expected должно идти после $actual, что пайп-ассерты не так удобны и нужны функции, а вывод истории ассертов избыточен.
Присоединяйтесь к обсуждению или разработке, предлагайте самые смелые идеи или фичи. Это интересно.
Отдельно выражаю благодарность @petrdobr за помощь в имплементации ассертов.
