Skip to content
...
Assert и Expect

Testo. Assert и Expect

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

PHPUnit предоставляет множество вариантов одних и тех же проверок (утверждений) в тестах:

php
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 тест-кейс не запускает сам себя и даже не знает своё имя в тестовой среде. Это позволяет писать более чистый код, и мы можем использовать конструктор как угодно.

Тестами могут быть даже обычные пользовательские функции!

php
#[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 должно, чую я

Выглядит отвратительно, ведь везде используем математическую запись вида $foo > 42.

После некоторых раздумий победил самый понятный, короткий и читабельный вариант. Угадаете какой?

php
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*) в один фасад, мы можем сгруппировать методы по семантике или типу:

php
// Строки
Assert::string($string)->contains("str");

// Файлы
Assert::file("foo.txt")->notExists();

// Исключения
Expect::exception(Failure::class)
    ->fromMethod(Service::class, 'process')
    ->withMessage("foo bar");

Тогда в начале пайпа, в методе Assert::string(), сразу проверим, что нам действительно передаётся строка, а в ->contains(...) выполним проверку уже будучи уверенными, что работаем с нужным типом.

Код занимает меньше места, фасады не раздутые. Вот это то, что выглядит действительно элегантно. А юзабельно или нет, покажет практика.

Здорово. Мне нравится.


Вот мы сделали несколько таких пайповых ассертов.

php
#[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. Будет ли это полезным?

В IDE

Есть ещё вариант выводить каждый ассерт как вложенная галочка в дереве тестов (как DataSet), но думаю, это будет сильно перегружено.

Может показаться, что открытых вопросов становится только больше. Но со временем появляются не только вопросы, но также растёт экспертиза: каждый ответ на закрытый вопрос подкреплён ментальными или практическими опытами.

Точка в assert/expect не поставлена. Но пока Testo не релизнулся в стабильный тег, мы можем позволить себе любые эксперименты.

Не удивлюсь, если в будущем мы всё-таки решим, что $expected должно идти после $actual, что пайп-ассерты не так удобны и нужны функции, а вывод истории ассертов избыточен.


Присоединяйтесь к обсуждению или разработке, предлагайте самые смелые идеи или фичи. Это интересно.

Отдельно выражаю благодарность @petrdobr за помощь в имплементации ассертов.