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

Какие случаи проверять

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

Для кого. Ты прочитал статьи 1–3 этой серии: у тебя есть песочница testy и шесть тестов к зачёту по сроку. Писать тест ты умеешь, вопрос — какие. Серия «От Java к Spring Boot» не нужна.

Что будет. Как находить классы входов и границы — в своём коде и в таблице задачи; как записать случаи таблицей и превратить её в один параметризованный тест; как проверять ошибки, пустой список и null.

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


Зачем выбирать случаи#

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

Проверить все входы нельзя. В сутках после срока 86 400 секунд, а Instant различает в каждой секунде миллиард точек. Проверять наугад — надеяться на удачу.

Ещё в статье 1 было: проверка находит ошибку, только если среди её случаев есть тот, на котором ошибка видна. Значит, случаи выбирают там, где ошибка вероятнее всего. Для этого есть два простых приёма: классы входов и границы. А потом сломаем код и проверим выбор.

Пример — всё тот же зачёт по сроку:

когда сдал балл
до срока или ровно в срок 100
позже, но не больше чем через сутки 50
ещё позже 0
срока или времени сдачи нет ошибка с понятным текстом

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


Часть 1. Классы и границы#

Счастливый путь и несчастливый

Счастливый путь (happy path) — случай, когда всё идёт как задумано: сдал вовремя, получил 100. Несчастливый путь (unhappy path) — всё остальное: опоздал, срока нет, пришло что-то странное.

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

У четырёх тестов статьи 2 опоздания есть, а случая «срока нет» — ни одного. Чем это обернётся, увидим в части 3.

Сценарии зелёные, правила не проверены

В одном рабочем проекте был модуль, где тестом проверен каждый сценарий, то есть действие человека от начала до конца. Все тесты зелёные, а замер покрытия показал 58% ветвей, путей через if (статья 1). Дыры нашлись в правилах вроде «повторить то же действие безвредно, а поменять записанный итог нельзя». Тестом не проверено ни одно из них.

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

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

Классы входов#

Входов у DeadlineScore бесконечно много, но делает код с ними лишь несколько разных вещей. Класс входов — группа входов, с которыми код поступает одинаково; в книгах это класс эквивалентности (equivalence class). Верен код для одного входа из класса — почти наверняка верен и для остальных, и от каждого класса хватит одного-двух представителей. Классы не придумывают, а вычитывают из правила:

класс пример балл
раньше срока за 30 дней 100
ровно в срок секунда в секунду 100
в пределах суток через час 50
позже суток через двое суток 0
нет данных срок — null ошибка

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

Правило простое: от каждого класса — хотя бы один случай. У тестов статьи 2 было четыре класса из пяти.

Границы — самые опасные точки#

Место, где один класс переходит в другой, — граница. У нас их две: сам срок и срок плюс сутки.

Ошибаются чаще всего именно здесь. < вместо <=, isBefore вместо !isAfter, 23 вместо 24 — это ошибка на единицу (off-by-one). Код с ней верен везде, кроме самой границы или узкой полосы рядом с ней. Правка с isBefore из статьи 1 меняла балл ровно в одной точке, в самом сроке. Тесты «за минуту» и «через час» таких ошибок не увидят никогда: они далеко от границ.

🔑 Ошибки живут на границах. Каждую границу проверяют с двух сторон: последнюю точку одного класса и первую точку следующего. Для срока это «ровно в срок» (100) и «сразу после» (50). Для суток — «ровно через сутки» (50) и «сразу после» (0).

Что значит «сразу после»#

У целых чисел после 90 идёт 91. А что идёт сразу после срока?

Instant хранит время с точностью до наносекунды, миллиардной доли секунды. Значит, первая точка класса «опоздал» — срок плюс одна наносекунда. В таблице, которую мы скоро напишем, это PT0.000000001S. Первая точка класса «опоздал больше чем на сутки» — PT24H0.000000001S.

⚠️ Частая ошибка — брать у границы «плюс-минус единицу» по привычке: секунду или день. У Instant единица — наносекунда, и строка «через секунду» здесь не край. Если кто-то станет сравнивать время, отбросив доли секунды, сдача через полсекунды после срока получит 100. А тест «через секунду» останется зелёным.

Две точки у границы#

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

Верхнюю границу тест берёт двумя точками: с 90 приложение стартует и окно равно 90, с 91 не стартует. Это последняя годная точка и первая негодная. Значения 50 и 200 тоже легли бы по разные стороны, но не сказали бы, где именно граница.


Часть 2. От таблицы случаев — к коду

Таблица «вход → итог»#

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

опоздание итог откуда строка
−30 дней 100 «раньше», далеко от границы
−1 секунда 100 «раньше», у границы
0 100 «ровно в срок» — граница
+1 наносекунда 50 сразу после срока
+20 минут 50 жалоба из статьи 3
+1 час 50 «в пределах суток»
+24 часа 50 ровно через сутки — граница
+24 часа и 1 наносекунда 0 сразу после суток
+2 дня 0 «позже суток»
срока нет ошибка «Срок не указан» несчастливый путь
времени сдачи нет ошибка «Время сдачи не указано» несчастливый путь

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

Параметризованный тест#

Девять строк — девять почти одинаковых методов? На проекте методы-близнецы расходятся: ожидание поправили в одном и забыли в соседнем. Цикл внутри одного теста не лучше: он падает на первой плохой строке и молчит об остальных (статья 2).

Параметризованный тест — тест, который JUnit запускает по разу на каждую строку данных. Это модуль JUnit junit-jupiter-params, и ставить его не нужно: spring-boot-starter-test приносит junit-jupiter целиком, с api, params и engine. Запускается он как любой тест, а в отчёте каждая строка — отдельный запуск. Упала одна — видно какая.

Первое знакомство с такими тестами — в статье 0 «Что нужно знать до Spring» цикла «От Java к Spring Boot». Здесь разберём их с нуля.

    @ParameterizedTest(name = "опоздание {0} → {1}")
    @CsvSource(delimiter = '|', textBlock = """
            -P30D              | 100
            -PT1S              | 100
            PT0S               | 100
            PT0.000000001S     | 50
            # жалоба из статьи 3: сдал через 20 минут — получил 100
            PT20M              | 50
            PT1H               | 50
            PT24H              | 50
            PT24H0.000000001S  | 0
            P2D                | 0
            """)
    void scoreByLateness(Duration lateness, int expected) {
        int score = DeadlineScore.score(DEADLINE, DEADLINE.plus(lateness));

        assertThat(score).isEqualTo(expected);
    }

Как это читать:

Строка-жалоба с комментарием#

Строка, которая начинается с #, — комментарий, и JUnit её пропускает. Так можно только в textBlock. Тест twentyMinutesLateGetsHalf из статьи 3 стал строкой PT20M, но история осталась рядом. Захочет кто-нибудь «почистить» таблицу от странной строки — комментарий скажет: это память о настоящей ошибке.

⚠️ Решётка — комментарий, только если она первая в строке, без пробелов перед ней. Закрывающие """ тоже участвуют в подсчёте общего отступа. Сдвинь их левее строк таблицы, и у каждой строки останутся пробелы слева: комментарий станет строкой данных. JUnit попробует превратить его в Duration и уронит этот запуск: Failed to convert String "# жалоба из статьи 3: …" to type java.time.Duration. Держи """ на одном отступе с таблицей.

Duration из текста#

Как текст PT20M становится Duration? JUnit делает это сам: неявное преобразование (implicit conversion). Параметр объявлен как Duration, пришла строка — JUnit вызывает Duration.parse. Так же "50" становится int.

Duration.parse читает формат ISO-8601 — международный стандарт записи дат и отрезков времени:

-PT1S — минус секунда, то есть за секунду до срока. PT0S — ноль. PT24H0.000000001S — 24 часа и 0,000000001 секунды: сутки и одна наносекунда. Девять знаков после точки — ровно наносекунды, а десятый Duration.parse не примет.

Опечатка PT20 без единицы зелёного не даст. Запуск упадёт с Failed to convert String "PT20" to type java.time.Duration.

Имя каждого запуска#

name = "опоздание {0} → {1}" — как подписан каждый запуск: на место {0} встаёт первый аргумент, на место {1} второй. В IDE под scoreByLateness будет девять строк, примерно так:

опоздание "-P30D" → "100"
опоздание "PT0.000000001S" → "50"
опоздание "PT24H0.000000001S" → "0"

⚠️ В интернете ты увидишь такие имена без кавычек: опоздание PT20M → 50. Так было до JUnit 6. В песочнице JUnit 6, и он берёт текстовые аргументы в кавычки.

А из @CsvSource все аргументы приходят текстом: имя собирается до превращения в Duration и int. Поэтому кавычки и у опоздания, и у числа: опоздание "PT20M" → "50". Это не ошибка. Кавычки можно выключить настройкой quoteTextArguments = false, но незачем.

Несчастливый путь: исключение с типом и текстом

Остались две строки таблицы, где ждём не балл, а ошибку. Одна из них — случай «срока нет», который статья 2 отложила на потом. В статье 3 ErrorTest упал на нём с Errors, а тест должен такую ошибку ждать:

    @Test
    void noDeadlineIsAnError() {
        assertThatThrownBy(() -> DeadlineScore.score(null, DEADLINE))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Срок не указан");
    }

Читается так: «утверждаю, что этот кусок бросит исключение типа IllegalArgumentException с текстом ровно „Срок не указан“». IllegalArgumentException.class — так называют класс исключения, которое ждём.

() -> DeadlineScore.score(null, DEADLINE) — лямбда: кусочек кода, который передают как значение, не выполняя сразу. Слева от стрелки параметры, справа то, что сделать; параметров здесь нет. Вызови score(null, …) напрямую, и исключение вылетит раньше проверки. А лямбду AssertJ вызовет сам, поймает исключение и проверит его. Не будет исключения — тест красный: Expecting code to raise a throwable.

Почему мало одного типа. В DeadlineScore две проверки на null, и обе бросают IllegalArgumentException: различает их только текст. Перепутай кто-нибудь тексты, и тест «только по типу» останется зелёным.

К тому же текст — часть правила: «ошибка с понятным текстом». Раз обещали, проверяем. hasMessage сверяет текст целиком, а если важно только слово, есть hasMessageContaining — «содержит».

    @ParameterizedTest
    @NullSource
    void noSubmissionTimeIsAnError(Instant submittedAt) {
        assertThatThrownBy(() -> DeadlineScore.score(DEADLINE, submittedAt))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Время сдачи не указано");
    }

@NullSource — источник из одного значения, null: тест запустится один раз, с submittedAt = null. Для одного null хватило бы и @Test, а здесь он — чтобы ты видел оба способа. Источник полезен, когда негодных значений несколько: его ставят рядом с другим источником, и к их строкам добавится null. Параметру простого типа, int или long, он не годится.

Тест halfAnHourAfterMoscowMidnightIsLate из статьи 3 остаётся отдельным методом. У него другой срок: полночь по Москве из даты и пояса. А таблица меняет только опоздание от одного DEADLINE. Итого в DeadlineScoreTest четыре метода и двенадцать запусков.


Часть 3. Доказательство: ломаем код

Таблица длиннее четырёх тестов. Но ловит ли она больше? Сломаем код, как в статье 1, и посмотрим, кто покраснеет. Номера поломок — из общего списка. Целиком, вместе с M3, его покажет следующая статья.

Поломка M2: сутки стали 23 часами. Это та самая дыра из статьи 2:

    static final Duration GRACE = Duration.ofHours(23);         // сколько можно опоздать за половину

Поломка M5: у второй границы «не позже» стало «раньше». Комментарий остался от верного кода и теперь врёт:

        if (submittedAt.isBefore(deadline.plus(GRACE))) {       // ровно через сутки — ещё половина

Поломка M4: срока нет, а вместо ошибки балл 100.

        if (deadline == null) {
            return FULL;
        }
поломка 4 теста статьи 2 тесты статьи 4
M1 — isBefore у срока, из статьи 1 красный onDeadlineIsStillOnTime красная строка PT0S
M2 — сутки стали 23 часами все зелёные красная строка PT24H: expected: 50, but was: 0
M5 — isBefore у второй границы все зелёные красная строка PT24H: expected: 50, but was: 0
M4 — нет срока, а балл 100 все зелёные красный noDeadlineIsAnError
«упрощение» коллеги из статьи 3 все зелёные красные строки PT0.000000001S, PT20M, PT24H0.000000001S и тест полуночи

Шесть тестов статьи 3 при M2, M4 и M5 тоже зелёные. Разберём почему.

M2. Тесты «через час» и «через двое суток» далеко от границы, а ошибка живёт между 23 и 24 часами, где теста нет. Строка PT24H стоит ровно там.

M5. Код ошибается в одной точке, ровно через сутки. Это поломка с isBefore из статьи 1, только у второй границы. Поймать её может лишь строка «ровно на границе». Две разные поломки, M2 и M5, поймала одна строка.

M4. Класса «нет данных» у тестов статьи 2 не было, и ловить было нечем. Забытый класс — целый непроверенный кусок правила.

Ошибка коллеги. Тесты статьи 3 поймали её на 20 минутах. Второе её лицо было в 💡 статьи 3: для toHours() сутки с минутами — ещё «24 часа», и сдавший получает 50 вместо 0. Теперь это строка PT24H0.000000001S. А строка PT1H при этой ошибке зелёная: через час и верный, и сломанный код дают 50.

Строки на обеих границах и тест «нет срока» мы видели красными, каждый на своей поломке. Значит, их зелёному можно верить (статья 1). Дело не в числе строк: сто тестов «через час» не поймали бы ни одной из этих поломок.


Часть 4. Списки, пустота и таблица задачи

Ноль — одно — много#

Вот обещанный Attempts, лучшая из нескольких сдач:

    /** Лучший балл из всех сдач; не сдавал ни разу — ноль. */
    public static int best(Instant deadline, List<Instant> submissions) {
        if (submissions == null) {
            throw new IllegalArgumentException("Список сдач не передан");
        }
        return submissions.stream()
                .mapToInt(submittedAt -> DeadlineScore.score(deadline, submittedAt))
                .max()                                          // лучшая из сдач
                .orElse(DeadlineScore.NONE);                    // сдач нет — ноль
    }

stream() … max() — способ пройти по списку и взять наибольшее. submittedAt -> … — снова лямбда, теперь с одним параметром: что сделать с каждой сдачей. У пустого списка max() ничего не найдёт, и тогда orElse подставит ноль.

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

    @Test
    void noAttemptsGetNothing() {
        assertThat(Attempts.best(DEADLINE, List.of())).isEqualTo(0);
    }

    @Test
    void singleAttemptCountsAsIs() {
        Instant late = DEADLINE.plus(Duration.ofHours(3));

        assertThat(Attempts.best(DEADLINE, List.of(late))).isEqualTo(50);
    }

    @Test
    void bestOfSeveralAttemptsCounts() {
        Instant early = DEADLINE.minus(Duration.ofDays(1));
        Instant late = DEADLINE.plus(Duration.ofDays(3));

        assertThat(Attempts.best(DEADLINE, List.of(late, early))).isEqualTo(100);
    }

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

null снаружи и внутри списка#

Ещё два несчастливых пути: списка нет вовсе, или список есть, но в нём дыра.

    @Test
    void missingListIsAnError() {
        assertThatThrownBy(() -> Attempts.best(DEADLINE, null))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Список сдач не передан");
    }

    @Test
    void missingTimeInsideListIsAnError() {
        List<Instant> withHole = Arrays.asList(DEADLINE, null);     // List.of не пускает null

        assertThatThrownBy(() -> Attempts.best(DEADLINE, withHole))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Время сдачи не указано");
    }

У дыры внутри текст из DeadlineScore. Так тест заодно показывает, что каждая сдача прошла через общее правило.

⚠️ List.of(…) обычно лучший способ собрать список, но null он не пускает и бросит NullPointerException. Тест упал бы ещё до вызова best, с Errors, ничего не проверив. Для списка с дырой здесь Arrays.asList: он null пускает.

Тот же перечень на рабочем коде

В команде так же разложены тесты расчёта цены со скидками:

Порядок здесь, в отличие от Attempts, важен, и тест порядка подобран так, чтобы неверный дал другой итог.

Как читать таблицу задачи#

В рабочем проекте раздел задачи «Требования к тестам» (статья 1) обычно начинается с того, где лежат тесты и как назвать класс. Дальше идут пункты «Тест N: …»: что подать на вход и строка «Проверить: …». Иногда вместо пунктов таблица, где строка — случай.

Читай раздел как таблицу «вход → итог»: каждый пункт — строка. «Проверить» даёт итог, а если назван текст ошибки, то и текст. Правило там так и записано: строка таблицы — проверка. Выпиши строки до кода, и сразу увидишь, каких классов в них нет.

Допустим, задача про Attempts даёт такую таблицу:

сдачи итог
ни одной 0
одна, через 3 часа после срока 50
две: через 3 дня и за день до срока 100

Ноль, одно и много здесь есть. А null нет ни снаружи списка, ни внутри. Строки задачи нужны все до одной, а две свои добавь сверх них и объясни в пул-реквесте: ревьюер увидит, что ты подумал о несчастливом пути.

Потом пройди по тексту задачи с карандашом. Числа, «не больше», «от … до», «пусто», «не указан» — это классы и границы. Есть ли в требованиях обе стороны каждой границы? Пустой список, один элемент, несколько? null?

Проверка, смотревшая в одну сторону

В рабочем проекте есть проверки полноты: тесты сверяют список адресов в описании с адресами в коде. Сверять можно в две стороны: «каждый адрес из описания есть в коде» и «каждый адрес из кода есть в описании». Часть проверок смотрела только в первую сторону: шла по описанию и искала адрес в коде. Три ручки, которых в описании не было вовсе, прошли мимо них: сверять было не с чем. Проверка в обратную сторону идёт по коду и сразу нашла пять ручек без записи в описании, среди них и те три.

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

💡 Есть подход, где случаи придумывает машина, — тестирование свойствами (property-based testing). Ты описываешь свойство, например «с ростом опоздания балл не растёт», а библиотека сама подбирает сотни входов и ищет тот, на котором свойство ломается.


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

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

  1. Чем счастливый путь отличается от несчастливого, и почему тесты только на счастливый проверяют самую лёгкую часть кода?
  2. На какие классы делятся входы DeadlineScore? Какого класса не было у четырёх тестов статьи 2, и какая поломка этим воспользовалась?
  3. Почему тесты «за минуту» и «через час» не замечают, что сутки стали 23 часами, а строка PT24H замечает?
  4. Почему «сразу после срока» в таблице — PT0.000000001S, а не PT1S? Прочитай вслух PT24H0.000000001S.
  5. Как строка PT20M | 50 попадает в параметры Duration lateness и int expected? Почему в имени запуска у числа 50 кавычки?
  6. Зачем у исключения проверять не только тип, но и текст? Объясни на двух проверках DeadlineScore.
  7. Что значит «ноль — одно — много» для Attempts? Почему в тесте «много» лучшая сдача стоит не первой, и почему список с null внутри собран через Arrays.asList?
  8. В «Требованиях к тестам» задачи три пункта, а в тексте задачи — «не больше 10 штук». Каких строк может не хватать, и что ты с этим сделаешь?

Что дальше#

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

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


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

Учебный пример живёт в песочнице testy из статьи 1. Новых зависимостей не нужно: параметризованные тесты приходят вместе с spring-boot-starter-test. DeadlineScore такой же, как в статье 1: он здесь, чтобы было что вернуть после поломок. DeadlineScoreTest целиком заменяет тест статьи 3, по тому же пути. Attempts и AttemptsTest — новые. 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.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullSource;

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

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

class DeadlineScoreTest {

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

    @ParameterizedTest(name = "опоздание {0} → {1}")
    @CsvSource(delimiter = '|', textBlock = """
            -P30D              | 100
            -PT1S              | 100
            PT0S               | 100
            PT0.000000001S     | 50
            # жалоба из статьи 3: сдал через 20 минут — получил 100
            PT20M              | 50
            PT1H               | 50
            PT24H              | 50
            PT24H0.000000001S  | 0
            P2D                | 0
            """)
    void scoreByLateness(Duration lateness, int expected) {
        int score = DeadlineScore.score(DEADLINE, DEADLINE.plus(lateness));

        assertThat(score).isEqualTo(expected);
    }

    @Test
    void noDeadlineIsAnError() {
        assertThatThrownBy(() -> DeadlineScore.score(null, DEADLINE))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Срок не указан");
    }

    @ParameterizedTest
    @NullSource
    void noSubmissionTimeIsAnError(Instant submittedAt) {
        assertThatThrownBy(() -> DeadlineScore.score(DEADLINE, submittedAt))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Время сдачи не указано");
    }

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

import java.time.Instant;
import java.util.List;

/** Несколько сдач одной задачи: в зачёт идёт лучшая. */
public final class Attempts {

    private Attempts() {
    }

    /** Лучший балл из всех сдач; не сдавал ни разу — ноль. */
    public static int best(Instant deadline, List<Instant> submissions) {
        if (submissions == null) {
            throw new IllegalArgumentException("Список сдач не передан");
        }
        return submissions.stream()
                .mapToInt(submittedAt -> DeadlineScore.score(deadline, submittedAt))
                .max()                                          // лучшая из сдач
                .orElse(DeadlineScore.NONE);                    // сдач нет — ноль
    }
}
// src/test/java/example/testy/score/AttemptsTest.java
package example.testy.score;

import org.junit.jupiter.api.Test;

import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;

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

class AttemptsTest {

    private static final Instant DEADLINE = Instant.parse("2026-10-12T21:00:00Z");

    @Test
    void noAttemptsGetNothing() {
        assertThat(Attempts.best(DEADLINE, List.of())).isEqualTo(0);
    }

    @Test
    void singleAttemptCountsAsIs() {
        Instant late = DEADLINE.plus(Duration.ofHours(3));

        assertThat(Attempts.best(DEADLINE, List.of(late))).isEqualTo(50);
    }

    @Test
    void bestOfSeveralAttemptsCounts() {
        Instant early = DEADLINE.minus(Duration.ofDays(1));
        Instant late = DEADLINE.plus(Duration.ofDays(3));

        assertThat(Attempts.best(DEADLINE, List.of(late, early))).isEqualTo(100);
    }

    @Test
    void missingListIsAnError() {
        assertThatThrownBy(() -> Attempts.best(DEADLINE, null))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Список сдач не передан");
    }

    @Test
    void missingTimeInsideListIsAnError() {
        List<Instant> withHole = Arrays.asList(DEADLINE, null);     // List.of не пускает null

        assertThatThrownBy(() -> Attempts.best(DEADLINE, withHole))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Время сдачи не указано");
    }
}

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

  1. Запусти ./mvnw test. Всё зелёное; если в песочнице только файлы статей 1–4, итог — Tests run: 18: двенадцать запусков DeadlineScoreTest, пять AttemptsTest и тест от start.spring.io.
  2. Поменяй в DeadlineScore Duration.ofHours(24) на Duration.ofHours(23). Красная одна строка: expected: 50, but was: 0. В IDE она подписана опоздание "PT24H" → "50", а в журнале Maven — именем метода с номером запуска: строка PT24H в таблице седьмая. Верни.
  3. Замени в DeadlineScore проверки после null версией коллеги из статьи 3. Красные строки PT0.000000001S, PT20M, PT24H0.000000001S и тест полуночи, а PT1H зелёная. Верни.
  4. Замени throw new IllegalArgumentException("Срок не указан"); на return FULL;. Красный noDeadlineIsAnError: Expecting code to raise a throwable. Верни.
  5. Сдвинь закрывающие """ таблицы на четыре пробела левее. Запусков станет тринадцать, и красным будет комментарий, ставший строкой данных. Верни.

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

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

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

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