Hammerhall EN Сколько стоит Войти

Тест покраснел: что делать

Статья 3 · читать минут 30

Для кого. Ты прочитал статьи 1–2 этой серии. У тебя есть песочница testy и DeadlineScoreTest с четырьмя тестами, и каждый ты видел красным, когда ломал код руками. Серия «От Java к Spring Boot» не нужна.

Что будет. Как прочитать красный отчёт: сообщение, стек, Failures и Errors, файлы отчётов. Как превратить ошибку в красный тест и почему бывает «у меня зелёное, а в сборке красное». Чего с красным тестом не делать и когда поправить сам тест правильно.

Откуда статья. Раздел «Буткемп / Лаборатория» — о том, как пишут код команды Hammerhall: буткемп работает в открытых репозиториях, лаборатория — в закрытых. Серия «От красного к зелёному» — как работать с тестами на проекте: писать их, читать красную сборку и верить зелёному тесту, только если видел его красным. Примеры — учебные: их можно повторить у себя.


Зачем учиться читать красное#

Красный крестик у пул-реквеста. По ссылке Details — несколько сотен строк журнала сборки, внизу BUILD FAILURE. Первые желания понятны: нажать «перезапустить» и надеяться; поправить тест, чтобы прошёл; написать в чат «у меня всё работает».

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

Об этом статья, в четырёх частях:

  1. ошибка, которую тесты не видят: сначала красный тест, потом починка;
  2. как читать красный отчёт — в IDE, в Maven, в файлах отчётов;
  3. почему тест зелёный у тебя и красный в сборке;
  4. чего с красным тестом не делать, когда тест правильно поменять и как читать чужой упавший тест.

Пример прежний — зачёт по сроку. Сдал до срока или ровно в срок — 100 баллов. Опоздал не больше чем на сутки — 50. Опоздал сильнее — 0.

Из статьи 2 «Первый модульный тест» у тебя четыре теста: сдал за минуту до срока, ровно в срок, через час, через двое суток. Все зелёные. Песочница та же, что в статье 1 «Зачем тесты и чего от тебя ждут на проекте». В коротких примерах, как и раньше, нет строк import: полные файлы — в конце статьи.


Часть 1. Ошибка, которую четыре теста не видят

«Упрощение»#

Коллега решил, что сравнивать моменты времени громоздко, и переписал правило через часы опоздания:

// src/main/java/example/testy/score/DeadlineScore.java — версия после «упрощения»
public final class DeadlineScore {
    // … константы — как были

    public static int score(Instant deadline, Instant submittedAt) {
        // … проверки на null — как были
        long hoursLate = Duration.between(deadline, submittedAt).toHours();   // сколько часов опоздал
        if (hoursLate <= 0) {
            return FULL;
        }
        if (hoursLate <= GRACE.toHours()) {
            return HALF;
        }
        return NONE;
    }
}

Duration.between(a, b) — отрезок времени от a до b. Если b раньше a, отрезок отрицательный. toHours() — сколько в нём часов. Читается гладко, почти как само правило.

Сборка зелёная: все четыре теста прошли. Код влили.

Жалоба#

Через день приходит письмо: «Сдал через 20 минут после срока — получил 100. А сосед опоздал на час и получил 50. Так и должно быть?»

Не должно. Дело в toHours(): он отвечает, сколько в отрезке целых часов, а остаток отбрасывает. В документации Java так и написано: число секунд делится на 3600 нацело. Любое опоздание меньше часа для кода коллеги — 0 целых часов, то есть «не опоздал».

А тесты молчали. Вот их случаи глазами коллеги, и рядом — случай из жалобы:

когда сдал toHours() балл у коллеги балл по правилу
за минуту до срока 0 100 100
ровно в срок 0 100 100
через час 1 50 50
через двое суток 48 0 0
через 20 минут 0 100 50

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

💡 Тот же изъян есть и на другом краю. Опоздавший на сутки и полчаса получает у коллеги 50 вместо нуля: 24 целых часа «не больше 24». Какие случаи брать, чтобы такое ловилось заранее, расскажет продолжение серии.

Сначала тест, который показывает жалобу

Первое желание — сразу чинить код. Не спеши. Сначала запиши жалобу тестом. Он ляжет в DeadlineScoreTest после четырёх тестов статьи 2:

    @Test
    void twentyMinutesLateGetsHalf() {
        // жалоба: «сдал через 20 минут — получил 100»
        Instant submittedAt = DEADLINE.plus(Duration.ofMinutes(20));

        int score = DeadlineScore.score(DEADLINE, submittedAt);

        assertThat(score).isEqualTo(50);
    }

Запускаешь — красный:

expected: 50
 but was: 100

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

Потом починка#

Теперь код. Возвращаем сравнение моментов, как было до «упрощения»:

        if (!submittedAt.isAfter(deadline)) {                   // ровно в срок — ещё вовремя
            return FULL;
        }
        if (!submittedAt.isAfter(deadline.plus(GRACE))) {       // ровно через сутки — ещё половина
            return HALF;
        }
        return NONE;

Запускаешь DeadlineScoreTest: все пять тестов зелёные. И этому зелёному можно верить: минуту назад ты видел новый тест красным на настоящей ошибке.

🔑 Сначала красный тест, потом починка. Порядок такой: жалоба → красный тест → починка → зелёный. Починил сначала, а тест написал потом — он сразу зелёный, и неизвестно, ловит ли он что-нибудь.

Тест остаётся навсегда#

После починки twentyMinutesLateGetsHalf не удаляют. Это защита от регрессии (статья 1): через полгода кто-нибудь снова захочет «упростить» правило. Тогда этот тест покраснеет раньше, чем код дойдёт до людей. Найденная ошибка должна стать тестом, иначе она вернётся.

В чек-листе пул-реквеста из статьи 1 это строка «у исправленной ошибки — тест, который без исправления красный». Теперь видно, зачем она.


Часть 2. Как читать красный отчёт

«Ожидалось» и «получено»#

В IDE упавший тест помечен красным, рядом сообщение и стек (о нём ниже). В Maven то же сообщение есть в конце журнала, в итогах. Выглядит примерно так:

[ERROR] Failures:
[ERROR]   DeadlineScoreTest.twentyMinutesLateGetsHalf:61
expected: 50
 but was: 100

Номера строк здесь и дальше — по файлам из «Примера целиком». Первая строка каждого файла там — комментарий с путём или пометкой, и она тоже считается.

Читается сверху вниз: какой тест (twentyMinutesLateGetsHalf в классе DeadlineScoreTest), на какой строке файла теста (61) и что не сошлось. expected — ожидалось, то, что записано в тесте. but was — получено, то, что вернул код.

Первый вопрос к красному тесту: кто прав — ожидание или код? Чаще прав тест: его писали спокойно и заранее, а код только что меняли. Но не всегда, об этом часть 4.

Стек: какая строка твоя#

Под сообщением — стек вызовов (stack trace, говорят и «стектрейс»): цепочка методов, по которой программа пришла туда, где случилась ошибка. Каждая строка — метод, файл и номер строки. Наверху место, где ошибка возникла. Ниже — кто туда позвал.

org.opentest4j.AssertionFailedError:
expected: 50
 but was: 100
    at example.testy.score.DeadlineScoreTest.twentyMinutesLateGetsHalf(DeadlineScoreTest.java:61)
    …

AssertionFailedError значит «проверка не сошлась»: такую ошибку бросает AssertJ, когда isEqualTo не совпал. Строка at из твоего кода — 61-я строка теста, та самая assertThat. Значит, тест дошёл до проверки, и проверка не прошла.

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

// фрагмент: тест, который не дошёл до проверки — Errors
class ErrorTest {

    @Test
    void forgotTheDeadline() {
        Instant submittedAt = Instant.parse("2026-10-12T21:00:00Z");

        int score = DeadlineScore.score(null, submittedAt);         // срок забыли передать

        assertThat(score).isEqualTo(100);
    }
}

В файле целиком, с package и import, вызов score(null, …) стоит на строке 16. Стек такой:

java.lang.IllegalArgumentException: Срок не указан
    at example.testy.score.DeadlineScore.score(DeadlineScore.java:24)
    at example.testy.score.ErrorTest.forgotTheDeadline(ErrorTest.java:16)
    …

Читай так:

Правило поиска: иди сверху вниз и остановись на первой строке из своего пакета, в песочнице это example.testy.score. В IDE строки стека — ссылки: щёлкни, и файл откроется на нужной строке.

Failures и Errors на деле#

Теперь на деле видна разница из статьи 1 между Failures и Errors. twentyMinutesLateGetsHalf дошёл до assertThat, и проверка не сошлась. Это Failures: код вернул не то, сравни ожидание и полученное.

forgotTheDeadline до проверки не дошёл, по дороге вылетело исключение. Это Errors: ищи по стеку, откуда оно.

Здесь неправ сам тест. Нет срока — ошибка «Срок не указан»: так задумано, это правило зачёта. Тест должен эту ошибку ждать, а не падать от неё.

Пишется это через assertThatThrownBy, о нём расскажет продолжение серии. Первое знакомство с ним есть в статье 0 «Что нужно знать до Spring» цикла «От Java к Spring Boot». А ErrorTest после опыта удали.

Где лежат подробности: target/surefire-reports

Журнал сборки длинный, сообщения тестов в нём перемешаны с остальным выводом. Удобнее файлы отчётов. Surefire, который запускает тесты на шаге test (статья 1), кладёт их в target/surefire-reports, по два на класс теста:

target/surefire-reports/
├── example.testy.score.DeadlineScoreTest.txt         ← сводка и стек упавших
└── TEST-example.testy.score.DeadlineScoreTest.xml    ← всё подробно, для поиска

*.txt — сводка для человека: сколько тестов, какие упали, и стек каждого упавшего. TEST-*.xml — то же и больше, в формате XML: текст с разметкой в угловых скобках, один вид для всех классов. Искать удобнее в нём, сразу во всех отчётах.

Особенно когда ошибка составная. Если исключение возникло из-за другого, Java печатает цепочку. Сверху внешнее, ниже строка Caused by: и его причина, и так иногда несколько этажей. Настоящая причина обычно в самой нижней Caused by:. Когда тест падает не на проверке, а при подготовке, до неё бывает пять этажей: например, если не поднялось приложение.

Рецепт — собрать причины из всех отчётов разом:

# -h — не печатать имя файла; -o — только найденный кусок; | — отдать вывод дальше
grep -ho 'Caused by:[^<&]\{0,250\}' target/surefire-reports/TEST-*.xml | sort -u

grep — программа, которая ищет в файлах строки по образцу. В Linux и macOS она есть сразу, на Windows — в Git Bash. Запускай её из папки проекта, итог появится прямо в терминале.

Здесь grep вынимает из всех отчётов кусок после Caused by:. Берёт до 250 знаков, пока не встретится < или &, то есть разметка XML. А sort -u сортирует строки и оставляет каждую причину по разу. Десять упавших тестов с одной причиной дадут одну строку. Без -h перед каждой строкой встанет имя файла, и одна причина из двух разных отчётов останется двумя строками.

Отчёты с машины сборки#

Помнишь из статьи 1: в команде проверка пул-реквеста сохраняет отчёты о тестах даже у упавшей сборки. Это они и есть. Открываешь Details у красного крестика, переходишь на сводку прогона (Summary) и внизу, в разделе Artifacts, скачиваешь архив с отчётами. Внутри — те самые *.txt и TEST-*.xml, только с машины сборки, а не с твоей.


Часть 3. Зелёный у тебя, красный в сборке

Полночь по Москве#

Срок задачам объявляют по-человечески: «до полуночи 13 октября». Хочется так и написать тест: от даты, а не от готового Instant. Вот как такой тест пишут впервые — и так его не пишут:

// фрагмент: тест, который зелёный у тебя и красный в сборке
class ZoneTest {

    @Test
    void halfAnHourAfterMidnightIsLate() {
        Instant deadline = LocalDate.of(2026, 10, 13)
                .atStartOfDay(ZoneId.systemDefault())               // ⚠️ полночь — по часам машины
                .toInstant();
        Instant submittedAt = Instant.parse("2026-10-12T21:30:00Z"); // 00:30 по Москве

        assertThat(DeadlineScore.score(deadline, submittedAt)).isEqualTo(50);
    }
    // …
}

LocalDate — дата без времени и без пояса: просто «13 октября». atStartOfDay(пояс) — начало этого дня, полночь, в указанном часовом поясе. toInstant() переводит её в точку на шкале времени. А ZoneId.systemDefault() — пояс машины, на которой идёт тест.

У тебя ноутбук в Москве. Полночь по Москве — 21:00 по Гринвичу: Z в конце времени значит UTC (статья 1). Сдано в 21:30, на полчаса позже срока. Балл 50, тест зелёный.

В пул-реквесте он красный:

expected: 50
 but was: 100

Сборку, которая проверяет пул-реквест, называют CI (continuous integration, непрерывная интеграция). В статье 1 это была проверка в GitHub Actions. Она запускается сама, когда открыт пул-реквест и когда в его ветку пришёл новый пуш.

Машины сборки, как правило, живут по UTC. Там «полночь 13 октября» наступает на три часа позже, в 00:00 по Гринвичу. Сдача в 21:30 накануне оказывается за два с половиной часа до срока: полный балл. Тест один, код один, а итог зависит от того, где запустили.

Как повторить у себя#

Пояс машины Java берёт из настройки user.timezone, и её можно задать при запуске:

./mvnw test -Dtest=ZoneTest -DargLine=-Duser.timezone=UTC

-Dtest=ZoneTest запускает только этот класс, как в статье 1. Тесты Surefire запускает в отдельной программе Java, поэтому -D здесь два.

Первое, -DargLine=…, — свойство Maven: «вот настройки для той программы». Второе, -Duser.timezone=UTC, — сама настройка: «считай, что машина живёт по UTC». В IDE её пишут в настройках запуска теста, в поле VM options. Тест покраснеет у тебя так же, как в сборке.

Починка: пояс — явно#

Ошибся сам тест: «полночь по Москве» — правило задачи, а не свойство машины. Пояс называют по имени:

                .atStartOfDay(ZoneId.of("Europe/Moscow"))                   // пояс назван явно

Так тест зелёный где угодно: в Москве, в UTC, в Нью-Йорке. Он вошёл в DeadlineScoreTest под именем halfAnHourAfterMoscowMidnightIsLate.

И честная деталь: с версией коллеги из части 1 этот тест тоже красный. Полчаса опоздания там те же 0 целых часов. Будь он в проекте раньше, «упрощение» не прошло бы сборку.

⚠️ Частая ошибка — LocalDate.now() и ZoneId.systemDefault() в коде, где важно время: дата и пояс «как на этой машине». В курсах ты их видел, и для учебной программы это годится. В рабочем проекте время в коде — Instant, а часы общие, по UTC. В песочнице срок — тоже Instant, а где без пояса не обойтись, его называют по имени. Иначе итог зависит от машины, а машин у проекта много: твоя, коллеги, сборка, сервер.

Другие причины «у меня зелёное»

Пояс — одна из причин. Ещё три частые, их называла статья 1:

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

Перезапуск — не починка#

Соблазн нажать Re-run понятен: вдруг повезёт. Иногда и везёт. Тест, который на одном и том же коде то красный, то зелёный, называют мигающим (flaky).

Он хуже красного. Команда привыкает, что «красное бывает просто так», и перестаёт смотреть. А следующее красное окажется настоящим. Мартин Фаулер называет такие тесты заразными.

Перезапуск ничего не чинит. Причина остаётся, будь то пояс, порядок или время, и выстрелит в чужом пул-реквесте. Позеленело после перезапуска — значит, нашёлся мигающий тест. Скажи о нём в пул-реквесте или старшему коллеге.


Часть 4. Чего не делать — и когда тест правильно поменять

Три способа спрятать красное#

В статье 1 было правило: красное не прячут. Вот как его прячут чаще всего. Все три способа делают сборку зелёной, и все три плохи.

⚠️ Частый совет: «мешает тест — поставь @Disabled, потом разберёмся». Документация JUnit хотя бы просит указать причину: @Disabled("…"). Чек-лист из статьи 1 строже: чужой тест без объяснения в пул-реквесте не отключают и не удаляют.

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

Бывает, что неправ тест. Честный случай один: изменилось обещание — само правило, а не код. Допустим, решили: опоздание до 20 минут включительно не считается, сайт иногда тормозит. Тогда twentyMinutesLateGetsHalf начнёт ждать 100 и получит новое имя: «получает половину» больше не правда. Это правка теста по делу.

Как выглядит такая правка:

Не можешь назвать, какое обещание изменилось, — тест, скорее всего, прав, а неправ код. Тогда чини код.

Тест, переписанный с обоснованием

Пример на сдачах. Задачу можно сдавать несколько раз, и правило было такое: новая сдача отменяет прежнюю. Тест стерёг его: сдал вовремя, потом пересдал с опозданием — в зачёте 50. Потом правило сознательно поменяли: засчитывается лучшая из сдач, иначе пересдача могла бы только отнять балл. Удалить тест проще всего, но так теряется след.

Правильно иначе. Утверждение разворачивают: теперь в зачёте 100. А рядом записывают, что изменилось и почему. Если прежнее свойство ещё нужно, например «какая сдача последняя», оно становится отдельной операцией со своим тестом.

Отменённое решение — такая же часть истории, как принятое. Тест, переписанный молча, выглядит ослаблением проверки.

Чужой красный тест — сначала прочитать

Твоя правка уронила тест, который писал не ты. Порядок такой: прочитай имя, @DisplayName, комментарии и тело. Потом ответь себе, что он охраняет. Только после этого решай, кто неправ: он или твоя правка.

Тест, который не компилировался

Это та история из статьи 1, где чужой тест спас оплаченный людьми доступ.

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

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

Вторая — возможность проверить границу. «Сейчас» передавалось в расчёт параметром, и шапка теста, комментарий над классом, объясняла зачем. Иначе границу «срок истекает ровно сейчас» нечем проверить, кроме как ждать. Новая версия брала «сейчас» из системных часов. Тесты остались бы зелёными, просто один краевой случай стал бы непроверяемым.

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

🔑 Перед тем как удалить или переписать чужой тест, спроси не «что здесь проверяется», а «какое обещание здесь записано». Первое видно по имени метода, второе — из комментариев и шапки. Именно оно переживает любые переделки.

Спросить языковую модель — можно, но о чём

Правило рабочего проекта из статьи 1: языковую модель джун там спрашивает, только чтобы она объяснила, а не написала за него. Красный тест — как раз законный вопрос. Дай модели текст теста, сообщение и стек и спроси «что здесь проверяется и почему упало?», а не «почини».

В команде в описании пул-реквеста для этого есть строки «что спрашивал у модели и что понял» и «что было непонятно». Во второй пишут, например: «не понял, почему тест падал только в сборке». Это нормальная строка, и ревьюер подскажет.

Красный тест: порядок действий

  1. Прочитай сообщение: что ожидалось, что получено.
  2. Найди в стеке первую строку из своего пакета.
  3. Failures или Errors: не сошёлся результат или тест не дошёл до проверки.
  4. Тест чужой — прочитай, какое обещание он охраняет.
  5. Красное только в сборке — повтори у себя: все тесты, clean, пояс.
  6. Чини код. Тест меняй, только если изменилось обещание, — и скажи об этом в пул-реквесте.

Проверь себя#

Ответь своими словами — вслух или на бумаге. Не получается — перечитай раздел.

  1. Почему версия коллеги с toHours() прошла все четыре теста статьи 2, но не прошла тест на 20 минут?
  2. Зачем писать тест, воспроизводящий жалобу, до починки, а не после?
  3. В отчёте expected: 50, but was: 100. Что из этого записано в тесте, а что вернул код?
  4. Как по стеку найти строку, где возникла ошибка, и строку своего теста? Какие строки пропускать?
  5. Чем Failures отличается от Errors, и что это подсказывает о том, где искать?
  6. Тест с ZoneId.systemDefault() зелёный у тебя и красный в сборке. Почему, и как повторить красное у себя?
  7. Чем плохи @Disabled и «поменять ожидание на то, что получилось»? Когда поменять тест правильно?
  8. Твоя правка уронила чужой тест. Какой вопрос задать себе, прежде чем его трогать?

Что дальше#

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

Первоисточники#


Пример целиком — с импортами#

Учебный пример живёт в песочнице testy из статьи 1. Здесь два файла. DeadlineScore после починки такой же, как в статье 1: он здесь, чтобы было видно строку 24 и было что вернуть после опытов. DeadlineScoreTest целиком заменяет тест статьи 2, по тому же пути. ManualCheck и TestyApplicationTests не трогай.

// 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/DeadlineScoreTest.java
package example.testy.score;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;

import static org.assertj.core.api.Assertions.assertThat;

class DeadlineScoreTest {

    // полночь 13 октября по Москве
    private static final Instant DEADLINE = Instant.parse("2026-10-12T21:00:00Z");

    @Test
    void beforeDeadlineGetsFull() {
        Instant submittedAt = DEADLINE.minus(Duration.ofMinutes(1));

        int score = DeadlineScore.score(DEADLINE, submittedAt);

        assertThat(score).isEqualTo(100);
    }

    @Test
    void onDeadlineIsStillOnTime() {
        int score = DeadlineScore.score(DEADLINE, DEADLINE);

        assertThat(score).isEqualTo(100);
    }

    @Test
    void lateWithinDayGetsHalf() {
        Instant submittedAt = DEADLINE.plus(Duration.ofHours(1));

        int score = DeadlineScore.score(DEADLINE, submittedAt);

        assertThat(score).isEqualTo(50);
    }

    @Test
    @DisplayName("опоздал больше чем на сутки — ноль")
    void lateByMoreThanDayGetsNothing() {
        Instant submittedAt = DEADLINE.plus(Duration.ofDays(2));

        int score = DeadlineScore.score(DEADLINE, submittedAt);

        assertThat(score).isEqualTo(0);
    }

    @Test
    void twentyMinutesLateGetsHalf() {
        // жалоба: «сдал через 20 минут — получил 100»
        Instant submittedAt = DEADLINE.plus(Duration.ofMinutes(20));

        int score = DeadlineScore.score(DEADLINE, submittedAt);

        assertThat(score).isEqualTo(50);
    }

    @Test
    void halfAnHourAfterMoscowMidnightIsLate() {
        Instant deadline = LocalDate.of(2026, 10, 13)
                .atStartOfDay(ZoneId.of("Europe/Moscow"))                   // пояс назван явно
                .toInstant();
        Instant submittedAt = Instant.parse("2026-10-12T21:30:00Z");        // 00:30 по Москве

        int score = DeadlineScore.score(deadline, submittedAt);

        assertThat(score).isEqualTo(50);
    }
}

Два файла для опытов — так тесты не пишут. Клади их в ту же папку src/test/java/example/testy/score/ под именами ErrorTest.java и ZoneTest.java, после опыта удали. Пока они лежат, итог другой: тестов больше, ErrorTest красный всегда, а ZoneTest — на машине, живущей по UTC.

// фрагмент: тест, который не дошёл до проверки — Errors
package example.testy.score;

import org.junit.jupiter.api.Test;

import java.time.Instant;

import static org.assertj.core.api.Assertions.assertThat;

class ErrorTest {

    @Test
    void forgotTheDeadline() {
        Instant submittedAt = Instant.parse("2026-10-12T21:00:00Z");

        int score = DeadlineScore.score(null, submittedAt);         // срок забыли передать

        assertThat(score).isEqualTo(100);
    }
}
// фрагмент: тест, который зелёный у тебя и красный в сборке
package example.testy.score;

import org.junit.jupiter.api.Test;

import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;

import static org.assertj.core.api.Assertions.assertThat;

class ZoneTest {

    @Test
    void halfAnHourAfterMidnightIsLate() {
        Instant deadline = LocalDate.of(2026, 10, 13)
                .atStartOfDay(ZoneId.systemDefault())               // ⚠️ полночь — по часам машины
                .toInstant();
        Instant submittedAt = Instant.parse("2026-10-12T21:30:00Z"); // 00:30 по Москве

        assertThat(DeadlineScore.score(deadline, submittedAt)).isEqualTo(50);
    }

    @Test
    void halfAnHourAfterMoscowMidnightIsLate() {
        Instant deadline = LocalDate.of(2026, 10, 13)
                .atStartOfDay(ZoneId.of("Europe/Moscow"))           // пояс назван явно
                .toInstant();
        Instant submittedAt = Instant.parse("2026-10-12T21:30:00Z");

        assertThat(DeadlineScore.score(deadline, submittedAt)).isEqualTo(50);
    }
}

Сделай руками:

  1. С двумя первыми файлами запусти ./mvnw test — всё зелёное, Tests run: 7: шесть тестов DeadlineScoreTest и TestyApplicationTests от start.spring.io.
  2. Замени в DeadlineScore проверки после null версией коллеги из части 1. Запусти снова: четыре теста статьи 2 зелёные, красные twentyMinutesLateGetsHalf (строка 61) и halfAnHourAfterMoscowMidnightIsLate (строка 73) — оба expected: 50, but was: 100. Верни как было.
  3. Добавь ErrorTest и запусти: в итоге — Errors: 1, а не Failures. В стеке — DeadlineScore.java:24 и ErrorTest.java:16. Открой target/surefire-reports: в *.txt — сводка и стек этого теста, в TEST-*.xml — то же подробнее. grep из части 2 здесь ничего не выведет: у этого исключения нет причины, нет и Caused by:. Удали ErrorTest.
  4. Добавь ZoneTest и запусти его с московским поясом — оба теста зелёные: ./mvnw test -Dtest=ZoneTest -DargLine=-Duser.timezone=Europe/Moscow. Теперь то же с -Duser.timezone=UTC: первый красный, expected: 50, but was: 100, второй зелёный. Удали ZoneTest.

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

← Все статьи о тестах

Попробовать руками

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