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

Первый модульный тест: от пустого файла до зелёного отчёта

Статья 2 · читать минут 25

Для кого. Ты прочитал статью 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.

Сам метод устроен просто:

⚠️ В интернете ты увидишь 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 октября по Москве

Срок нужен всем четырём тестам, и он у всех один. Написать его в каждом тесте заново — четыре одинаковые строки, и читателю придётся сверять, правда ли они одинаковые. Поэтому срок — константа: значение с именем, записанное один раз. Тогда в каждом тесте видно только то, чем он отличается от соседей, — время сдачи.

Каждое слово объявления — со смыслом.

И ещё одно свойство — самого Instant: он неизменяемый. DEADLINE.plus(…) не сдвигает срок, а возвращает новый момент времени. Поэтому одну константу можно спокойно делить между тестами — испортить её нельзя.


Часть 2. Тело теста: дано — когда — тогда

Три блока#

Вот первый тест целиком:

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

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

        assertThat(score).isEqualTo(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#

После запуска 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 — все четыре останутся зелёными: ни один из них не сдаёт работу между двадцать третьим и двадцать четвёртым часом опоздания. Какие случаи брать, чтобы таких дыр не оставалось, — в продолжении серии.


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

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

  1. Почему тестовый класс и методы можно писать без public, а в примерах из интернета он есть? Что будет с тестом, где import org.junit.Test?
  2. Зачем срок вынесен в константу, и что дают ей слова private, static и final?
  3. Один тест вызывает DEADLINE.plus(Duration.ofHours(1)). Изменится ли срок в соседнем тесте? Почему?
  4. Найди в lateWithinDayGetsHalf «дано», «когда» и «тогда». Почему в onDeadlineIsStillOnTime блоков два?
  5. Коллега предлагает писать isEqualTo(DeadlineScore.FULL): «без магических чисел». Что станет с таким тестом, если кто-то сделает FULL = 10?
  6. Почему четыре теста лучше одного с четырьмя проверками?
  7. Чем lateWithinDayGetsHalf лучше test3? Когда удобнее имя вида метод_Should…_When…, и почему @DisplayName не заменяет хорошее имя метода?
  8. Все четыре теста зелёные. Что сделать, чтобы этому зелёному можно было верить, и почему ломать нужно по одной строке?

Что дальше#

Тест покраснел — что делать. Читаем отчёт и стек вызовов, ищем в них свою строку — и разбираем ошибку коллеги, которую четыре теста из этой статьи пропустили. Это следующая статья серии.

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


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

Учебный пример. Живёт в песочнице 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);
    }
}

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

  1. ./mvnw test — Tests run: 5, Failures: 0, Errors: 0, Skipped: 0 и BUILD SUCCESS.
  2. В IDE запусти один тест треугольником у метода, потом весь класс. Найди в дереве русское имя.
  3. Три поломки из таблицы части 4 — по одной, каждый раз возвращая строку на место.
  4. Сделай FULL = 10. Покраснеют beforeDeadlineGetsFull и onDeadlineIsStillOnTime: ждали 100, получили 10. Верни.
  5. Поменяй в GRACE 24 часа на 23 — всё зелёное. Верни и запомни эту дыру: к ней вернёмся в продолжении серии.

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

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

Продолжение следует

Готовится в «От красного к зелёному»:

Новые тексты появятся в рубрике «Тесты».

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

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