Зачем тесты и чего от тебя ждут на проекте
Для кого. Ты знаешь Java по курсу: классы, методы,
if, циклы, списки. Тесты, может быть, писал раз-другой — а может, не писал вовсе. Пришёл в команду, открыл первый пул-реквест — и увидел красный крестик.Что будет. Что такое тест и чем он лучше проверки глазами; где тесты живут, как их запустить и прочитать итог; какие бывают тесты и чего на проекте ждут от твоих.
Откуда статья. Серия написана для джунов команды, которая пишет на Spring Boot 4 шлюз уведомлений — сервис платформы Hammerhall, который рассылает письма и сообщения. Примеры — учебные: их можно повторить у себя.
Зачем эта серия#
Первый пул-реквест в команде часто выглядит так. Задача сделана, код работает — ты проверил: запустил, посмотрел. Открываешь пул-реквест, и через пару минут рядом с ним красный крестик: проверка «Build & Test» не прошла. Внутри — несколько сотен строк журнала сборки. А в комментарии ревьюер спрашивает одно: «Где тесты?»
В курсах тесты обычно идут последней главой: «а ещё бывают тесты». На проекте наоборот. Тесты — первое, что смотрит ревьюер, и последнее, что пропускает сборка. В шлюзе это записано правилом: задача без тестов не принимается, как бы хорошо она ни работала.
Эта серия — о тестах с самого начала, в семи статьях:
- зачем тесты и чего от тебя ждут — эта статья;
- первый модульный тест;
- тест покраснел — что делать;
- какие случаи проверять;
- тест, который ничего не проверяет;
- когда вокруг твоего кода другой код — заглушки;
- тест с настоящей базой.
Большую серию «От Java к Spring Boot» для этого читать не нужно; где тема там уже разобрана, статья подскажет, куда заглянуть.
Сквозной пример — зачёт по сроку. Задачу сдают к сроку, и балл зависит от того, когда её сдали:
| когда сдал | балл |
|---|---|
| до срока или ровно в срок | 100 |
| позже, но не больше чем через сутки | 50 |
| ещё позже | 0 |
| срока или времени сдачи нет | ошибка с понятным текстом |
Правило учебное: в платформе и в задачах команды его нет. А рядом — живое: правила шлюза и истории платформы Hammerhall — рассказом, без её кода.
🔑 Главная мысль серии, к которой мы будем возвращаться: зелёному тесту верят, только если видели его красным. Почему — увидишь уже в первой части.
Часть 1. Тест — программа, которая проверяет программу
Проверка глазами#
Вот правило зачёта в коде. Срок и время сдачи — Instant:
точка на шкале времени, без часового пояса. Duration —
отрезок времени.
public final class DeadlineScore {
public static final int FULL = 100;
public static final int HALF = 50;
public static final int NONE = 0;
static final Duration GRACE = Duration.ofHours(24); // сколько можно опоздать за половину
public static int score(Instant deadline, Instant submittedAt) {
// … проверки на null — в полном примере
if (!submittedAt.isAfter(deadline)) { // не позже срока — вовремя
return FULL;
}
if (!submittedAt.isAfter(deadline.plus(GRACE))) { // не позже срока плюс сутки — половина
return HALF;
}
return NONE;
}
}isAfter отвечает «позже ли»; ! перед ним —
«не позже», то есть раньше или ровно. В коротких
примерах нет строк import — полные файлы в конце
статьи.
Как проверить, что правило работает? В курсах обычно так: пишут
main, запускают и смотрят глазами.
class ManualCheck {
void main() {
Instant deadline = Instant.parse("2026-10-12T21:00:00Z"); // полночь 13 октября по Москве
IO.println(DeadlineScore.score(deadline, deadline.minusSeconds(60))); // ждём 100
IO.println(DeadlineScore.score(deadline, deadline.plusSeconds(3600))); // ждём 50
IO.println(DeadlineScore.score(deadline, deadline.plus(Duration.ofDays(2)))); // ждём 0
}
}⚠️ В курсах ты видел
public static void main(String[] args) и
System.out.println. Это работает и сейчас. С Java 25 можно
короче: void main() без static и параметров
Java запускает сама, а IO — класс из
java.lang, подключать его не нужно. В серии Java 25, так и
пишем.
Z в конце времени — время по Гринвичу (UTC); Москва на
три часа впереди, поэтому 21:00 по Гринвичу — её полночь.
Запускаешь — в консоли 100, 50,
0. Сверяешь с комментариями — сходится. Готово?
Для учебной задачи — да. Для проекта у такой проверки три беды.
Сравнивает человек, а не программа. Программа печатает числа, а сверяешь их ты. Человек устаёт, торопится и видит то, что ожидает увидеть.
Её надо повторять — и никто не повторит. Через месяц
в этот класс придёт другой человек со своей задачей. Про
ManualCheck он не знает, а если и знает — сверять глазами
снова придётся ему.
Случаев больше трёх. Ровно в срок, секунда в секунду? Ровно через сутки? Срока нет вовсе? На каждый случай — ещё строка печати и ещё одно сравнение глазами.
Ошибка, которую проверка глазами не видит
Представь, что кто-то решил «упростить» первое условие:
if (submittedAt.isBefore(deadline)) { // было: !submittedAt.isAfter(deadline)
return FULL;
}Читается почти так же: «если сдал раньше срока — полный балл».
Запускаем ManualCheck — снова 100,
50, 0. Всё сходится.
А ошибка есть. Сдавший ровно в срок до правки получал 100, а после неё — 50: «раньше срока» и «не позже срока» различаются ровно в одной точке — в самом сроке. Среди трёх наших проверок такой точки нет, поэтому они ничего не заметили.
Проверка находит ошибку, только если среди её случаев есть тот, на котором ошибка видна. Какие случаи брать — в продолжении серии. А пока — как сделать, чтобы проверку не надо было повторять руками.
Автоматический тест#
Тест — программа, которая сама вызывает твой код, сама знает, какой результат правильный, и сама сравнивает. Человеку она говорит одно из двух: всё сошлось — тест зелёный; не сошлось — тест красный, и вот что именно не сошлось.
Вот тест на тот самый случай «ровно в срок». @Test и
assertThat разберём сразу под ним.
class DeadlineScoreTest {
@Test // «это тест» — JUnit найдёт и запустит
void onDeadlineIsStillOnTime() {
Instant deadline = Instant.parse("2026-10-12T21:00:00Z");
int score = DeadlineScore.score(deadline, deadline); // сдал секунда в секунду
assertThat(score).isEqualTo(100); // «утверждаю, что балл — 100»
}
}Ожидание записано в самом тесте: помнить его не нужно — тест нужно запускать. С правильным условием он зелёный. С «упрощённым» — покраснеет и скажет:
expected: 100
but was: 50«Ожидалось 100, получено 50». Сверять глазами больше нечего. И заметь: этот тест мы видели красным — на настоящей ошибке. Значит, когда он зелёный, этому можно верить. Тест, который ни разу не краснел, может и вовсе ничего не проверять — о таких в продолжении серии.
Два инструмента из этого примера ты будешь видеть каждый день.
JUnit — самая распространённая в Java библиотека для
тестов. Ты пишешь методы и помечаешь их @Test. Это
аннотация — пометка над методом; сама она ничего не
делает, её читает инструмент. JUnit находит помеченные методы, запускает
и сообщает, какие прошли, а какие упали; main не нужен.
Приносит его набор библиотек spring-boot-starter-test — в
проекты, собранные на start.spring.io, его кладут сразу.
⚠️ В интернете ты увидишь JUnit 4 (org.junit.Test) и
JUnit 5. В серии JUnit 6, и пишется он как пятый: те же пакеты
org.junit.jupiter. Примеры для пятого почти всегда годятся,
для четвёртого — нет.
AssertJ — библиотека проверок из того же набора.
assertThat(score).isEqualTo(100) читается как фраза:
«утверждаю, что балл равен 100»; не сошлось — пишет, что ждали и что
пришло.
Регрессия: тест — для того, кто придёт потом
Сегодня твой тест почти не нужен: ты и так только что всё проверил. Он нужен завтра — когда код будет менять другой человек или ты сам через месяц, забыв подробности.
Регрессия — ошибка в том, что раньше работало: правка в одном месте сломала другое. Защита от неё одна — после каждой правки запускать все тесты, а не только свои. Каждый тест — чьё-то записанное знание «здесь должно быть так». Запуская их, ты спрашиваешь всех, кто писал код до тебя: «я ничего вам не сломал?»
На платформе Hammerhall таких вопросов больше тысячи трёхсот; сборка вместе с ними идёт около трёх минут, и через неё проходит каждая правка. Никто не помнит, что проверяет каждый тест, — и не должен.
Часть 2. Где тесты живут и как их запустить
Папка src/test#
Код и тесты лежат рядом, но раздельно:
src/
├── main/java/example/testy/score/DeadlineScore.java ← код
└── test/java/example/testy/score/DeadlineScoreTest.java ← его тест- Тест к классу — в
src/test/java, в том же пакете, что и класс. Так тест видит и то, что объявлено без модификатора доступа, какGRACEу нас. И найти его легко: тот же путь, другая верхняя папка. - Имя класса с тестами — имя класса плюс
Test:DeadlineScoreTest. - Файлы, нужные только тестам, — образцы ответов, особые настройки —
лежат в
src/test/resources.
Maven: шаги сборки и шаблоны имён
Тесты на работе запускает не IDE, а сборка.
Maven — программа, которая собирает проект по
описанию в pom.xml: скачивает библиотеки, компилирует код и
тесты, запускает тесты. В проект он приходит обёрткой mvnw,
которая сама скачает нужную версию. Подробно — статья 0 «Что нужно знать до
Spring» в рубрике «Статьи».
Сборка идёт шагами по порядку: компиляция,
test, упаковка, …, verify. Команда с именем
шага проходит все шаги до него: ./mvnw verify проходит и
через test. Библиотеки только для тестов отмечены в
pom.xml пометкой
<scope>test</scope> и в готовое приложение не
попадут.
Тесты на шаге test запускает Surefire —
плагин, дополнение, которое Maven подключает сам. Какие классы считать
тестами, он решает по имени. По умолчанию — классы, имя
которых:
- начинается на
Test—TestDeadline; - кончается на
Test—DeadlineScoreTest; - кончается на
Tests—TestyApplicationTests; - кончается на
TestCase—DeadlineTestCase.
Всё остальное он пропускает молча. Класс
ScoreRulesCheck с десятком тестов Maven не запустит
никогда, а в IDE он будет зелёным: IDE запускает то, что ты ей
показал.
Интеграционные тесты — с настоящей базой, о них в продолжении серии,
— в шлюзе называются …IT, а такого окончания в списке нет.
Поэтому в pom.xml шлюза шаблоны заданы заново:
*Test и *IT. Заданный список
заменяет умолчания: класс …Tests в шлюзе
не запустится. Называй тесты …Test или …IT.
Имя вида …IntegrationTest кончается на Test —
такое Surefire находит и без настройки.
⚠️ В интернете ты увидишь, что *IT запускает отдельный
плагин Failsafe, позже в сборке. В шлюзе проще: Surefire запускает
*Test и *IT в одном прогоне.
Тесты, зелёные только в IDE#
На платформе Hammerhall так однажды и случилось: два класса
интеграционных тестов были зелёными — но только в IDE. Сборка их не
запускала: имя кончалось на IT, а шаблона для него в
pom.xml ещё не было. Код, который они должны были стеречь,
не стерёг никто, а все думали, что стерегут.
Тест, который не запускается, хуже, чем никакого: его бездействия не видно, он выглядит защитой. Проверка простая — добавил тест, и число тестов в отчёте сборки выросло ровно на столько же.
Запуск#
- В IDE — зелёный треугольник рядом с классом теста или с одним методом. Удобно, пока пишешь: запускаешь только своё.
- Все тесты —
./mvnw test. На Windows — так же в Git Bash; в обычной командной строке —mvnw.cmd test. - Один класс —
./mvnw test -Dtest=DeadlineScoreTest. - Всё, что проверит пул-реквест, —
./mvnw verify: тесты плюс шаги после них; в шлюзе среди них — проверка покрытия, о ней в части 4. Перед пушем — именно её и целиком: твои зелёные — половина вопроса, вторая — не сломал ли ты чужие.
Отчёт#
В конце прогона Maven пишет итог. В песочнице из «Примера целиком» зелёный выглядит так — два теста: твой и тест от start.spring.io:
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESSКрасный — примерно так:
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE- Failures — проверка не сошлась: ждали одно,
получили другое, как наш
expected: 100 but was: 50. - Errors — тест не дошёл до проверки: по дороге
вылетело исключение, которого никто не ждал, например
NullPointerException. - Skipped — тест пропущен: отключён или не подошли условия запуска.
Где искать подробности — в продолжении серии. Пока запомни: красная сборка — не приговор, а адрес. Она говорит, где смотреть.
Проверка пул-реквеста на GitHub
Прогона у себя мало: можно забыть, прогнать не всё, а на твоей машине может быть то, чего нет у других.
GitHub Actions — сервис GitHub, который по событию в
репозитории — открытый пул-реквест, новый пуш — запускает команды на
машине GitHub. Что запускать, описано в файлах
.github/workflows/*.yml самого репозитория; ставить ничего
не нужно. Итог виден в пул-реквесте: галочка или крестик у каждой
проверки, по ссылке Details — весь журнал.
⚠️ У тебя зелёное, а в пул-реквесте красное — это не «GitHub глючит».
Чаще всего виноват файл, забытый в коммите; тест, который проходит
только в определённом порядке; старые файлы в твоей target,
которых у сборки с нуля нет. Перезапускать, пока не позеленеет, — не
выход; как разбираться — в продолжении серии.
Часть 3. Какие бывают тесты#
От модульного до нагрузочного
Названия тестов в разных командах разные. Ниже — слова, которыми пользуется команда шлюза. Для таблицы: Spring — библиотека, на которой написаны наши сервисы, она сама создаёт и связывает объекты приложения, но поднимается не мгновенно; ручка — адрес, на который отвечает сервис.
| вид | что проверяет | сколько идёт |
|---|---|---|
| модульный | один класс — без базы, сети и Spring | миллисекунды |
| тест настроек | приложение не стартует с неверной настройкой | доли секунды |
| веб-слоя | ручка: код ответа и его текст — без базы | секунды |
| интеграционный | код вместе с настоящей базой | секунды |
| сквозной | всё приложение, от запроса до базы | секунды и дольше |
| конфигурации и дымовая | файлы разбираются; всё вместе поднимается | минуты |
| сторож | правило проекта, по файлам: «цикл пакетов роняет сборку», «миграции — в своей схеме» | секунды |
| контрактный | два сервиса одинаково понимают общие данные | секунды |
| нагрузочный | сколько выдержит система и где упрётся | минуты и часы |
Модульные, веб-слоя, интеграционные и сквозные пишутся тем же JUnit и отличаются тем, сколько настоящего вокруг кода: ничего; кусок Spring; база; всё приложение. Сторож — тоже JUnit, но проверяет файлы проекта. У мониторинга и нагрузки инструменты свои.
Дымовая проверка (smoke test) названа, по самой известной версии, по электронике: включили новую плату — не пошёл ли дым. Не «всё ли правильно», а «живо ли вообще».
Нагрузочный тест не проверяет правильность ответа: он спрашивает, сколько система выдержит и что сломается первым.
Пирамида#
Чем больше настоящего вокруг кода, тем тест медленнее, тем дольше его писать — и тем хуже он показывает, где ошибка. Модульный упал — виноват один метод; сквозной упал — что-то где-то между запросом и базой. Зато чем больше настоящего, тем ближе тест к жизни: модульный не заметит ошибки в SQL-запросе — базы в нём нет.
Отсюда картинка, которую рисуют в каждой книге о тестах:
╱╲
╱ ╲ сквозные — единицы: дорого, медленно, «где-то сломалось»
╱────╲
╱ ╲ интеграционные — десятки: база, секунды
╱────────╲
╱ ╲ модульные — сотни: миллисекунды, точный адрес ошибки
╱────────────╲Пирамида тестов: много дешёвых внизу, мало дорогих наверху. Её придумал Майк Кон; коротко о ней написал Мартин Фаулер — ссылка в первоисточниках. Правило из неё простое: каждый случай проверяй на самом нижнем этаже, где он виден. Ошибку в формуле зачёта ловит модульный тест за миллисекунды, ошибку в SQL — только интеграционный, а поднимать ради формулы приложение целиком незачем. Нагрузочные тесты в пирамиду не входят: это отдельный вид, дороже любого из них.
Но и одних нижних мало: каждая проверка отвечает только на свой вопрос. В мониторинге платформы это записано прямо в заголовке его проверки: «Второе важнее первого. „Файл разобрался“ не значит „Grafana нашла источник, а Prometheus достучался до целей“». Поэтому там после проверки файлов весь стек поднимается целиком — и смотрят, что получилось.
Часть 4. Чего от тебя ждут#
Правила, записанные в шлюзе#
В правилах работы шлюза Hammerhall про тесты сказано прямо. Пересказ:
- Задача принимается только с тестами, в одном пул-реквесте с решением.
- Тесты пишутся по таблице задачи: строка таблицы — проверка.
- Новый код задачи покрыт полностью — каждая строка и каждая ветвь. Что тестом не исполнить, названо в пул-реквесте с причиной.
- Порог покрытия всего проекта — 80% строк и 70% ветвей. Ниже — сборка красная. Порог только растёт: чтобы влить задачу, дописывают тесты, а не опускают порог.
- ⚠️ Покрытие — не доказательство. Тест без проверки результата даёт покрытие и не ловит ничего. Ревьюер смотрит, что проверяет тест.
Покрытие — доля кода, которую тесты исполнили.
Считает его JaCoCo — инструмент, который во время
прогона отмечает исполненные строки и пройденные ветви
— пути через if, «да» и «нет»; с ветками git не путай.
JaCoCo проверяет порог на ./mvnw verify, отчёт —
target/site/jacoco/index.html; подробно — в продолжении
серии. 100% — для твоего нового кода, 80 и 70 — для проекта целиком.
И про помощников. Джуну шлюза по правилам можно пользоваться только бесплатными языковыми моделями и только чтобы объяснить — упавший тест, чужой код. Написать тест за него — нельзя. Причина не формальная и годится не только для шлюза: тест — твоё понимание задачи, записанное кодом. Чужое понимание на ревью не защитишь.
Требования к тестам в задаче#
В шлюзе у задач джуна с кодом есть раздел «Требования к тестам»: какие тесты написать, как назвать и что каждый проверяет — по пунктам. Это не подсказка, а часть задачи — та самая «таблица задачи», где каждый пункт — проверка. Без этих тестов задача не сдана, даже если всё работает.
Неписаные правила#
Этого в правилах нет, но ревьюер спросит.
- Чужой тест — запись обещания. Твоя правка уронила чужой тест — сначала прочитай его: что он охраняет и почему. Возможно, неправ не тест, а правка. На платформе однажды так спасли оплаченный людьми доступ — эта история в продолжении серии.
- Красное не прячут: не удаляют, не отключают, не подгоняют ожидание под то, что получилось. Подогнанный тест зелёный — и стережёт уже неправильное поведение. Что делать вместо этого — в продолжении серии.
- Тест должен уметь покраснеть. Написал — сломай код руками и посмотри, покраснеет ли, как мы сделали в части 1. Не покраснел — ничего не проверяет.
- Тест прибирает за собой. Тесты проекта идут друг за другом в одной программе и с одной базой. Тест, который поменял общее и не вернул, роняет чужие — и только при определённом порядке запуска. Про базу — в продолжении серии.
Чек-лист пул-реквеста с тестами
Проверь себя#
Ответь своими словами — вслух или на бумаге. Не получается — перечитай раздел.
- Чем плоха проверка через
mainи печать, если сегодня она показала правильные числа? - Почему замена
!submittedAt.isAfter(deadline)наsubmittedAt.isBefore(deadline)не видна вManualCheck? Какой случай её ловит? - Что такое регрессия, и при чём тут тесты, которые писал не ты?
- Класс
ScoreRulesCheckс тестами в IDE зелёный. Почему сборка его не запустит, и как заметить, что тест не запускается? - Чем
Failuresотличается отErrorsв отчёте Maven? - Почему модульных тестов в проекте больше, чем сквозных, хотя сквозные ближе к жизни?
- Твоя правка уронила чужой тест, и он мешает влить задачу. Что сделать первым делом и чего не делать?
Что дальше#
Статья 2 — первый модульный тест. Пишем
DeadlineScoreTest с нуля и разбираем каждую строку: класс
теста, метод, проверку — и как назвать тест, чтобы, упав, он сам сказал,
что сломалось.
Первоисточники#
- JUnit
6 — Annotations —
@Testи другие пометки JUnit. - AssertJ — документация — начни с Quick Start.
- Maven Surefire — Inclusions and Exclusions of Tests — шаблоны имён, по которым Maven находит тесты.
- Martin Fowler — Test Pyramid — короткая заметка о пирамиде.
- GitHub Docs — About protected branches — раздел об обязательных проверках перед слиянием.
Пример целиком — с импортами#
Учебный пример. Живёт в своей песочнице — маленьком проекте, где всё запускается сразу.
Песочница testy. Сайт start.spring.io
(Spring Initializr) собирает заготовку проекта за минуту. Поля:
| поле | значение |
|---|---|
| Project | Maven |
| Language | Java |
| Spring Boot | 4.1.x — последняя 4.1 |
| Group | example |
| Artifact | testy |
| Package name | example.testy |
| Java | 25 |
Зависимости не добавляй: без них Initializr кладёт
spring-boot-starter-test сам, и он уже несёт JUnit, AssertJ
и Mockito. Нужен JDK 25 — с JDK из курса, 17 или 21, сборка упадёт с
release version 25 not supported.
Generate скачает архив; распакуй и открой в IDE папку с
pom.xml. Свои классы клади в подпакет
example.testy.score. Initializr создаст ещё
TestyApplication и тест TestyApplicationTests
с пустым методом contextLoads — «приложение хотя бы
поднимается». Не трогай их.
import static в тесте подключает статический метод
assertThat, чтобы писать его без имени класса.
// src/main/java/example/testy/score/DeadlineScore.java
package example.testy.score;
import java.time.Duration;
import java.time.Instant;
/**
* Зачёт по сроку: сколько баллов за задачу, сданную в такое-то время.
* Учебное правило серии «От красного к зелёному».
*/
public final class DeadlineScore {
public static final int FULL = 100; // вовремя
public static final int HALF = 50; // опоздал, но не больше чем на сутки
public static final int NONE = 0; // опоздал сильнее
static final Duration GRACE = Duration.ofHours(24); // сколько можно опоздать за половину
private DeadlineScore() { // объекты не нужны: только метод
}
public static int score(Instant deadline, Instant submittedAt) {
if (deadline == null) {
throw new IllegalArgumentException("Срок не указан");
}
if (submittedAt == null) {
throw new IllegalArgumentException("Время сдачи не указано");
}
if (!submittedAt.isAfter(deadline)) { // ровно в срок — ещё вовремя
return FULL;
}
if (!submittedAt.isAfter(deadline.plus(GRACE))) { // ровно через сутки — ещё половина
return HALF;
}
return NONE;
}
}// src/main/java/example/testy/score/ManualCheck.java
package example.testy.score;
import java.time.Duration;
import java.time.Instant;
/** Проверка глазами: запустить и сравнить напечатанное с ожидаемым. */
class ManualCheck {
void main() {
Instant deadline = Instant.parse("2026-10-12T21:00:00Z"); // полночь 13 октября по Москве
IO.println(DeadlineScore.score(deadline, deadline.minusSeconds(60))); // ждём 100
IO.println(DeadlineScore.score(deadline, deadline.plusSeconds(3600))); // ждём 50
IO.println(DeadlineScore.score(deadline, deadline.plus(Duration.ofDays(2)))); // ждём 0
}
}// src/test/java/example/testy/score/DeadlineScoreTest.java
package example.testy.score;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class DeadlineScoreTest {
@Test
void onDeadlineIsStillOnTime() {
Instant deadline = Instant.parse("2026-10-12T21:00:00Z");
int score = DeadlineScore.score(deadline, deadline);
assertThat(score).isEqualTo(100);
}
}Сделай руками то, что в части 1 было на словах:
- Запусти
ManualCheck—100,50,0. Запусти./mvnw test—Tests run: 2, всё зелёное. - Замени в
DeadlineScoreпервое условие наif (submittedAt.isBefore(deadline)). Снова запусти оба.ManualCheckпечатает то же самое. Тест — красный:expected: 100,but was: 50. - Верни условие как было. Тест снова зелёный.
Теперь ты знаешь про этот тест главное: он умеет покраснеть, и краснеет ровно тогда, когда правило нарушено. Такому зелёному можно верить.