Всем привет, меня зовут Сергей Прощаев, и в этой статье расскажу про то, как отличить тест, который действительно защищает код, от теста, который просто красиво горит зелёным. Tech Lead и руководитель направления Java | Kotlin разработки в FinTech & E‑commerce, преподаю на курсах разработки и архитектуры в ОТУС.
Ситуация, с которой начнём. Вы попросили ИИ‑агента написать тесты на класс со скидками. Он выдал сорок с лишним тест‑методов, покрытие показало 100% по строкам, все зелёные, CI довольный. Вы уже тянетесь нажать «Merge». Но часть этих тестов бесполезна: они проходят при любом поведении кода и не поймают ни одного бага. Некоторые даже хуже — закрепляют неправильную логику.
Ниже будет четыре таких теста. Сможете за несколько минут найти те, что дают ложную зелёную галочку, ещё до того, как прочитаете разбор?
Я специально беру это на примере, потому что сам ловлю такие тесты на ревью буквально каждую неделю. Генерация тестов у меня давно ушла к агенту: описываю задачу, получаю два‑три десятка методов, прогоняю, всё зелёное. Удобно. Ровно до того дня, когда в проде вылезает баг на пути, который «покрыт» пятью тестами. Вот тогда и выясняется, что зелёный пайплайн умеет врать.

Дальше — реальный сценарий: код, четыре сгенерированных теста для самопроверки, разбор каждого, правильный ход мысли и в конце ответ на вопрос, какой именно навык вы у себя проверили. Всё, что нужно для работы, можно забрать прямо из статьи, на сайт идти не придётся.
Ситуация: зелёный пайплайн, который врёт
Представим типичную задачу, которую вы вполне можете получить и на собеседовании, и в первый месяц на проекте. Есть сервис начисления скидок в интернет‑магазине. Простой метод: считает итоговую сумму заказа с учётом двух правил. Если сумма больше 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 — тот самый маршрут, по которому проблема тихо проезжает в прод.

Главная мысль, которую стоит вынести из этой схемы: единственная развилка, где всё решается, — это ревью ассертов. Если ревьюер доверяет зелёной галочке и не читает, что именно проверяет каждый тест, то ветка неизбежно уходит вправо‑вниз, к багу в проде. Инструменты генерации сдвинули узкое место: раньше тяжело было тесты писать, теперь тяжело их проверять. И это уже не про скрипты, а про мышление.
Правильный ход мысли: чек‑лист вместо интуиции
Теперь то, ради чего всё затевалось. Как этот навык из интуитивного сделать системным. Мой вариант, который я обычно даю команде, — прогонять каждый сгенерированный тест через несколько вопросов, и все они по сути варианты одного: «а сломается ли этот тест, если код станет неправильным».
Первое. Упадёт ли тест, если поведение метода сломать. Мысленно поменяйте в коде операцию или константу. Если тест при этом остаётся зелёным — он ничего не проверяет.
Второе. Посчитан ли 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 на открытом уроке «Профессия тестировщика в эпоху ИИ — угроза потери работы или суперсила?». Без паники и магического мышления — про навыки, которые становятся важнее, когда тесты начинает писать машина.
Можно почитать по теме:
