Тест, который ничего не проверяет
Для кого. Ты прочитал статьи 1–4 этой серии: у тебя есть песочница
testy, таблица случаев к зачёту по сроку и привычка ломать код руками. Серия «От Java к Spring Boot» не нужна.Что будет. Как выглядит тест, который не умеет покраснеть, и как его починить; что показывает покрытие JaCoCo и о чём оно молчит; как проверить сам тест — сломать код по общему списку поломок.
Откуда статья. Раздел «Буткемп / Лаборатория» — о том, как пишут код команды Hammerhall: буткемп работает в открытых репозиториях, лаборатория — в закрытых. Серия «От красного к зелёному» — как работать с тестами на проекте: писать их, читать красную сборку и верить зелёному тесту, только если видел его красным. Примеры — учебные: их можно повторить у себя.
Зачем проверять тесты#
В статье 1 «Зачем тесты и чего от тебя ждут на проекте» осталось обещание: тест, который ни разу не краснел, может и вовсе ничего не проверять. Эта статья — о таких тестах.
Тест — тоже программа, и ошибки в нём тоже бывают. Только ошибка в коде делает тест красным, а ошибка в тесте обычно не делает красным ничего. Такой тест останется зелёным, даже когда код сломают. Со стороны он выглядит защитой, как тест, который сборка не запускает (статья 1).
Пример прежний: зачёт по сроку, DeadlineScore и итоговые
тесты статьи 4
«Какие случаи проверять». Напишем к нему тесты, которые не проверяют
ничего, и посмотрим на них глазами покрытия. Потом испортим код пятью
способами и узнаем, кто это заметит.
Ломать код и смотреть, кто покраснеет, ты уже пробовал в статьях 2 и 4. Сегодня у приёма появится имя, а поломки сложатся в общий список, который обещала статья 4.
Часть 1. Четыре способа быть зелёным всегда
Вот четыре теста на DeadlineScore из учебного файла
EmptyTest. Все зелёные, и писать так не надо; целиком файл
— в конце статьи. DEADLINE — та же константа, что в статье
2: полночь 13 октября по Москве. Строк import в коротких
примерах, как всегда, нет.
Вызвал — и не проверил#
@Test
void noAssertion() {
DeadlineScore.score(DEADLINE, DEADLINE); // вызвали — и всё
}Тест вызывает код и выбрасывает результат. Проверки нет, значит,
падать нечему. Вернёт score 100, 50 или минус сорок — тест
зелёный. Покраснеть он может только от исключения, которое вылетит по
дороге.
Починка — ожидание, записанное в самом тесте числом, как в статье 2:
@Test
void onDeadlineIsStillOnTime() {
int score = DeadlineScore.score(DEADLINE, DEADLINE);
assertThat(score).isEqualTo(100);
}Ждал исключение — и проглотил#
@Test
void swallowsException() {
try {
DeadlineScore.score(null, DEADLINE);
} catch (IllegalArgumentException expected) {
// ждали исключение — и молча проглотили; а если его не будет?
}
}Задумано верно: без срока score обязан бросить
исключение. Бросит — catch его поймает, и тест зелёный. Не
бросит — try закончится спокойно, и тест снова зелёный. Оба
исхода дают один цвет. Значит, тест не отличает правильный код от
сломанного.
Починка — assertThatThrownBy из статьи 4. Он сам
вызывает код через лямбду () ->, сам ловит исключение и
падает, если исключения не было. Заодно проверяет тип и
текст:
@Test
void noDeadlineIsAnError() {
assertThatThrownBy(() -> DeadlineScore.score(null, DEADLINE))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Срок не указан");
}⚠️ В старых примерах ты увидишь другой способ: в try
сразу за вызовом стоит fail(), который роняет тест, а
catch пустой. Дошли до fail() — значит,
исключения не было. Способ работает, пока fail() на месте.
Забыл одну строку — получил swallowsException. В
assertThatThrownBy забыть нечего.
Сверил код с самим собой#
@Test
void agreesWithItself() {
Instant hourLate = DEADLINE.plus(Duration.ofHours(1));
assertThat(DeadlineScore.score(DEADLINE, hourLate))
.isEqualTo(DeadlineScore.score(DEADLINE, hourLate)); // код сверяется сам с собой
}Проверка есть, но ожидание посчитано тем же кодом, что и результат. Что бы код ни ответил, с собой он согласится.
В жизни это выглядит хитрее. Ожидание берут из той же константы
(DeadlineScore.HALF), из того же вспомогательного метода
или пересчитывают в тесте той же формулой. В статье 2 ожидание поэтому и
стало числом, а не константой: ошибся в константе, и тест с ней
согласится.
Ожидание записывают независимо от кода: из правила,
из задачи, руками. Для опоздания на час правило говорит «50», и тест
статьи 2 так и пишет: assertThat(score).isEqualTo(50).
Проверил то, что верно всегда#
@Test
void alwaysTrue() {
assertThat(DeadlineScore.score(DEADLINE, DEADLINE)).isBetween(0, 100); // верно при любом правиле
}isBetween(0, 100) значит «от 0 до 100 включительно».
Баллов у зачёта три: 100, 50 и 0, и все лежат между нулём и сотней.
Перепутай границу, сдвинь сутки, отдай опоздавшему полный балл. Балл
останется одним из трёх, и проверка будет истинной.
Так же устроены «не null» и «список не пуст». Если ответ
известен точно, проверяй точно: «сдал ровно в срок — 100». Это строка
PT0S | 100 в таблице статьи 4.
Проверить тест на пустоту можно, не запуская его. Придумай правдоподобную ошибку в правиле, какую мог бы сделать коллега, и спроси: покраснеет ли тест? У всех четырёх ответ «нет».
Помощник, который согласился с проверяемым
Тест, согласный сам с собой, умеет прятаться в одну удобную строку. В рабочем проекте так вышло с защитой от подделанных запросов. Сервер выдаёт браузеру секретный знак, а браузер прикладывает его к каждому запросу, который что-то меняет.
В тестах знак подкладывал помощник из тестовой библиотеки, одной строкой на тест. Брал он знак изнутри, у того же механизма сервера, и подавал ровно так, как механизм ждёт. Однажды механизм настроили не до конца, и знак от настоящего браузера сервер разобрать не мог. Регистрация из браузера не работала. А 429 тестов, включая тесты регистрации, были зелёными: заметил это человек, отправив запрос руками.
Хуже того, помощник подменял механизм на весь прогон. Правильный тест, идущий путём браузера, в полном прогоне краснел. И рука тянулась «починить» его тем же помощником.
После этого там завели правило. У каждого механизма, которым пользуется внешний клиент, хотя бы один тест идёт тем же путём, что клиент, без тестовых помощников. Помощник экономит строки и ровно поэтому обходит то, что ты проверяешь.
Часть 2. Покрытие: где тесты побывали
В итогах прогона пустой тест не отличить: он такой же зелёный, как все. Нужен инструмент, который смотрит внутрь.
JaCoCo в песочнице#
Покрытие — доля кода, которую тесты исполнили
(статья 1). Считает его JaCoCo, плагин Maven, как
Surefire. Во время прогона он отмечает исполненные строки и пройденные
ветви, то есть дороги if: «да» и «нет». Как маркер, которым
ведут по пройденным строкам: после прогона видно, где тесты были, а где
ни разу.
В учебных проектах JaCoCo почти не встретишь, а на работе без него редко обходятся. Тестов там сотни, и никто не помнит, куда заходит каждый. JaCoCo разобран в статье 0 «Что нужно знать до Spring» цикла «От Java к Spring Boot». Статьи 1 и 4 обещали вернуться к нему подробнее, и здесь он — с нуля.
Ставить его отдельно не нужно: несколько строк в
pom.xml, и Maven сам его скачает. В рабочем проекте он уже
подключён. В песочницу добавим сами, рядом с плагином, который положил
Initializr:
<!-- pom.xml песочницы testy: что добавляет статья 5 — внутри <build><plugins> -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.15</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal> <!-- подключить счётчик к прогону тестов -->
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal> <!-- отчёт в target/site/jacoco -->
</goals>
</execution>
</executions>
</plugin>Каждая работа плагина — блок <execution>. В
<goal> сказано, что сделать, в
<phase> — на каком шаге сборки, а
<id> — имя блока. Работ здесь две.
prepare-agent встаёт на свой шаг по умолчанию, ещё до
тестов. Он подключает к их прогону агента: маленькую
программу, которую Java загружает вместе с тестами. Агент и ведёт
маркером. report на шаге verify собирает из
отмеченного отчёт.
⚠️ В интернете ты увидишь примеры с JaCoCo 0.8.8 или 0.8.11. Официально Java 25 поддерживается с JaCoCo 0.8.14, а старые версии спотыкаются о классы новых JDK. В песочнице — 0.8.15, как и в рабочем проекте.
Запуск — ./mvnw clean verify. Отчёт — файл
target/site/jacoco/index.html, открой его в браузере.
⚠️ clean здесь не для порядка. JaCoCo
дописывает отметки нового прогона к старым, если они
остались в target. Без clean в отчёт попадёт и
то, что исполняли тесты прошлых запусков.
Как читать отчёт#
Отчёт — страницы, вложенные друг в друга: пакеты, классы пакета,
методы класса, исходник. У каждого пакета и класса в таблице два
процента (Cov.). Первый — по инструкциям: это мелкие шаги,
на которые Java раскладывает каждую строку кода. Второй — по ветвям.
Строки даны числами: сколько пропущено (Missed) из скольких
(Lines).
Нажми на пакет example.testy.score, потом на
DeadlineScore. Откроется твой код, раскрашенный
построчно:
- зелёная строка исполнена полностью;
- жёлтая — частично: обычно это условие, у которого тесты прошли одну дорогу из двух;
- красная не исполнилась ни разу.
У строк с if слева стоит ромбик того же цвета. Зелёный
значит, что тесты прошли обе ветви, жёлтый — одну, красный — ни
одной.
⚠️ Красное в отчёте — не «тест упал», а «сюда тесты не заходили».
Сто процентов без единой проверки
Вот второй учебный файл, HollowTest. Тест в нём написан
нарочно. Он заходит в каждую ветвь score: вовремя,
половина, ноль, нет срока, нет времени сдачи. И не проверяет ничего.
class HollowTest {
private static final Instant DEADLINE = Instant.parse("2026-10-12T21:00:00Z");
@Test
void touchesEveryLine() {
DeadlineScore.score(DEADLINE, DEADLINE); // ветвь «вовремя»
DeadlineScore.score(DEADLINE, DEADLINE.plus(Duration.ofHours(1))); // ветвь «половина»
DeadlineScore.score(DEADLINE, DEADLINE.plus(Duration.ofDays(2))); // ветвь «ноль»
try {
DeadlineScore.score(null, DEADLINE); // ветвь «нет срока»
} catch (IllegalArgumentException ignored) {
}
try {
DeadlineScore.score(DEADLINE, null); // ветвь «нет времени сдачи»
} catch (IllegalArgumentException ignored) {
}
}
}Запусти только его:
./mvnw clean verify -Dtest=HollowTest, где
-Dtest запускает один класс (статья 1). У
DeadlineScore в отчёте 100% инструкций и ветвей и ни одной
пропущенной строки из десяти. Всё зелёное, все ромбики зелёные.
Остальные классы песочницы красные: их этот тест не трогал.
Запусти так же итоговые тесты статьи 4,
-Dtest=DeadlineScoreTest. Будет то же самое: отчёт не
отличит пустой тест от честного. А четыре теста из части 1
(-Dtest=EmptyTest) оставят красными две строки из десяти,
«нет времени сдачи» и «ноль». Ромбики у двух условий будут жёлтыми:
ветвей пройдено 75%.
🔑 Покрытие говорит, что код исполнялся, а не что он проверен. JaCoCo видит, где тесты были, и не знает, что они там проверили.
96% и экран, который врал#
В браузерной части рабочего проекта был экран, который показывал человеку с десятью решёнными задачами: «ни одной задачи не засчитано». У экрана было больше двухсот пятидесяти зелёных тестов и 96% покрытия.
Тесты подменяли ответы сервера, а придумывал эти ответы тот же человек, который писал экран. Врал же экран на настоящих данных, которых в придуманных ответах не было: у части задач нужных ему сведений нет вовсе. Экран принял «сведений нет» за «ничего не решено».
Вывод там записали так: покрытие меряет, сколько строк исполнилось, а не сколько состояний бывает у данных.
Непокрытая ветвь — это вопрос#
Значит, покрытие бесполезно? Нет. На «проверено ли» оно отвечает плохо, а на «не проверено ли» — точно. Красная строка доказывает, что код не проверен: тест её не исполнил, значит, и проверить не мог.
Каждая красная строка и каждый жёлтый ромбик — вопрос. Ответов у него два:
- дыра в тестах: случай есть, а теста нет; пиши тест, с проверкой;
- мёртвый код: до него не добраться никаким входом; тест на него не напишешь, его убирают.
Дыру назвал отчёт, а не прогон
Однажды в команде после зелёного прогона в отчёте появился класс с нулём процентов. Ручку, которая показывает роли человека, тесты звали только для человека без ролей. Код, превращающий роли в ответ, не исполнился ни разу. Переставь в ответе два поля местами, и ни один из 610 тестов этого бы не заметил.
Бывает и второй ответ: непокрытой оказывается защитная проверка, до которой не дойти никаким входом. Мёртвый код в отчёте выглядит забытым случаем, но его нельзя ни выполнить, ни проверить. Он тянет цифру вниз и ничего не защищает.
Отсюда привычка. После своей правки смотри не только на процент: один нулевой класс среди сотни его почти не двигает. Смотри на имена: какие классы и ветви остались красными.
Порог ловит не всё#
В команде у JaCoCo есть третья работа, check. На шаге
verify она берёт весь проект и требует не меньше 80% строк
и 70% ветвей (статья 1). Ниже порога ./mvnw verify красный,
и пул-реквест не вливают. Новый код задачи там покрывают полностью.
И там же записано главное для этой статьи. Покрытие — не доказательство: тест без проверки результата даёт покрытие и не ловит ничего. Поэтому ревьюер смотрит, что проверяет тест.
Будь DeadlineScore всем проектом, четыре пустых теста
прошли бы порог 80/70. Восемь строк из десяти дают ровно 0,80, а ветвей
пройдено 75%. HollowTest прошёл бы даже требование к новому
коду. Порог ловит код, куда тесты не заходили. Что они там проверили —
вопрос к тебе и к ревьюеру.
Часть 3. Сломай руками#
Мутант#
Проверить тест можно одним способом: дать ему повод покраснеть. В статьях 2 и 4 ты так и делал: портил код одной правкой и смотрел, кто покраснеет. У испорченной копии кода есть имя — мутант.
Тест покраснел — мутант убит: такую ошибку тест замечает. Все зелёные — мутант выжил, и это вопрос к тестам.
Вот обещанный в статье 4 общий список: пять мутантов
DeadlineScore, каждый прогнан на всех тестах серии. Откуда
они: M1 и M2 из статьи 1, M3 из статьи 2, M4 и M5 из статьи 4.
| мутант | что испортили | 4 «пустых» | HollowTest |
4 теста статьи 2 | итоговые тесты статьи 4 |
|---|---|---|---|---|---|
| M1 | !isAfter(deadline) → isBefore(deadline):
сдавший ровно в срок уже опоздал |
зелёные | зелёный | красный onDeadlineIsStillOnTime |
красная строка PT0S |
| M2 | GRACE: 24 часа → 23 |
зелёные | зелёный | зелёные | красная строка PT24H |
| M3 | во второй ветви HALF → FULL: опоздавшим
полный балл |
зелёные | зелёный | красный lateWithinDayGetsHalf |
красные строки PT0.000000001S, PT20M,
PT1H, PT24H, тест полуночи и
singleAttemptCountsAsIs из AttemptsTest |
| M4 | нет срока — return FULL вместо исключения |
зелёные | зелёный | зелёные | красный noDeadlineIsAnError |
| M5 | !isAfter(срок + сутки) → isBefore(…):
ровно через сутки — уже ноль |
зелёные | зелёный | зелёные | красная строка PT24H |
Что видно из таблицы:
- пустые тесты и
HollowTestне убили ни одного мутанта; - четыре теста статьи 2 убили двоих: сдвинутой границы суток (M2, M5) и «нет срока» (M4) среди их случаев нет;
- итоговые тесты статьи 4 убили всех пятерых. M3 они поймали шестью
проверками сразу: опоздавшему на три часа
Attempts.bestтоже даёт 100.
Покрытие у HollowTest и у итоговых тестов одинаковое.
Мутанты делят их честно: ноль из пяти и пять из пяти.
Как ломать#
- Одна правка за раз, иначе не поймёшь, какую из них поймали.
- Запусти тесты:
./mvnw test. Красными должны стать те, что стерегут испорченное место. - Верни правку и убедись, что всё снова зелёное. Мутант, забытый в пул-реквесте, хуже любого пустого теста.
- Мутант выжил — подумай. Если такую ошибку стоит ловить, не хватает случая: допиши его и сломай снова. Если правка поведения не меняет, ловить нечего.
Где ломать: на границах (isAfter ↔︎
isBefore, > ↔︎ >=), в
константах, в возвращаемом значении, там, где бросается исключение. Там
и прячутся ошибки, которые пропускают тесты.
Так мысль из статьи 1 становится рабочим приёмом. Написал тест — сломай код, который он стережёт, увидь красное и верни.
💡 Ломать руками долго, и мутантов будет ровно столько, сколько ты придумаешь. PIT (pitest) делает то же автоматом: сам вносит в код мелкие правки, на каждой гоняет тесты и считает, сколько мутантов убито и сколько выжило. Его сайт говорит прямо: обычное покрытие «измеряет только, какой код исполнили тесты», а качество тестов видно по доле убитых мутантов. В рабочем проекте подключить его — решение мидла, разработчика с опытом. Тебе хватит знать, что так можно, а руками ты уже умеешь.
Отрицательный и положительный контроль
Отрицательный контроль — нарочно нарушить то, что тест стережёт, и убедиться, что он покраснел. Под этим именем приём есть и в статье 11 «Тесты-сторожа» цикла «От Spring к системе». Каждый мутант выше — отрицательный контроль.
Положительный контроль нужен проверкам наоборот, вида «X не случилось»: «отказ», «ошибки нет», «ничего не изменилось». Такая проверка истинна и тогда, когда механизм не работает вовсе. Выключенный счётчик тоже «не вырос». Поэтому рядом ставят доказательство, что X умеет случаться: тот же путь, где X разрешён, и та же проверка его видит.
«Не случилось» истинно и при поломке
В команде это записано правилом: рядом с каждым «X не произошло» обязано стоять доказательство, что X вообще умеет происходить.
Строже всего там с правами. Ответ «отказ» истинен и тогда, когда операция успела сработать, а отказ пришёл после неё. Поэтому к каждому «не пустило» там добавляют «и ничего не произошло»: доступ прежний, записей в журнале действий ноль. Иначе доступ, который тихо выдали, не виден ничем.
Часть 4. Проверять следствие#
Пустые тесты из части 1 не задают вопроса, на который бывает неверный ответ. Один не смотрит на результат вовсе, другие сверяют его с ожиданием, верным всегда. Правильный вопрос к тесту — о следствии. Что должно стать правдой, если код сработал, и чего не будет, если он сломан? Вот как этот вопрос выглядит в коде.
«Проверено следствием»#
Возьмём проверку списка на Java. Метод идёт по сдачам группы, сверяет
балл каждой с правилом и считает: проверенные в checked, а
упавшие, где балл не сошёлся, в failed. Итог «всё в
порядке», если failed == 0.
Почему этого мало? «Упавших ноль» истинно и тогда, когда сдач нет
вовсе: в пустом списке упавших тоже ноль. Список могли не загрузить или
загрузить не тот, а проверка всё равно зелёная. Поэтому условие пишут
так: проверенных больше нуля и упавших ноль,
checked > 0 && failed == 0. Первая половина —
положительный контроль из части 3, одной строкой.
Похожая ловушка — верить ответу «я здоров». Метод загрузки, который
вернул true, говорит только о себе: он отработал без
ошибок. Есть ли в списке хоть одна сдача, этот ответ не скажет. Поэтому
проверяют не ответ, а следствие: сдачи на месте, и балл у каждой по
правилу.
Так и в части 1. noAssertion похож на ответ «я здоров»:
код вызвался и не упал. А тест с точным ожиданием проверяет следствие:
сдал ровно в срок — 100.
Зелёный тест и доля, равная нулю
Представь экран, который показывает долю: сколько людей из всех заявок дошли до регистрации. Тест расчёта по меркам этой статьи хороший. Ожидание записано числом, обе ветви условия, «заявки есть» и «заявок нет», пройдены двумя тестами класса.
Вопрос теста такой: если хранилище скажет, что дошли 3 из 10, получится ли 30,0? Это арифметика, и она верна. Тест зелёный, а на настоящем экране доля — ноль.
Вопрос дела другой: какая доля людей на самом деле дошла? Чтобы на него ответить, кто-то должен ставить заявке последний статус. Вручную его не поставить, так задумано: этот статус ставит система. А код, который его ставил бы, не написали.
Заметить это тест не может. «Дошли — 3» он придумал сам: вместо настоящего хранилища подставил заглушку, объект с заранее готовым ответом. О заглушках — в продолжении серии. Интеграционный тест того же экрана, с настоящей базой, тоже не может: он кладёт в базу заявку сразу в нужном статусе, в обход кода.
Состояние, которое в жизни получается только после работы другого кода, тест подложил руками. Урок тот же, что у экрана из части 2. И то же правило из статьи 1: зелёный тест отвечает ровно на тот вопрос, который ему задали. Тест с вопросом дела был бы красным с первого дня: «человек дошёл до регистрации — попал ли он в долю?»
Проверь себя#
Ответь своими словами — вслух или на бумаге. Не получается — перечитай раздел.
swallowsExceptionиnoDeadlineIsAnErrorждут одного и того же исключения. Почему первый зелёный и при сломанном коде, а второй — нет?- Почему ожидание в тесте нельзя брать из той же константы или считать той же формулой, что в коде?
- Как, не запуская тест, понять, что он не заметит ошибки? Какой вопрос себе задать?
HollowTestпроходит каждую строку и ветвьDeadlineScore. Что это доказывает и чего не доказывает?- В отчёте JaCoCo красная строка. Какие два ответа бывают у этого вопроса и что делать в каждом случае?
- Что такое мутант? Почему M2 выжил на четырёх тестах статьи 2, но не на итоговых тестах статьи 4?
- Чем положительный контроль отличается от отрицательного? Почему проверке «отказ» нужен положительный?
- Что значит «проверено следствием»? Почему тест доли из части 4, хороший по всем признакам этой статьи, не заметил нуля на экране?
Что дальше#
Следующая статья серии — когда вокруг твоего кода другой код: база, сеть, часы, чужой сервис. Заглушки руками и Mockito — и как заглушкой не написать тот же тест, согласный сам с собой.
Первоисточники#
- JaCoCo — Coverage Counters — что считается строкой и ветвью, что значат цвета и ромбики.
- JaCoCo
— Maven Plug-in — работы плагина (goals):
prepare-agent,report,check; у каждой своя страница с настройками. - JaCoCo — check — как задаются правила порога: элемент, счётчик, доля, минимум.
- PIT — Real world mutation testing — мутационное тестирование: убитые и выжившие мутанты; начни с Quick Start.
Пример целиком — с импортами#
Учебный пример живёт в песочнице testy из статьи 1. К
этой статье в ней всё из статей 1–4: DeadlineScore и
Attempts, итоговые DeadlineScoreTest и
AttemptsTest. Новых зависимостей нет, добавляется только
плагин JaCoCo в pom.xml, внутри
<build><plugins>:
<!-- pom.xml песочницы testy: что добавляет статья 5 — внутри <build><plugins> -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.15</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal> <!-- подключить счётчик к прогону тестов -->
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal> <!-- отчёт в target/site/jacoco -->
</goals>
</execution>
</executions>
</plugin>DeadlineScore такой же, как в статье 1: он здесь, чтобы
было что вернуть после поломок. Остальные файлы песочницы не трогай.
// 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/test/java/example/testy/score/ под именами
EmptyTest.java и HollowTest.java, после опытов
удали.
// фрагмент: учебный, так НЕ пишут — четыре теста, зелёные при любой поломке
package example.testy.score;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
class EmptyTest {
private static final Instant DEADLINE = Instant.parse("2026-10-12T21:00:00Z");
@Test
void noAssertion() {
DeadlineScore.score(DEADLINE, DEADLINE); // вызвали — и всё
}
@Test
void swallowsException() {
try {
DeadlineScore.score(null, DEADLINE);
} catch (IllegalArgumentException expected) {
// ждали исключение — и молча проглотили; а если его не будет?
}
}
@Test
void agreesWithItself() {
Instant hourLate = DEADLINE.plus(Duration.ofHours(1));
assertThat(DeadlineScore.score(DEADLINE, hourLate))
.isEqualTo(DeadlineScore.score(DEADLINE, hourLate)); // код сверяется сам с собой
}
@Test
void alwaysTrue() {
assertThat(DeadlineScore.score(DEADLINE, DEADLINE)).isBetween(0, 100); // верно при любом правиле
}
}// фрагмент: учебный, так НЕ пишут — 100% покрытия и ни одной проверки
package example.testy.score;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.time.Instant;
class HollowTest {
private static final Instant DEADLINE = Instant.parse("2026-10-12T21:00:00Z");
@Test
void touchesEveryLine() {
DeadlineScore.score(DEADLINE, DEADLINE); // ветвь «вовремя»
DeadlineScore.score(DEADLINE, DEADLINE.plus(Duration.ofHours(1))); // ветвь «половина»
DeadlineScore.score(DEADLINE, DEADLINE.plus(Duration.ofDays(2))); // ветвь «ноль»
try {
DeadlineScore.score(null, DEADLINE); // ветвь «нет срока»
} catch (IllegalArgumentException ignored) {
}
try {
DeadlineScore.score(DEADLINE, null); // ветвь «нет времени сдачи»
} catch (IllegalArgumentException ignored) {
}
}
}Сделай руками:
- Добавь плагин в
pom.xmlи запусти./mvnw clean verify:Tests run: 18, всё зелёное. Откройtarget/site/jacoco/index.html, пакетexample.testy.score, классDeadlineScore: 100% инструкций и ветвей, пропущенных строк — 0 из 10. - Положи оба учебных файла и снова запусти
./mvnw clean verify:Tests run: 23, всё зелёное. Пустые тесты сборку не роняют. ./mvnw clean verify -Dtest=HollowTest—Tests run: 1, а-Dtest=DeadlineScoreTest—Tests run: 12. УDeadlineScoreв обоих отчётах то же, что в первом шаге../mvnw clean verify -Dtest=EmptyTest—Tests run: 4; пропущены 2 строки из 10, ветвей пройдено 75%. Найди две красные строки и два жёлтых ромбика.- Теперь без
clean:./mvnw verify -Dtest=HollowTest, а за ним снова./mvnw verify -Dtest=EmptyTest. УDeadlineScoreопять 0 пропущенных строк: отчёт сложил прогоны. Вот зачемclean. - Мутанты из части 3 — по одному: правка,
./mvnw test, сверка с таблицей, возврат.EmptyTestиHollowTestзелёные на каждом. Колонку статьи 2 так не сверить: тех четырёх тестов в песочнице уже нет, они стали строками таблицы статьи 4. - Удали
EmptyTestиHollowTest: так не пишут даже в песочнице../mvnw test— сноваTests run: 18. Плагин JaCoCo оставь.
Теперь ты знаешь, чем зелёный тест отличается от проверенного. Первый просто ни разу не краснел, второй краснеет на каждой поломке, которую стережёт.