Какие случаи проверять
Для кого. Ты прочитал статьи 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);
}Как это читать:
@ParameterizedTestвместо@Test— «запускать по разу на каждую строку». Строки даёт источник — следующая пометка.@CsvSource— источник-таблица. CSV — формат «значения через разделитель»; каждая строка — один запуск."""…"""— текстовый блок: строка Java на несколько строк. Общий отступ слева Java убирает сама.delimiter = '|'— разделитель колонок. По умолчанию это запятая, а черта читается как таблица. Пробелы вокруг значений JUnit обрезает.- У метода появились параметры. В статье 2 тестам они были не нужны, а
здесь их заполняет JUnit: первая колонка идёт в
lateness, вторая вexpected. -P30D,PT20M,P2D— отрезки времени в формате ISO-8601: минус 30 дней, 20 минут, 2 дня. Разбор ниже.DEADLINE.plus(lateness)— срок плюс опоздание.DEADLINE— константа из статьи 2.
Строка-жалоба с комментарием#
Строка, которая начинается с #, — комментарий, и 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 —
международный стандарт записи дат и отрезков времени:
P— начало отрезка, есть всегда;D— дни; день здесь ровно 24 часа;T— дальше идут часы, минуты, секунды;H,M,S— часы, минуты, секунды; у секунд может быть дробная часть, до девяти знаков после точки;- минус перед
P— весь отрезок отрицательный. Знак — добавка Java, в самом стандарте его нет.
-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
пускает.
Тот же перечень на рабочем коде
В команде так же разложены тесты расчёта цены со скидками:
- активных правил нет — цена не меняется; одно правило; много правил и порядок между ними;
- правило есть, но истекло;
- человек не указан,
null: личные скидки не применяются; - скидка 100% даёт итог 0, а не минус; скидка больше цены — тоже 0;
- неровное число: округляется итог, а не скидка;
- неизвестный или неактивный тариф — исключение.
Порядок здесь, в отличие от Attempts, важен, и тест
порядка подобран так, чтобы неверный дал другой итог.
Как читать таблицу задачи#
В рабочем проекте раздел задачи «Требования к тестам» (статья 1) обычно начинается с того, где лежат тесты и как назвать класс. Дальше идут пункты «Тест N: …»: что подать на вход и строка «Проверить: …». Иногда вместо пунктов таблица, где строка — случай.
Читай раздел как таблицу «вход → итог»: каждый пункт — строка. «Проверить» даёт итог, а если назван текст ошибки, то и текст. Правило там так и записано: строка таблицы — проверка. Выпиши строки до кода, и сразу увидишь, каких классов в них нет.
Допустим, задача про Attempts даёт такую таблицу:
| сдачи | итог |
|---|---|
| ни одной | 0 |
| одна, через 3 часа после срока | 50 |
| две: через 3 дня и за день до срока | 100 |
Ноль, одно и много здесь есть. А null нет ни снаружи
списка, ни внутри. Строки задачи нужны все до одной, а две свои добавь
сверх них и объясни в пул-реквесте: ревьюер увидит, что ты подумал о
несчастливом пути.
Потом пройди по тексту задачи с карандашом. Числа, «не больше», «от …
до», «пусто», «не указан» — это классы и границы. Есть ли в требованиях
обе стороны каждой границы? Пустой список, один элемент, несколько?
null?
Проверка, смотревшая в одну сторону
В рабочем проекте есть проверки полноты: тесты сверяют список адресов в описании с адресами в коде. Сверять можно в две стороны: «каждый адрес из описания есть в коде» и «каждый адрес из кода есть в описании». Часть проверок смотрела только в первую сторону: шла по описанию и искала адрес в коде. Три ручки, которых в описании не было вовсе, прошли мимо них: сверять было не с чем. Проверка в обратную сторону идёт по коду и сразу нашла пять ручек без записи в описании, среди них и те три.
У каждой проверки спрашивай, в какую сторону она смотрит и что проходит мимо. Тесты по требованиям смотрят от обещаний к коду. Обратный вопрос — нет ли в коде того, чего никто не обещал и не проверил, например лишней ветви. На него отвечает отчёт покрытия: как его читать, показано в статье 0, а подробнее — в продолжении серии.
💡 Есть подход, где случаи придумывает машина, — тестирование свойствами (property-based testing). Ты описываешь свойство, например «с ростом опоздания балл не растёт», а библиотека сама подбирает сотни входов и ищет тот, на котором свойство ломается.
Проверь себя#
Ответь своими словами — вслух или на бумаге. Не получается — перечитай раздел.
- Чем счастливый путь отличается от несчастливого, и почему тесты только на счастливый проверяют самую лёгкую часть кода?
- На какие классы делятся входы
DeadlineScore? Какого класса не было у четырёх тестов статьи 2, и какая поломка этим воспользовалась? - Почему тесты «за минуту» и «через час» не замечают, что сутки стали
23 часами, а строка
PT24Hзамечает? - Почему «сразу после срока» в таблице —
PT0.000000001S, а неPT1S? Прочитай вслухPT24H0.000000001S. - Как строка
PT20M | 50попадает в параметрыDuration latenessиint expected? Почему в имени запуска у числа 50 кавычки? - Зачем у исключения проверять не только тип, но и текст? Объясни на
двух проверках
DeadlineScore. - Что значит «ноль — одно — много» для
Attempts? Почему в тесте «много» лучшая сдача стоит не первой, и почему список сnullвнутри собран черезArrays.asList? - В «Требованиях к тестам» задачи три пункта, а в тексте задачи — «не больше 10 штук». Каких строк может не хватать, и что ты с этим сделаешь?
Что дальше#
Следующая статья серии — тест, который ничего не проверяет: зелёный при любой поломке. Будем ломать код руками, по порядку, и разберём, что говорит покрытие и о чём оно молчит.
Первоисточники#
- JUnit
6 — Parameterized Classes and Tests —
@CsvSource,@NullSource, неявное преобразование (таблица типов), имена запусков и кавычки JUnit 6. - AssertJ
— Exception assertions —
assertThatThrownBy,hasMessageи родственные проверки. - Java
25 —
Duration.parse— форматPnDTnHnMn.nSс примерами. - Java
25 —
Instant— первые абзацы: как хранится время и почему точность — наносекунда. - Java
25 —
List, Unmodifiable Lists — почемуList.ofне пускаетnull.
Пример целиком — с импортами#
Учебный пример живёт в песочнице 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("Время сдачи не указано");
}
}Сделай руками:
- Запусти
./mvnw test. Всё зелёное; если в песочнице только файлы статей 1–4, итог —Tests run: 18: двенадцать запусковDeadlineScoreTest, пятьAttemptsTestи тест от start.spring.io. - Поменяй в
DeadlineScoreDuration.ofHours(24)наDuration.ofHours(23). Красная одна строка:expected: 50,but was: 0. В IDE она подписанаопоздание "PT24H" → "50", а в журнале Maven — именем метода с номером запуска: строкаPT24Hв таблице седьмая. Верни. - Замени в
DeadlineScoreпроверки послеnullверсией коллеги из статьи 3. Красные строкиPT0.000000001S,PT20M,PT24H0.000000001Sи тест полуночи, аPT1Hзелёная. Верни. - Замени
throw new IllegalArgumentException("Срок не указан");наreturn FULL;. КрасныйnoDeadlineIsAnError:Expecting code to raise a throwable.Верни. - Сдвинь закрывающие
"""таблицы на четыре пробела левее. Запусков станет тринадцать, и красным будет комментарий, ставший строкой данных. Верни.
Теперь у каждой строки таблицы есть причина стоять в ней, а у каждой границы — поломка, на которой она краснеет.