Слогер Создать блог
Разработка

Почему тест с toContain в Pest может ничего не проверять — и как это исправить

Pest'овский toContain принимает иголки, а не сообщение об ошибке. Если добавить пояснение вторым аргументом, отрицание начинает проходить всегда — и тест превращается в декорацию.

Неделю назад я добавил тест, который не проверял ровным счётом ничего. Он был зелёным при каждом запуске — и именно поэтому я ему доверял.

Правило простое: никаких длинных тире в текстах, которые публикует проект. Я пишу маркетинговые тексты рядом с кодом, и em dash — самый надёжный признак того, что абзац состряпала машина. Однажды мне уже пришлось вычистить 126 таких тире из опубликованных постов. Поэтому я решил автоматизировать проверку.

Я написал guard-тест, который должен был падать при появлении длинного тире. Тест был зелёным. Всегда. И не проверял ничего.

В чём ловушка toContain

Большинство библиотек утверждений дают второй параметр для сообщения об ошибке. PHPUnit даёт. expect($x)->toBeTrue('почему это важно') даёт. Я привык к этому паттерну и дописал человеческое пояснение вторым аргументом в Pest. А у Pest toContain устроен иначе.

Сигнатура метода — toContain(mixed ...$needles). Каждый аргумент — иголка, которую ищут в строке. Мой текст сообщения стал второй иголкой.

В положительной форме всё предсказуемо: expect('alpha beta')->toContain('alpha', 'beta') проходит, а если добавить 'zzz' — падает. Но в отрицательной, с not, начинается подвох. not отрицает всю конъюнкцию. Конструкция ->not->toContain(a, b) означает «неверно, что присутствуют обе иголки». Если одна иголка отсутствует, утверждение удовлетворяется, и до второй иголки дело не доходит.

Проверил на Pest 4.4.1 вместо того, чтобы гадать:

ПроверкаРезультатПочему
toContain(present, absent)FAILEDОбе иголки должны быть, absent нет.
not->toContain(present, absent)PASSEDabsent нет, конъюнкция ложна, отрицание истинно. Это мой случай.
not->toContain(absent, absent)PASSEDОбе отсутствуют, та же логика.
not->toContain(present)FAILEDЕдинственная иголка есть, отрицание ложно.

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

Как починить

Если нужно сообщение — заверните проверку в простой предикат: expect(str_contains($string, '—'))->toBeFalse('Тире в таком-то ключе'). Немного менее флюентно, зато реально проверяет.

Если сообщение не нужно — оставьте одну иголку: expect($string)->not->toContain('—'). Оба варианта рабочие. Худший — тот, что лучше всего читается.

Почему я поверил зелёному тесту

Починка заняла минуту. Сложнее вопрос — почему я доверял тесту, который ни разу не видел красным.

Тест, который всегда зелёный, неотличим от теста, который не может упасть. Зелёный сам по себе ничего не значит: он осмыслен, только если тест в принципе способен покраснеть. Обычно ты узнаёшь это случайно: написал тест, он упал, поправил код, стал зелёным. Красный достался бесплатно.

Guard-тесты так не работают. Их пишут под уже исправленный баг или под правило, которое нельзя нарушать в будущем. Они зелёные с рождения, и никто не видит их красными, а значит — не узнаёт, что они не могут упасть.

Теперь я намеренно ломаю каждый такой тест: возвращаю запрещённое в код, гоняю тесты, жду падения, потом откатываю и жду зелёного. Десять секунд. Это тот же цикл «красный-зелёный», что и в TDD, только применённый задним числом. И это единственное, что отличает guard-тест от комментария.

Пока я разбирался, нашёл в том же файле второй тест с точно такой же ошибкой. Уехал бы в прод вместе с первым.

Соседняя ловушка: слишком грубая защита

В тот же день я написал ещё один guard-тест — запрет на фразу из маркетинговых текстов. На первом же реальном прогоне он зацепил правду, и это провал с противоположным знаком.

Я утверждал в блоге, что продукт умеет то, чего не умеет. Логичный шаг — запретить фразу тестом. Но фраза встречалась и в совершенно честном предложении про план конкурента, у которого эта функция есть. Тест что-то делал, но делал не то.

Две ошибки живут рядом. Guard, который ничего не проверяет, говорит, что тексты чистые, хотя они грязные. Guard, который слишком прямолинеен, говорит, что тексты сломаны, хотя они нормальные. Самый быстрый способ заткнуть ложную тревогу — удалить тест. Ни тот, ни другой не предупреждают о себе заранее.

Оставшаяся версия проверяет фразу построчно и срабатывает только если она встречается в строке о моём собственном продукте. Кода больше, область поражения уже — и такой тест выживает при столкновении с реальным корпусом текстов. Это и есть проверка guard'а: прогнать его по всему, что уже опубликовано, а не ограничиваться случаем, под который он придуман. Если он зацепил правду — он ещё не готов.

Итог

  • toContain в Pest — вариативный, без отдельного параметра для сообщения.
  • not->toContain($needle, 'message') проходит, как только сообщения нет в строке. А его там нет всегда. Тест мёртв.
  • Если нужно сообщение — используйте expect(str_contains($haystack, $needle))->toBeFalse('message'). Если не нужно — одну иголку: not->toContain($needle).
  • Каждый guard-тест нужно намеренно сломать хотя бы раз: зелёный с рождения ничего не доказывает.
  • Потом прогнать его по всему существующему корпусу. Guard, который задевает правдивое утверждение, удалит тот, кто наткнётся на него следующим.

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

По материалам: dev. Текст переработан редакцией Слогера.

← На главную

Рекламное место — Конец поста
Реклама · Слогер

Комментарии (0)

Войдите, чтобы комментировать.

Пока нет комментариев. Будьте первым.