Первый модульный тест: от пустого файла до зелёного отчёта
Для кого. Ты прочитал статью 1 этой серии: знаешь, что такое тест, где лежит папка
src/testи чемFailuresотличается отErrors. Тесты ты пока читал, а писать не писал — или писал по образцу из интернета.Что будет. Как написать модульный тест с нуля — файл, класс, метод, проверку — и зачем в нём каждая строка; почему ожидание в тесте — число, а тест — на одно поведение, и как его назвать; как запустить один тест, класс и все, прочитать зелёный отчёт и убедиться, что каждый тест умеет краснеть.
Откуда статья. Серия написана для джунов команды, которая пишет на Spring Boot 4 шлюз уведомлений — сервис платформы Hammerhall, который рассылает письма и сообщения. Примеры — учебные: их можно повторить у себя.
Зачем писать тест с нуля#
Первая задача с кодом, и в ней — раздел о тестах: какие написать и
что каждый проверяет. Ты открываешь IDE, а в src/test/java
— пусто или чужие файлы. Где создать свой, что написать первой строкой,
как назвать метод?
Интернет отвечает охотно, но часто из прошлого: многие примеры написаны для JUnit 4, и в песочнице серии они не скомпилируются. А тест, собранный из чужих кусков без понимания, на ревью не защитишь. Ревьюер спросит, почему в ожидании число, а не константа, и почему тестов четыре, а не один, — и ответ должен быть твой.
Поэтому в этой статье — один класс тестов от пустого файла до зелёного отчёта, по строке. Модульный тест — помнишь таблицу из статьи 1 — проверяет один класс без базы, сети и Spring: самый быстрый и самый частый вид тестов.
Пример — тот же зачёт по сроку: до срока или ровно в срок — 100,
позже, но не больше чем на сутки — 50, ещё позже — 0. Считает его
статический метод
DeadlineScore.score(deadline, submittedAt): срок и время
сдачи, оба Instant, на выходе балл. Мы напишем четыре теста
— по одному на каждый случай. Случай, когда срока нет
(null), — в продолжении серии.
Песочница — та же testy, что в статье 1. В ней уже есть
DeadlineScore, тест TestyApplicationTests,
который положил Initializr, и наш DeadlineScoreTest с одним
тестом. Его содержимое мы напишем заново. Песочницы нет — как её
собрать, сказано в конце статьи 1. В коротких примерах ниже нет строк
import; полные файлы — в конце этой статьи.
Часть 1. Пустой файл, первый метод
Где создать файл#
Тест к классу лежит в src/test/java, в том же пакете,
что и класс, и называется именем класса плюс Test — по
этому окончанию Maven его и находит (статья 1). Для
DeadlineScore из пакета example.testy.score
путь такой:
src/test/java/example/testy/score/DeadlineScoreTest.java.
В IDE найди в дереве проекта папку src/test/java, создай
в ней пакет example.testy.score, если его ещё нет, и в
пакете — класс. Пакет пишется через точки, вложенные папки IDE сделает
сама.
Зачем тот же пакет, помнишь из статьи 1: тест видит то, что объявлено без модификатора доступа, и его легко найти — тот же путь, только верхняя папка другая. Ревьюер ищет его именно там.
Пустой класс#
package example.testy.score;
class DeadlineScoreTest {
}Это уже настоящий класс тестов, только тестов в нём пока ноль. И
заметь: перед class нет public. В курсах
классы почти всегда публичные, и рука тянется написать.
Тестовому классу это не нужно: JUnit 5 и 6 находят и запускают классы
и методы без public, нельзя только private.
Документация JUnit прямо советует public не писать, если
нет особой причины. Работе он не мешает — просто лишнее слово.
Метод с пометкой @Test#
class DeadlineScoreTest {
@Test
void beforeDeadlineGetsFull() {
// …
}
}@Test — аннотация (помнишь, статья 1: пометка, которую
читает инструмент). JUnit ищет в классе методы с этой пометкой и каждый
запускает как отдельный тест. Импорт —
org.junit.jupiter.api.Test.
Сам метод устроен просто:
void— тест ничего не возвращает, так требует JUnit. Итог теста — не значение, а исход: прошёл или упал.- Без параметров. Тест запускает JUnit, а не твой код, и нашим тестам параметры не нужны. Бывают тесты, которые получают данные строками таблицы, — о них в продолжении серии.
- Без
public— как и класс. - Имя — о нём часть 3.
⚠️ В интернете ты увидишь import org.junit.Test,
public у класса и у каждого метода и проверки вида
Assert.assertEquals(…). Это JUnit 4: там
public был обязателен. В песочнице JUnit 6, а пишется он
как пятый: org.junit.jupiter.api.Test, без
public, проверки — AssertJ. Четвёртого JUnit в песочнице
нет, и такой тест не скомпилируется:
package org.junit does not exist.
Хуже, если JUnit 4 приедет в проект вместе с чужой библиотекой. Тогда тест скомпилируется, но сборка, скорее всего, его не запустит, а IDE — может: снова «зелёный только в IDE». Тесты JUnit 3 и 4 запускает особый движок JUnit для старых тестов, Vintage, — а его в песочнице нет. Проверка — из статьи 1: добавил тест, и число тестов в отчёте выросло ровно на столько же.
Константа DEADLINE#
Первая строка внутри класса:
private static final Instant DEADLINE = Instant.parse("2026-10-12T21:00:00Z"); // полночь 13 октября по МосквеСрок нужен всем четырём тестам, и он у всех один. Написать его в каждом тесте заново — четыре одинаковые строки, и читателю придётся сверять, правда ли они одинаковые. Поэтому срок — константа: значение с именем, записанное один раз. Тогда в каждом тесте видно только то, чем он отличается от соседей, — время сдачи.
Каждое слово объявления — со смыслом.
private— константа нужна только этому классу.static— она одна на весь класс. JUnit перед каждым тестом создаёт новый объект тестового класса: так тесты не влияют друг на друга через поля. Обычное поле создавалось бы заново для каждого теста;static— одно на всех, и по нему сразу видно: это общие данные, а не состояние теста.final— присвоить константе другое значение нельзя. Ни один тест не передвинет срок для остальных.- Имя заглавными буквами через подчёркивание — так в Java принято называть константы.
И ещё одно свойство — самого Instant: он
неизменяемый. DEADLINE.plus(…) не сдвигает
срок, а возвращает новый момент времени. Поэтому одну константу можно
спокойно делить между тестами — испортить её нельзя.
Часть 2. Тело теста: дано — когда — тогда
Три блока#
Вот первый тест целиком:
@Test
void beforeDeadlineGetsFull() {
Instant submittedAt = DEADLINE.minus(Duration.ofMinutes(1));
int score = DeadlineScore.score(DEADLINE, submittedAt);
assertThat(score).isEqualTo(100);
}Он состоит из трёх блоков, разделённых пустыми строками.
- Дано — подготовка. Срок уже есть, это константа;
время сдачи — за минуту до срока.
minusвычитает отрезок времени,Duration— этот отрезок (помнишь из статьи 1),ofMinutes(1)— одна минута. - Когда — ровно одно действие, которое проверяем:
вызов
score. - Тогда — проверка: балл равен 100.
Схему так и называют: «дано — когда — тогда» (given
— when — then; встретишь и «arrange — act — assert»). Пустые строки
здесь не для красоты: по ним глаз сразу находит, где подготовка, где
действие, где проверка. Некоторые команды пишут ещё комментарии
// given, // when, // then; в
коротком тесте хватает пустых строк.
Во втором тесте блоков два:
@Test
void onDeadlineIsStillOnTime() {
int score = DeadlineScore.score(DEADLINE, DEADLINE); // сдал ровно в срок
assertThat(score).isEqualTo(100);
}Подготавливать нечего: сдал ровно в срок — значит, время сдачи и есть
DEADLINE. Блок может быть пустым; порядок — нет. Проверка
всегда после действия, а действие одно.
Проверка и import static#
assertThat(score).isEqualTo(100) — это AssertJ,
библиотека проверок из набора spring-boot-starter-test
(статья 1). Читается фразой: «утверждаю, что балл равен 100». В
assertThat(…) — то, что получил код; в
isEqualTo(…) — то, что ты ждёшь. Перепутать трудно, порядок
подсказывают сами слова.
Не сошлось — тест красный, и AssertJ пишет оба значения: ожидалось одно, получено другое. Как читать красный отчёт целиком — в продолжении серии.
Откуда assertThat без имени класса? Это статический
метод класса org.assertj.core.api.Assertions, и полностью
вызов выглядел бы так: Assertions.assertThat(score). Короче
его делает строка в начале файла:
import static org.assertj.core.api.Assertions.assertThat;Обычный import подключает класс.
import static подключает статический член
класса — метод или константу, — и его можно писать без имени класса.
Документация Java советует пользоваться этим скупо, только для того, что
зовёшь постоянно. assertThat зовут в каждом тесте, поэтому
так пишут везде.
⚠️ IDE на assertThat может предложить несколько
вариантов из разных библиотек. В том же наборе
spring-boot-starter-test приезжает
Hamcrest — ещё одна библиотека проверок, ставить её не
нужно; у неё свой assertThat с двумя аргументами, в классе
org.hamcrest.MatcherAssert. Выберешь его — строка
assertThat(score).isEqualTo(100) не скомпилируется. Нам
нужен org.assertj.core.api.Assertions.
Почему 100, а не DeadlineScore.FULL
У DeadlineScore есть константа FULL = 100.
Велик соблазн написать проверку через неё: короче и без
магического числа — числа в коде, смысл которого не
объяснён. Вот так — неверно, только для разбора:
assertThat(score).isEqualTo(DeadlineScore.FULL); // так не пишем: ожидание взято из проверяемого кодаПредставь, что кто-то, правя класс, опечатался:
FULL = 10. Теперь за работу, сданную вовремя, код даёт 10
баллов. Тест с FULL по сути спрашивает код: «ты вернул
столько, сколько сам считаешь полным баллом?»
Код отвечает «да»: 10 равно 10. Тест зелёный, а ошибка уходит в
работу. Тест с числом 100 покраснеет: ждали 100, получили
10.
Ожидание — то, что тест считает правильным ответом. Его записывают независимо от кода, который проверяют, — по обещанию, то есть по строке правила: «вовремя — 100». Тест, взявший ожидание из проверяемого кода, сверяет код с ним же самим — как ученик, который проверяет контрольную по своему же черновику.
Число в тесте повторяет число в коде нарочно. Обещание записано дважды, в двух независимых местах, и сборка следит, чтобы записи совпадали. Это дублирование — не грех, а смысл теста.
🔑 Ожидание в тесте — из обещания, а не из кода. Ошибся в константе — тест с той же константой согласится с ошибкой.
С DEADLINE дело другое: это данные самого теста, а не
ожидание. Тест берёт её у себя, а не у проверяемого класса.
Часть 3. Четыре поведения, четыре имени
Остальные тесты#
Ещё два теста — на опоздание:
@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);
}Теперь у нас четыре момента сдачи: за минуту до срока — 100, ровно в
срок — 100, через час — 50, через двое суток — 0. Каждый тест — одно
поведение: одно обещание правила, проверенное на одном
случае. Пометку @DisplayName над последним разберём
ниже.
Один тест — одно поведение#
Почему не один тест с четырьмя проверками подряд? Три причины.
Первая же несошедшаяся проверка останавливает тест. AssertJ бросает ошибку, и строки ниже не выполняются. Сломались два правила — увидишь одно; починишь его — увидишь второе. Четыре отдельных теста покажут обе поломки сразу.
Имя говорит, что нарушено. Красный тест
scoreWorks сообщает только «что-то с баллами». Красный
lateWithinDayGetsHalf называет нарушенное обещание ещё до
того, как ты открыл код.
Каждый тест можно запустить отдельно — и только его, пока чинишь именно это правило. Поэтому тесты не должны зависеть друг от друга. JUnit не обещает даже порядок файла: порядок у него, как сказано в документации, «детерминированный, но намеренно неочевидный». Каждый тест готовит всё сам, общая у них только неизменяемая константа.
Одно поведение — не обязательно одна строка assertThat.
Если ответ метода — объект из двух полей, проверок может быть две.
Имя теста — первое, что ты прочтёшь, когда он упадёт
В песочнице имена — короткая фраза по-английски, слова слитно, каждое
следующее — с заглавной: случай — и что при нём происходит.
lateWithinDayGetsHalf — «опоздал в пределах суток —
получает половину», onDeadlineIsStillOnTime — «ровно в срок
— ещё вовремя». Слово test в имени лишнее: и так понятно,
что это тест.
Плохие имена — test1, testScore,
scoreWorks. Когда в проекте с сотнями тестов покраснеет
test3, его имя не скажет ничего — придётся читать код,
чтобы просто понять, что проверялось.
У другого стиля имя складывается из частей через подчёркивание,
метод_Should…_When…: метод — что должно быть — при каком
условии. Он длиннее, зато удобен, когда в одном классе тесты нескольких
методов: первая часть имени группирует их по методу. Наш класс проверяет
один метод, score, — первая часть повторялась бы в каждом
имени, поэтому здесь короткий стиль. Живут оба; в чужом проекте пиши
так, как там принято.
@DisplayName — имя для людей#
@DisplayName — пометка JUnit, которая
даёт тесту второе имя, для отчёта. В нём можно то, чего нет в имени
метода: пробелы, тире, знаки препинания, русский язык. В нашем файле она
стоит над одним тестом, чтобы ты увидел разницу: в IDE последний тест
подписан не lateByMoreThanDayGetsNothing(), а «опоздал
больше чем на сутки — ноль».
Когда она помогает. Когда фразу проще прочитать по-русски, чем собрать из английских слов. Когда в случае есть числа и условия, которые в имени метода превращаются в кашу.
Когда мешает. Когда имя метода и так читается — тогда это второе имя,
которое надо держать в согласии с первым. Поменял поведение, поправил
имя метода, забыл про @DisplayName — и отчёт врёт.
И главное: Maven русское имя по умолчанию не показывает. В зелёном
журнале у него только классы, а упавший тест он называет именем метода.
Это настраивается, но в песочнице не настроено. Поэтому
@DisplayName не заменяет хорошее имя метода, а только
дополняет его.
Хороший @DisplayName — обещание целиком: случай и исход.
«Опоздал больше чем на сутки» называет только случай; «опоздал больше
чем на сутки — ноль» говорит и что при нём будет.
Часть 4. Запустить, прочитать зелёное, сломать
Три способа запуска#
- Один тест — зелёный треугольник рядом с методом в IDE. Так проверяешь то, что пишешь прямо сейчас: секунда — и ответ.
- Класс — треугольник рядом с классом или команда
./mvnw test -Dtest=DeadlineScoreTest. - Все —
./mvnw test. Перед пушем —./mvnw verify, как в статье 1.
Зелёный отчёт в IDE#
После запуска IDE открывает панель с деревом: класс
DeadlineScoreTest, под ним четыре теста с зелёными
галочками, у каждого — время в миллисекундах; рядом — сводка, сколько
прошло из скольких. Четвёртый тест подписан русским именем из
@DisplayName.
Зелёный отчёт в Maven#
./mvnw test в песочнице заканчивается так:
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESSТестов пять: четыре наших и contextLoads от Initializr.
После статьи 1 было два: один тест мы переписали, три добавили — число
выросло ровно на столько, сколько добавили.
Выше в журнале — по строке на каждый класс:
Running example.testy.score.DeadlineScoreTest и под ней
итог класса, Tests run: 4, …. Имён тестов в зелёном журнале
Maven не пишет — только классы.
Каждый тест хоть раз красный#
Четыре теста зелёные. Можно ли им верить? Пока нет.
🔑 Главная мысль серии: зелёному тесту верят, только если видели его красным. В статье 1 мы так проверили один тест; теперь — каждый из четырёх. Вот метод, который будем ломать:
public static int score(Instant deadline, Instant submittedAt) {
// … проверки на null — в полном примере
if (!submittedAt.isAfter(deadline)) { // ровно в срок — ещё вовремя
return FULL;
}
if (!submittedAt.isAfter(deadline.plus(GRACE))) { // ровно через сутки — ещё половина
return HALF;
}
return NONE;
}Ломаем по одной строке: поменял — запустил класс — увидел красное — вернул — снова зелёное.
| что сломать | что покраснеет | что скажет |
|---|---|---|
первый return FULL; → return HALF; |
beforeDeadlineGetsFull и
onDeadlineIsStillOnTime |
ждали 100, получили 50 |
return HALF; → return FULL; |
lateWithinDayGetsHalf |
ждали 50, получили 100 |
return NONE; → return HALF; |
lateByMoreThanDayGetsNothing, в IDE — «опоздал больше
чем на сутки — ноль» |
ждали 0, получили 50 |
Только одна поломка за раз. Две сразу — и уже не скажешь, какой тест на какую покраснел.
Что это дало. Каждый тест хоть раз покраснел, и именно на своей
поломке. Первая уронила сразу два — и это не повтор: оба стерегут полный
балл, но в разных точках, до срока и ровно в срок. Поломку из статьи 1 —
isBefore вместо !isAfter — из четырёх поймает
только onDeadlineIsStillOnTime: «раньше срока» и «не позже
срока» различаются только в самом сроке.
💡 Не всякую поломку видят эти четыре теста. Поменяй в
GRACE 24 часа на 23 — все четыре останутся зелёными: ни
один из них не сдаёт работу между двадцать третьим и двадцать четвёртым
часом опоздания. Какие случаи брать, чтобы таких дыр не оставалось, — в
продолжении серии.
Проверь себя#
Ответь своими словами — вслух или на бумаге. Не получается — перечитай раздел.
- Почему тестовый класс и методы можно писать без
public, а в примерах из интернета он есть? Что будет с тестом, гдеimport org.junit.Test? - Зачем срок вынесен в константу, и что дают ей слова
private,staticиfinal? - Один тест вызывает
DEADLINE.plus(Duration.ofHours(1)). Изменится ли срок в соседнем тесте? Почему? - Найди в
lateWithinDayGetsHalf«дано», «когда» и «тогда». Почему вonDeadlineIsStillOnTimeблоков два? - Коллега предлагает писать
isEqualTo(DeadlineScore.FULL): «без магических чисел». Что станет с таким тестом, если кто-то сделаетFULL = 10? - Почему четыре теста лучше одного с четырьмя проверками?
- Чем
lateWithinDayGetsHalfлучшеtest3? Когда удобнее имя видаметод_Should…_When…, и почему@DisplayNameне заменяет хорошее имя метода? - Все четыре теста зелёные. Что сделать, чтобы этому зелёному можно было верить, и почему ломать нужно по одной строке?
Что дальше#
Тест покраснел — что делать. Читаем отчёт и стек вызовов, ищем в них свою строку — и разбираем ошибку коллеги, которую четыре теста из этой статьи пропустили. Это следующая статья серии.
Первоисточники#
- JUnit
6 — Test Classes and Methods — что JUnit считает тестовым классом и
методом и почему
publicне нужен. - JUnit
6 — Display Names —
@DisplayNameи что в нём можно писать. - JUnit 6 — Migrating from JUnit 4 — чем отличается JUnit 4 из примеров в интернете.
- AssertJ — документация
— раздел Quick start:
import staticи первые проверки. - Maven
Surefire — Using JUnit Platform — как Maven запускает JUnit и как
показать
@DisplayNameв его отчётах.
Пример целиком — с импортами#
Учебный пример. Живёт в песочнице testy из статьи 1. Как
её собрать, сказано там, в «Примере целиком». DeadlineScore
— тот же, что в статье 1, без изменений. DeadlineScoreTest
целиком заменяет однотестовый из статьи 1. 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 static org.assertj.core.api.Assertions.assertThat;
class DeadlineScoreTest {
private static final Instant DEADLINE = Instant.parse("2026-10-12T21:00:00Z"); // полночь 13 октября по Москве
@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);
}
}Сделай руками:
./mvnw test—Tests run: 5, Failures: 0, Errors: 0, Skipped: 0иBUILD SUCCESS.- В IDE запусти один тест треугольником у метода, потом весь класс. Найди в дереве русское имя.
- Три поломки из таблицы части 4 — по одной, каждый раз возвращая строку на место.
- Сделай
FULL = 10. ПокраснеютbeforeDeadlineGetsFullиonDeadlineIsStillOnTime: ждали 100, получили 10. Верни. - Поменяй в
GRACE24 часа на 23 — всё зелёное. Верни и запомни эту дыру: к ней вернёмся в продолжении серии.
Теперь у каждого из четырёх тестов есть то, ради чего его писали: он умеет покраснеть, и краснеет ровно тогда, когда нарушено его обещание.