Перейти к содержанию
Educora
Продвинутый22 мин14 / 18

Тестирование с JUnit 5

Пиши автоматические модульные тесты на JUnit 5: проверки, параметризованные тесты и подмена зависимостей с помощью Mockito.

Проверь себя
В этом уроке ты узнаешь
  • Писать тестовый класс с @Test и assertEquals, assertThrows, assertAll
  • Запускать один тест для многих наборов данных через @ParameterizedTest
  • Подменять зависимость с помощью Mockito и проверять вызовы через verify

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

Что такое тест?

Модульный тест (unit test) проверяет один небольшой фрагмент кода — обычно один метод — отдельно от остальных. Идея проста: ты знаешь ожидаемый результат, вызываешь метод и сравниваешь одно с другим. Ниже — крошечный тестовый «фреймворк» без всяких библиотек. В третьей проверке ожидание нарочно неверное, чтобы ты увидел, как выглядит упавший тест.

Java
import java.util.List;

public class Main {
    static int passed = 0, failed = 0;

    static void check(String name, Object expected, Object actual) {
        if (expected.equals(actual)) {
            passed++;
            System.out.println("PASS " + name);
        } else {
            failed++;
            System.out.println("FAIL " + name + ": expected " + expected + " but was " + actual);
        }
    }

    public static void main(String[] args) {
        check("average of 85, 90, 95", 90.0, Grades.average(List.of(85, 90, 95)));
        check("95 is an A", "A", Grades.letter(95));
        check("79 is a B", "B", Grades.letter(79));
        System.out.println(passed + " passed, " + failed + " failed");
    }
}

class Grades {
    static double average(List<Integer> scores) {
        if (scores.isEmpty()) throw new IllegalArgumentException("No scores");
        return scores.stream().mapToInt(Integer::intValue).average().getAsDouble();
    }

    static String letter(int score) {
        if (score < 0 || score > 100) throw new IllegalArgumentException("Score out of range: " + score);
        return score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : score >= 50 ? "D" : "F";
    }
}
Ожидаемый результат
PASS average of 85, 90, 95
PASS 95 is an A
FAIL 79 is a B: expected B but was C
2 passed, 1 failed
Суть теста: сравнение ожидаемого и фактического результата

JUnit делает то же самое на профессиональном уровне: сам находит тестовые методы, запускает каждый отдельно, собирает ошибки и выдаёт аккуратный отчёт в IDE, Maven или Gradle. Достаточно зависимости org.junit.jupiter:junit-jupiter с областью test из прошлого урока. Основная часть JUnit 5 называется Jupiter, поэтому его пакеты начинаются с org.junit.jupiter.

Первые тесты на JUnit 5

Java
import static org.junit.jupiter.api.Assertions.*;

import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

class GradesTest {

    @Test
    void averageOfThreeScores() {
        List<Integer> scores = List.of(85, 90, 95);   // Arrange
        double result = Grades.average(scores);        // Act
        assertEquals(90.0, result, 0.001);             // Assert
    }

    @Test
    @DisplayName("An empty list is rejected")
    void emptyListThrows() {
        var ex = assertThrows(IllegalArgumentException.class, () -> Grades.average(List.of()));
        assertEquals("No scores", ex.getMessage());
    }

    @Test
    void lettersAtBoundaries() {
        assertAll(
            () -> assertEquals("A", Grades.letter(90)),
            () -> assertEquals("B", Grades.letter(80)),
            () -> assertEquals("F", Grades.letter(49)));
    }
}
src/test/java/GradesTest.java — класс Grades тот же, что выше

Над каждым тестовым методом стоит аннотация @Test; метод может не быть public и ничего не возвращает. Первый тест следует схеме AAA: Arrange (подготовь данные), Act (вызови метод), Assert (проверь результат). assertEquals(expected, actual, delta) сравнивает дробные числа с точностью 0.001, ведь в Java 0.1 + 0.2 — это не ровно 0.3, а 0.30000000000000004. assertThrows проверяет, что выброшено исключение, и возвращает его, чтобы можно было проверить и сообщение. assertAll выполняет все проверки и не останавливается на первой ошибке.

АннотацияЧто делает
@Testпомечает метод как тест
@DisplayName("...")задаёт понятное имя в отчёте
@BeforeEach / @AfterEachвыполняется перед / после каждого теста
@BeforeAll / @AfterAllвыполняется один раз на весь класс (static-метод)
@Disabledвременно отключает тест
@ParameterizedTestзапускает тест для каждой строки данных

Для каждого теста JUnit создаёт новый объект, поэтому тесты не влияют друг на друга и могут выполняться в любом порядке. Тесты запускают зелёной кнопкой ▶ в IDE или командой mvn test / ./gradlew test в терминале. Представь, что кто-то случайно заменил в методе letter условие score >= 80 на score > 80:

Terminal
mvn test
Ожидаемый результат
[INFO] Running GradesTest
[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
[ERROR] Failures:
[ERROR]   GradesTest.lettersAtBoundaries:25 Multiple Failures (1 failure)
	org.opentest4j.AssertionFailedError: expected: <B> but was: <C>
[INFO] BUILD FAILURE
Пример вывода (сокращён): тест сразу поймал ошибку — 80 баллов больше не «B»
JUnit 4 (старый)
import org.junit.Test;                          // JUnit 4
import static org.junit.Assert.assertEquals;

public class GradesTest {
    @Test(expected = IllegalArgumentException.class)
    public void emptyListThrows() {
        Grades.average(List.of());
    }
}
JUnit 5 (современный)
import org.junit.jupiter.api.Test;              // JUnit 5
import static org.junit.jupiter.api.Assertions.*;

class GradesTest {
    @Test
    void emptyListThrows() {
        var ex = assertThrows(IllegalArgumentException.class, () -> Grades.average(List.of()));
        assertEquals("No scores", ex.getMessage());
    }
}
В JUnit 5 классы и методы могут не быть public, а у исключения можно проверить и сообщение

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

Чтобы повторить одну и ту же проверку с разными данными, не нужно копировать тест. @ParameterizedTest запускает тест отдельно для каждого набора данных: @CsvSource передаёт каждую строку со значениями через запятую в параметры метода, а @ValueSource подходит для простого списка значений. В отчёте каждый случай виден отдельно, например 85 -> B. Эти аннотации находятся в модуле junit-jupiter-params, который уже входит в зависимость junit-jupiter.

Тесты копипастой
@Test void letterFor95() { assertEquals("A", Grades.letter(95)); }
@Test void letterFor85() { assertEquals("B", Grades.letter(85)); }
@Test void letterFor72() { assertEquals("C", Grades.letter(72)); }
@Test void letterFor49() { assertEquals("F", Grades.letter(49)); }
// one more case = one more copy-pasted method...
Параметризованные тесты: новый случай = новая строка
@ParameterizedTest(name = "{0} -> {1}")
@CsvSource({ "95, A", "85, B", "72, C", "49, F" })
void letterForScore(int score, String expected) {
    assertEquals(expected, Grades.letter(score));
}

@ParameterizedTest
@ValueSource(ints = { -1, 101, 150 })
void outOfRangeScoresAreRejected(int score) {
    assertThrows(IllegalArgumentException.class, () -> Grades.letter(score));
}

Mockito: подмена зависимостей

Настоящий класс часто зависит от других объектов: базы данных, почтового сервера, внешнего API. Обращаться к ним по-настоящему в модульном тесте медленно и ненадёжно. Решение — передавать зависимость через конструктор и в тесте заменять её моком (поддельным объектом). ReportService ниже получает баллы из интерфейса ScoreRepository и не знает, читает ли тот базу данных или это подделка.

Java
interface ScoreRepository {
    List<Integer> findScores(String student);   // e.g. reads a database
}

class ReportService {
    private final ScoreRepository repository;

    ReportService(ScoreRepository repository) {
        this.repository = repository;
    }

    String report(String student) {
        return student + ": " + Grades.average(repository.findScores(student));
    }
}
Зависимость приходит через конструктор — это упрощает тестирование класса
Java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;

import java.util.List;
import org.junit.jupiter.api.Test;

class ReportServiceTest {

    @Test
    void reportUsesScoresFromRepository() {
        ScoreRepository repo = mock(ScoreRepository.class);
        when(repo.findScores("Aysel")).thenReturn(List.of(90, 80));

        ReportService service = new ReportService(repo);
        String report = service.report("Aysel");

        assertEquals("Aysel: 85.0", report);
        verify(repo).findScores("Aysel");
        verifyNoMoreInteractions(repo);
    }
}
Для Mockito нужна зависимость org.mockito:mockito-junit-jupiter (область test)

mock(...) создаёт пустую поддельную копию интерфейса. when(...).thenReturn(...) учит её: «если спросят про Aysel, верни 90 и 80» — это называется заглушкой (stub). verify проверяет, что сервис вызвал репозиторий ровно один раз и с правильным аргументом, а verifyNoMoreInteractions подтверждает, что лишних вызовов не было. В больших тестовых классах то же самое делают аннотациями: над классом пишут @ExtendWith(MockitoExtension.class), над полем — @Mock.

Главное

  • Модульный тест проверяет небольшой фрагмент кода отдельно: сравнивает ожидаемый результат с фактическим.
  • JUnit 5: @Test, assertEquals(expected, actual), assertThrows, assertAll; тесты лежат в src/test/java.
  • Схема AAA: Arrange → Act → Assert; каждый тест работает на новом объекте и не зависит от других.
  • @ParameterizedTest + @CsvSource / @ValueSource запускает один тест со многими наборами данных.
  • Mockito подменяет зависимости: mock, when(...).thenReturn(...), verify; передача зависимости через конструктор упрощает тестирование.

Проверь себя

Вопросов: 10. Каждый правильный ответ приносит XP.

1 / 10
Почему тест assertEquals(0.3, 0.1 + 0.2) падает?