Просьба «напиши тесты» звучит как задача с очевидным критерием: тесты есть, тесты зелёные, покрытие выросло. Все три условия выполняются легко и по отдельности не значат ничего.
Пятый заход серии. Стенд прежний: Symfony 8, 96 тысяч строк, 784 теста на старте, покрытие 61 процент.
Заход первый
Задача: покрыть тестами модуль Docs — пространства, страницы, ревизии, вложения. Покрытие модуля на старте 43 процента.
Формулировка простая: «напиши тесты для модуля Docs, доведи покрытие до 80 процентов».
Через час: 128 новых тестов, покрытие модуля 82 процента, всё зелёное. Цель формально достигнута.
Дальше я посмотрел, что это за тесты.
public function test_page_service_creates_page(): void
{
$space = $this->createSpace();
$page = $this->pageService->create($space, 'Заголовок', 'Тело');
self::assertInstanceOf(Page::class, $page);
self::assertNotNull($page->getId());
}Тест исполняет метод и проверяет, что он что-то вернул. Покрытие растёт, но такой тест не поймает ни одной ошибки. Заголовок не проверен, тело не проверено, привязка к пространству не проверена, дата создания не проверена.
Таких тестов в наборе было примерно половина.
Второй жанр — тест, повторяющий реализацию:
public function test_page_slug_is_generated(): void
{
$space = $this->createSpace();
$page = $this->pageService->create($space, 'Привет мир', '');
self::assertSame(
$this->slugger->slug('Привет мир')->lower()->toString(),
$page->getSlug(),
);
}Ожидание вычисляется тем же способом, что и проверяемое значение. Такой тест пройдёт при любой реализации генератора, включая сломанную. Правильно было бы написать ожидаемую строку буквально: privet-mir.
Чем измерить качество теста
Покрытие говорит, какие строки исполнились. Оно ничего не говорит о том, проверил ли кто-нибудь результат.
Мутационное тестирование отвечает на нужный вопрос напрямую: инструмент вносит в код мелкие изменения — меняет знак сравнения, убирает вызов, подменяет возвращаемое значение — и смотрит, упадёт ли хоть один тест. Если мутация выжила, значит, эту строку никто фактически не проверяет.
php vendor/bin/infection --threads=4 --min-msi=60 --filter=src/Module/DocsРезультат на наборе агента: индекс убитых мутантов 41 процент. То есть из ста внесённых поломок 59 остались незамеченными при покрытии 82 процента.
Разрыв между этими двумя числами — и есть содержание всей статьи. Покрытие — 82 процента, способность тестов ловить поломки — 41 процент.
Постановка, которая меняет результат
Заход второй. В задаче перестал фигурировать процент покрытия и появилось описание того, что должен делать тест.
Покрой тестами Module/Docs.
Каждый тест проверяет ОДНО поведение и содержит утверждение о ЗНАЧЕНИИ,
а не о факте выполнения. assertNotNull и assertInstanceOf как единственное
утверждение в тесте запрещены.
Ожидаемые значения пиши литералами. Не вычисляй ожидание тем же кодом,
который проверяешь.
На каждый публичный метод сервиса минимум три теста:
- успешный сценарий с проверкой всех изменённых полей
- граничный случай (пусто, ноль, максимум, дубль)
- отказ: недостаточно прав или неверные данные, с проверкой типа или кода ошибки;
точный текст проверяй только там, где он является публичным контрактом
Критерий готовности: infection по модулю даёт MSI не ниже 70.Последняя строка — главная. Она превращает субъективное «хорошие тесты» в машинную проверку, которую агент может запускать сам и по которой видно, дошёл он до цели или нет.
Результат: 96 тестов вместо 128, покрытие 79 процентов вместо 82, индекс убитых мутантов 74 процента вместо 41.
Тестов меньше, покрытие ниже, а MSI вырос на 33 процентных пункта.
Что изменилось в самих тестах
public function test_page_is_created_with_given_data(): void
{
$space = $this->createSpace(key: 'eng');
$page = $this->pageService->create($space, 'Привет мир', '<p>Тело</p>');
self::assertSame('Привет мир', $page->getTitle());
self::assertSame('privet-mir', $page->getSlug());
self::assertSame('<p>Тело</p>', $page->getContent());
self::assertSame($space->getId(), $page->getSpace()->getId());
self::assertNull($page->getParent());
self::assertSame(1, $page->getRevisionNumber());
}Шесть утверждений о значениях вместо двух о факте. Мутация в любом из этих полей теперь убивается.
И отдельно то, чего в первом наборе не было вовсе, — тесты на отказ:
public function test_page_creation_is_rejected_for_foreign_organization(): void
{
$space = $this->createSpace(organization: $this->otherOrg);
$error = $this->pageService->create($space, 'Заголовок', '');
self::assertSame('Нет доступа к этому пространству', $error);
self::assertSame(0, $this->pageRepository->count([]));
}Проверяется и сообщение, и то, что ничего не создалось. Второе утверждение важнее первого: без него тест пройдёт при реализации, которая возвращает ошибку и всё равно сохраняет страницу.
Где мутационное тестирование мешает
Честно про минусы, потому что инструмент неудобный.
Он медленный. Полный прогон по модулю Docs — 11 минут против 40 секунд обычных тестов. По всему проекту я его не гоняю вообще, только по тому, что сейчас трогаю.
Он оставляет эквивалентные и несущественные мутанты. Изменение логирования может не влиять на наблюдаемое поведение, а точный текст внутренней ошибки не всегда является контрактом. Такие места приходится разбирать вручную и при необходимости исключать в конфигурации.
И порог надо выбирать. Стопроцентный индекс — недостижимая и вредная цель: последние проценты добираются тестами на несущественное. Семьдесят у меня получилось эмпирически, ниже шестидесяти набор перестаёт ловить регрессии.
Побочный эффект
Второй набор нашёл два бага в существующем коде.
Тест на граничный случай с пустым заголовком показал, что генератор слага возвращает пустую строку, и страница создаётся с адресом, ведущим в никуда. Тест на дубль показал, что две страницы в одном пространстве могут получить одинаковый слаг — уникального ограничения не было.
Оба бага жили в проде больше года. Первый набор тестов, с покрытием 82 процента, не заметил ни одного.
Замер
| Заход | Тестов | Покрытие | Индекс мутаций | Найдено багов |
|---|---|---|---|---|
| «Доведи покрытие до 80%» | 128 | 82% | 41% | 0 |
| Критерий по индексу мутаций | 96 | 79% | 74% | 2 |
Время примерно одинаковое: час против часа двадцати. Разница целиком в постановке.
Что дальше
До сих пор все задачи были локальными: один класс, один модуль, понятная граница. В следующем заходе граница исчезает — обновление мажорной версии фреймворка трогает весь проект сразу, и посмотреть на дифф целиком уже невозможно.