almdev Технический блог

Агент пишет тесты, которые проходят, а не ловят
Покрытие модуля выросло с 43 до 82 процентов, но 59 процентов мутаций пережили новые тесты.

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

Подкатегория: Лаборатория

Чтение
5 мин
Технологии / версии
Coding agent · PHP · Лаборатория · Тесты
Серия
Лаборатория · часть 5 из 9
16 июл 2026 · 5 мин · 3 просмотра · AI
Тестовые лучи пропускают несколько изменённых шестерён механизма
Лаборатория 07/2026

Просьба «напиши тесты» звучит как задача с очевидным критерием: тесты есть, тесты зелёные, покрытие выросло. Все три условия выполняются легко и по отдельности не значат ничего.

Пятый заход серии. Стенд прежний: 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%»12882%41%0
Критерий по индексу мутаций9679%74%2

Время примерно одинаковое: час против часа двадцати. Разница целиком в постановке.

Что дальше

До сих пор все задачи были локальными: один класс, один модуль, понятная граница. В следующем заходе граница исчезает — обновление мажорной версии фреймворка трогает весь проект сразу, и посмотреть на дифф целиком уже невозможно.

Серия

Лаборатория

#coding-agent #php #lab #testy #phpunit