Всем привет, меня зовут Сергей Прощаев, и в этой статье расскажу про то, как отличить тест, который действительно защищает код, от теста, который просто красиво горит зелёным. Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce, преподаю на курсах разработки и архитектуры в ОТУС.

Ситуация, с которой начнём. Вы попросили ИИ‑агента написать тесты на класс со скидками. Он выдал сорок с лишним тест‑методов, покрытие показало 100% по строкам, все зелёные, CI довольный. Вы уже тянетесь нажать «Merge». Но часть этих тестов бесполезна: они проходят при любом поведении кода и не поймают ни одного бага. Некоторые даже хуже — закрепляют неправильную логику.

Ниже будет четыре таких теста. Сможете за несколько минут найти те, что дают ложную зелёную галочку, ещё до того, как прочитаете разбор?

Я специально беру это на примере, потому что сам ловлю такие тесты на ревью буквально каждую неделю. Генерация тестов у меня давно ушла к агенту: описываю задачу, получаю два‑три десятка методов, прогоняю, всё зелёное. Удобно. Ровно до того дня, когда в проде вылезает баг на пути, который «покрыт» пятью тестами. Вот тогда и выясняется, что зелёный пайплайн умеет врать.

Рис. 1. О чём эта статья: зелёный пайплайн не гарантирует, что баги пойманы
Рис. 1. О чём эта статья: зелёный пайплайн не гарантирует, что баги пойманы

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

Ситуация: зелёный пайплайн, который врёт

Представим типичную задачу, которую вы вполне можете получить и на собеседовании, и в первый месяц на проекте. Есть сервис начисления скидок в интернет‑магазине. Простой метод: считает итоговую сумму заказа с учётом двух правил. Если сумма больше 100 — скидка 10%. Если товаров в корзине больше 5 — ещё 5%. Правила не суммируются: применяется только большая скидка.

Вот код, который мы тестируем (Java):

// Java
public class DiscountService {

    private static final BigDecimal THRESHOLD = new BigDecimal("100");
    private static final BigDecimal RATE_LARGE_SUM = new BigDecimal("0.10");
    private static final BigDecimal RATE_MANY_ITEMS = new BigDecimal("0.05");

    public BigDecimal applyDiscount(BigDecimal total, int itemCount) {
        if (total == null) {
            throw new IllegalArgumentException("total is null");
        }
        BigDecimal discount = BigDecimal.ZERO;
        if (total.compareTo(THRESHOLD) > 0) {
            discount = RATE_LARGE_SUM;
        }
        if (itemCount > 5 && discount.compareTo(RATE_MANY_ITEMS) < 0) {
            discount = RATE_MANY_ITEMS;
        }
        return total.subtract(total.multiply(discount));
    }
}

Мы попросили ИИ‑агента сгенерировать на этот метод юнит‑тесты. Он выдал больше сорока, покрытие показало 100% по строкам, все зелёные. Ниже — четыре из них, самые характерные. Прежде чем читать разбор, остановитесь и попробуйте сами ответить на один вопрос по каждому: если я сломаю логику метода, этот тест упадёт или нет? Если тест не может упасть при сломанном коде — он бесполезен, каким бы зелёным он ни был.

Тест 1 (Java, JUnit 5):

// Java, JUnit 5
@Test
void testApplyDiscount() {
    BigDecimal result = discountService.applyDiscount(new BigDecimal("200"), 3);
    assertNotNull(result);
}

Тест 2 (Java, JUnit 5):

// Java, JUnit 5
@Test
void testDiscountForLargeOrder() {
    BigDecimal total = new BigDecimal("200");
    int itemCount = 3;
    BigDecimal expected = total.subtract(total.multiply(new BigDecimal("0.10")));
    BigDecimal result = discountService.applyDiscount(total, itemCount);
    assertEquals(expected, result);
}

Тест 3 (Java, JUnit 5):

// Java, JUnit 5
@Test
void testDiscountBoundary() {
    BigDecimal result = discountService.applyDiscount(new BigDecimal("100"), 3);
    assertEquals(new BigDecimal("100"), result);
}

Тест 4 (Java, JUnit 5):

// Java, JUnit 5
@Test
void testBothDiscountsApply() {
    BigDecimal total = new BigDecimal("200");
    int itemCount = 10;
    BigDecimal expected = total.subtract(total.multiply(new BigDecimal("0.15")));
    BigDecimal result = discountService.applyDiscount(total, itemCount);
    assertEquals(expected, result);
}

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

Почему кажется, что всё в порядке

Прежде чем разбирать, скажу про главную ловушку восприятия. Все четыре теста зелёные. Покрытие 100%. Дашборд радует глаз. И именно здесь новичок делает первую ошибку — ставит апрув, не читая, что проверяет каждый тест. Ошибка не в самом approve: поставить его после вдумчивого ревью нормально. Ошибка в том, чтобы одобрить набор тестов, доверившись зелёному статусу вместо разбора ассертов.

Мне как‑то попалась хорошая формулировка на одном инженерном блоге: тест ценен только тогда, когда он может упасть. Скажу точнее, потому что «должен падать» — это красивый крючок, но за ним стоит более строгая мысль: тест должен уметь обнаруживать дефект, то есть различать корректное и некорректное поведение кода. Тест, который проходит при любом поведении, этой способности не имеет — он лишь добавляет стоимость поддержки и ложную уверенность. Вот это держите в голове весь разбор. Мы будем задавать каждому тесту не вопрос «проходит ли он», а вопрос «а поймает ли он поломку, если код станет неправильным».

Разберём по порядку. Первые два — самые показательные.

Разбор: где ловушка в каждом тесте

Тест 1 — пустой.

Это самый частый мусор, который генерирует ИИ. Сразу оговорюсь, чтобы не было перегиба: assertNotNull — не зло сам по себе. Есть места, где это законная проверка: фабрика, которая обязана вернуть объект, метод, возвращающий Optional, создание сессии. Проблема этого теста не в assertNotNull, а в том, что это его единственная проверка. assertNotNull(result) пройдёт при вообще любом ненулевом результате. Метод вернёт неправильную скидку — тест проходит.

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

Тест 2 — слабый, и это неочевидно.

Вот тут большинство новичков попадаются, и тут же важно не впасть в другую крайность. Тест выглядит серьёзным: есть ожидаемое значение, есть assertEquals, проверяется конкретное число. И кое‑что он всё‑таки ловит: если метод вернёт скидку 0%, 20% или вообще null — тест упадёт. Совсем пустым его не назовёшь. Проблема тоньше. Посмотрите, как посчитан expected: той же формулой, что и в самом методе, total.subtract(total.multiply(new BigDecimal(«0.10»))).

И вот ключевой момент, в котором часто путаются: беда не в том, что expected вычисляется. Вычислять ожидаемое значение нормально, если формула отражает спецификацию. Беда в том, что здесь тест дублирует алгоритм тестируемого метода. Если тест создавался копированием реализации — а именно так часто и генерирует ИИ, подсматривая в код метода, — то он и ломается вместе с ней. Скажем, кто‑то решает изменить бизнес‑правило и правит скидку в методе, а заодно машинально приводит к ней и expected в тесте: оба участка едут синхронно и снова совпадают, тест остаётся зелёным при сломанной логике.

Это классический случай correlated bug — ситуации, когда тест и реализация допускают одну и ту же ошибку и поэтому продолжают совпадать. Правильно задать ответ независимо, от спецификации: при заказе на 200 результат должен быть ровно 180, значит assertEquals(new BigDecimal(«180.00»), result) — жёсткое число, которое не поедет вслед за багом в реализации.

Тест 3 — почти хороший, но с миной.

Идея правильная: проверяется граница. Сумма ровно 100, скидка не должна примениться (условие‑то > 100, строго больше). Это как раз тот edge‑case, который ИИ пропускает чаще всего, и хорошо, что он тут есть. Но мина в сравнении BigDecimal. Метод вернёт 100, а вот new BigDecimal(«100») и результат вычисления могут иметь разный масштаб — например 100 против 100.00. assertEquals для BigDecimal использует метод equals, а он в BigDecimal считает 100 и 100.00 разными числами, потому что у них отличается scale.

Такой тест может как проходить, так и падать в зависимости от того, как именно посчиталось значение, и это уже потенциальная нестабильность. Сам по себе сценарий взят верный — проверять надо именно границу, — но реализацию ассерта надо чинить: использовать compareTo, который сравнивает числовое значение независимо от scale, вместо equals.

Тест 4 — противоречит требованиям, и это делает его опасным.

Самый коварный. Смотрите на название: testBothDiscountsApply, «применяются обе скидки». И expected посчитан со скидкой 15% — то есть автор теста исходит из того, что 10% и 5% складываются. Здесь важно быть аккуратным в формулировке. Сам по себе тест не «неправильный» в вакууме: если бы владелец продукта сказал «скидки суммируются», это был бы отличный тест.

Проблема в том, что он противоречит заявленным требованиям — а по нашему условию скидки НЕ суммируются, применяется только большая. Значит, при заказе на 200 и десяти товарах правильный ответ минус 10%, а не минус 15%. Тест зафиксировал понимание требований, расходящееся со спецификацией.

И вот почему это опасно: если разработчик случайно напишет код, который складывает скидки (баг!), этот тест станет зелёным и подтвердит баг. Тест не ловит расхождение с требованием — он его узаконивает. Отсюда правило, которое я всегда проговариваю джунам: тест ценен ровно настолько, насколько верны требования, на которых он построен. Прежде чем писать ассерт, убедитесь, что понимаете правило, которое проверяете.

Подведу черту. Из четырёх тестов: один пустой — проверяет только факт возврата (1), один слабый — дублирует алгоритм и уязвим к correlated bug (2), один взял правильный сценарий, но сломан в реализации (3), один противоречит требованиям и потому закрепляет баг (4). Кстати, обратите внимание и на имена: testApplyDiscount, testBothDiscountsApply — это тоже сигнал.

Единого стандарта именования нет, команды пишут по‑разному:

shouldApplyDiscountForOrdersOver100, returnsDiscountForLargeOrder, встречается и BDD‑стиль givenLargeOrder_whenApplyDiscount_thenReturn180. Мне ближе вариант, где имя читается как утверждение о поведении и включает ожидаемый результат — например applyDiscount_returns180_whenOver100.

А вот имена вида «название метода + пара слов» — типичный почерк генерации: они описывают, что дёргается, но не что проверяется. А пайплайн по всем четырём был зелёный. Вот вам цена доверия к зелёной галочке.

Как это выглядит в реальном процессе

Чтобы было видно, где именно рождается ложная уверенность, посмотрим на путь сгенерированного теста от запроса до мержа. Ниже на Рис. 2 — тот самый маршрут, по которому проблема тихо проезжает в прод.

Рис. 2. Маршрут сгенерированного теста: где именно теряется доверие к пайплайну. Схема упрощена до одного прохода; в реальной команде это цикл — сгенерировали, проверили, поправили промпт, сгенерировали снова, прогнали мутационное тестирование и только потом мержим.
Рис. 2. Маршрут сгенерированного теста: где именно теряется доверие к пайплайну. Схема упрощена до одного прохода; в реальной команде это цикл — сгенерировали, проверили, поправили промпт, сгенерировали снова, прогнали мутационное тестирование и только потом мержим.

Главная мысль, которую стоит вынести из этой схемы: единственная развилка, где всё решается, — это ревью ассертов. Если ревьюер доверяет зелёной галочке и не читает, что именно проверяет каждый тест, то ветка неизбежно уходит вправо‑вниз, к багу в проде. Инструменты генерации сдвинули узкое место: раньше тяжело было тесты писать, теперь тяжело их проверять. И это уже не про скрипты, а про мышление.

Правильный ход мысли: чек‑лист вместо интуиции

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

  • Первое. Упадёт ли тест, если поведение метода сломать. Мысленно поменяйте в коде операцию или константу. Если тест при этом остаётся зелёным — он ничего не проверяет.

  • Второе. Посчитан ли expected независимо от реализации. Если ожидаемое значение вычисляется той же формулой, что в коде, — это тавтология. Ответ должен быть посчитан человеком заранее и зашит числом.

  • Третье. Может ли ассерт пройти на заведомо неверной реализации. Если тест проходит на заглушке, которая возвращает захардкоженное значение, — ассерт слабый.

  • Четвёртое. Проверяет ли тест наблюдаемое поведение, а не внутреннюю кухню. Тесты на конкретные значения и контракты, а не на порядок вызовов и промежуточное состояние.

  • Пятое. Есть ли граничные и негативные сценарии. Без явной инструкции ИИ чаще всего генерирует сценарии счастливого пути. Ноль, отрицательные значения, пустая корзина, ровно 100, пересечение обоих правил — вот где живут баги.

Так должен выглядеть тест 2, переписанный по этим правилам (Java, JUnit 5):

// Java, JUnit 5
@Test
void discountForLargeOrder_returns180() {
    // ответ посчитан руками: 200 - 10% = 180, а не выведен формулой из кода
    BigDecimal result = discountService.applyDiscount(new BigDecimal("200"), 3);
    assertEquals(0, new BigDecimal("180.00").compareTo(result));
}

@Test
void discountsDoNotStack_returns180_not170() {
    // 200, 10 товаров: применяется только большая скидка 10%, не 10%+5%
    BigDecimal result = discountService.applyDiscount(new BigDecimal("200"), 10);
    assertEquals(0, new BigDecimal("180.00").compareTo(result));
}

Разница принципиальная. Здесь ответ зашит числом, compareTo сравнивает значения независимо от scale (снимая проблему 100 против 100.00), а второй тест прямо фиксирует то самое бизнес‑правило про несуммируемость, на котором споткнулся четвёртый тест из генерации.

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

Что делают команды, которые прошли это раньше нас

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

Сначала цифра, которая хорошо отрезвляет. Опрос SmartBear среди 273 руководителей в разработке (данные января 2026, опубликованы в марте), показал: 70% говорят, что качество приложений уже ухудшилось по мере того, как ИИ ускоряет разработку. Причина ровно та, что мы разобрали: код генерируется быстрее, чем команды успевают осмысленно его проверять.

Самый рабочий инструмент против пустых тестов — мутационное тестирование. Идея красивая в своей простоте: специальный инструмент вносит в ваш код мелкие поломки (меняет > на >=, + на ‑, убирает строки) и смотрит, упадёт ли хоть один тест. Если поломку никто не заметил — тесты слабые, каким бы ни было покрытие по строкам. Это прямая проверка на дублирование алгоритма и пустые ассерты.

В экосистеме Java для этого де‑факто стандартом стал PIT, он же Pitest — работает по байткоду, дружит с Maven, Gradle и JUnit 5, показывает не только строчное покрытие, но и mutation score, то есть процент убитых мутантов. Помню, когда я впервые прогнал PIT на проекте с «отличным» покрытием под 90%, оказалось, что почти половину внесённых поломок тесты вообще не поймали. Отрезвляет моментально.

Насколько велика разница между «покрытием» и реальной силой тестов, хорошо видно по цифрам исследований. Исследование MutGen зафиксировало, что обычный промпт к языковой модели даёт 53% mutation score на наборе HumanEval‑Java, и этот показатель не менялся даже после четырёх итераций без обратной связи по мутантам.

А вот когда в цикл генерации начали подавать выжившие мутанты как явные цели, подход с обратной связью по мутациям дошёл до 89,5%.

То есть сам факт «ИИ написал тесты» не значит почти ничего, пока вы не проверили их способность ловить поломки.

Отдельно интересен опыт Meta — пожалуй, самый показательный боевой кейс. Мне попалось их исследование по инструменту, который они в конце 2024 года прогнали на своих внутренних продуктах. Ключевое дизайн‑решение там оказалось контринтуитивным: система не генерирует тесты с нуля, а улучшает уже написанные людьми, и прогоняет каждый кандидат через фильтры, которые отсекают галлюцинированные и бесполезные тесты ещё до показа инженеру.

В результате инженеры приняли 73% сгенерированных тестов. Вдумайтесь: даже у Meta с их ресурсами больше четверти машинных тестов ушло в мусорку, и весь смысл системы был в том, чтобы человек не тратил на этот мусор внимание.

И ещё одна вещь, которая меня в своё время зацепила. Есть соблазн попросить ИИ «улучшить» свой же код по кругу. Так вот, рецензируемое исследование IEEE‑ISTAS 2025 от исследователей из Университета Сан‑Франциско, Vector Institute и Массачусетского университета в Бостоне протестировало 400 сгенерированных фрагментов кода на 40 раундах итеративной доработки и обнаружило рост критических уязвимостей на 37,6% всего после пяти раундов просьб к модели «улучшить» свой код. Мораль простая: цикл, в котором ИИ проверяет сам себя, тихо деградирует. Нужен внешний независимый арбитр, и пока этим арбитром остаётся человек.

Что из этого забрать джуну прямо сейчас, без доступа к инфраструктуре Meta. Три вещи. Задавайте ожидаемый результат от спецификации, а не выводите его формулой из тестируемого кода. Мысленно ломайте код и проверяйте, поймает ли тест поломку. И если есть возможность подключить PIT хотя бы на ключевые модули — подключайте, mutation score покажет правду про ваши тесты честнее любого покрытия по строкам.

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

Какой навык вы сейчас проверили

Давайте назовём вещи своими именами. Задача с четырьмя тестами проверяла у вас не знание JUnit и не синтаксис ассертов. Она проверяла способность к критической оценке тестов — умение смотреть на зелёный тест и спрашивать не «проходит ли он», а «поймает ли он баг». Это навык мышления, а не прокликивания.

Хороший способ проверить себя — пройти бесплатный вступительный тест по направлению «Инженер по тестированию». Не ради оценки, а чтобы увидеть, где уже есть база, а где ещё остаются пробелы.

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

Но у модели нет собственного понимания бизнес‑правил: она опирается на контекст, который получила во входных данных, а при генерации тестов этот контекст чаще всего сводится к самой реализации.

Отсюда всё, что мы видели: она не «знает», что скидки не суммируются, если ей это явно не сказали; единственный assertNotNull она проставит с той же уверенностью, что и осмысленную проверку; и правильный тест, и тот, что закрепит баг, выйдут из‑под неё одинаково зелёными.

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

Если вы дочитали и поймали себя на том, что забраковали не все проблемные тесты из четырёх, — это нормально, и это как раз повод потренировать насмотренность. Именно эта насмотренность делает тестировщика ценным в эпоху, когда писать тесты научилась машина.


Если после этого разбора у вас остался вопрос «а что тогда вообще будет с профессией тестировщика?», его разберём 16 июля в 20:00 на открытом уроке «Профессия тестировщика в эпоху ИИ — угроза потери работы или суперсила?». Без паники и магического мышления — про навыки, которые становятся важнее, когда тесты начинает писать машина.

Можно почитать по теме: