Зачем писать тесты
Автоматические тесты проверяют, что код работает, и сразу сообщают, если после изменений что-то сломалось. Они же служат живой документацией: по тестам видно, как должен вести себя класс.
Основной фреймворк для Java — JUnit 5 (JUnit Jupiter).
Подключение
Maven (pom.xml):
<dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.11.0</version> <scope>test</scope></dependency>Gradle (build.gradle.kts):
dependencies { testImplementation("org.junit.jupiter:junit-jupiter:5.11.0") testRuntimeOnly("org.junit.platform:junit-platform-launcher")}tasks.test { useJUnitPlatform()}Тесты лежат в src/test/java в тех же пакетах, что и тестируемые классы.
Первый тест
Тестируемый класс src/main/java/com/example/Calculator.java:
package com.example;public class Calculator { public int add(int a, int b) { return a + b; } public int divide(int a, int b) { if (b == 0) { throw new IllegalArgumentException("Деление на ноль"); } return a / b; } public boolean isPrime(int n) { if (n < 2) { return false; } for (int i = 2; (long) i * i <= n; i++) { if (n % i == 0) { return false; } } return true; }}Тест src/test/java/com/example/CalculatorTest.java:
package com.example;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertFalse;import static org.junit.jupiter.api.Assertions.assertThrows;import static org.junit.jupiter.api.Assertions.assertTrue;import org.junit.jupiter.api.Test;class CalculatorTest { private final Calculator calculator = new Calculator(); @Test void addsTwoNumbers() { assertEquals(5, calculator.add(2, 3)); } @Test void addsNegativeNumbers() { assertEquals(-5, calculator.add(-2, -3)); } @Test void dividesNumbers() { assertEquals(4, calculator.divide(20, 5)); } @Test void divisionByZeroThrows() { IllegalArgumentException e = assertThrows( IllegalArgumentException.class, () -> calculator.divide(1, 0) ); assertEquals("Деление на ноль", e.getMessage()); } @Test void detectsPrimes() { assertTrue(calculator.isPrime(13)); assertFalse(calculator.isPrime(15)); assertFalse(calculator.isPrime(1)); }}Тестовые классы и методы могут быть без public. Имена методов должны описывать проверяемое поведение.
Запуск
mvn test./gradlew testВ IDE: зеленый треугольник рядом с классом или методом. Отчеты Maven находятся в target/surefire-reports, Gradle — в build/reports/tests/test/index.html.
Основные проверки
import static org.junit.jupiter.api.Assertions.*;import java.util.List;import org.junit.jupiter.api.Test;class AssertionsTest { @Test void variousAssertions() { assertEquals(10, 5 + 5); assertEquals(0.3, 0.1 + 0.2, 0.0001); assertNotEquals(1, 2); assertTrue(10 > 5); assertFalse("abc".isEmpty()); assertNull(null); assertNotNull("значение"); String a = "text"; String b = a; assertSame(a, b); assertArrayEquals(new int[]{1, 2, 3}, new int[]{1, 2, 3}); assertIterableEquals(List.of(1, 2), List.of(1, 2)); assertEquals(4, 2 + 2, "Сообщение при провале теста"); } @Test void groupedAssertions() { assertAll( "проверка строки", () -> assertEquals(5, "hello".length()), () -> assertTrue("hello".startsWith("he")), () -> assertEquals("HELLO", "hello".toUpperCase()) ); }}Порядок аргументов assertEquals(ожидаемое, фактическое). Для double обязательно задавайте допустимую погрешность.
assertAll выполняет все проверки и показывает все провалившиеся, а не только первую.
Жизненный цикл
import org.junit.jupiter.api.AfterAll;import org.junit.jupiter.api.AfterEach;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.Test;class LifecycleTest { @BeforeAll static void initAll() { System.out.println("один раз перед всеми тестами"); } @BeforeEach void init() { System.out.println("перед каждым тестом"); } @Test void first() { System.out.println("тест 1"); } @Test void second() { System.out.println("тест 2"); } @AfterEach void tearDown() { System.out.println("после каждого теста"); } @AfterAll static void tearDownAll() { System.out.println("один раз после всех тестов"); }}Для каждого тестового метода JUnit создает новый экземпляр класса, поэтому тесты не должны зависеть друг от друга и от порядка выполнения.
Другие аннотации
| Аннотация | Назначение |
|---|---|
@DisplayName("...") | читаемое название теста |
@Disabled("причина") | временно отключить тест |
@Nested | вложенный класс для группировки тестов |
@Tag("slow") | метка для фильтрации |
@Timeout(2) | ограничение времени в секундах |
@RepeatedTest(5) | повторить тест несколько раз |
import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertTrue;import java.util.ArrayList;import java.util.List;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Nested;import org.junit.jupiter.api.Test;class ListTest { private List<String> list; @BeforeEach void setUp() { list = new ArrayList<>(); } @Nested @DisplayName("Когда список пуст") class WhenEmpty { @Test @DisplayName("размер равен нулю") void sizeIsZero() { assertEquals(0, list.size()); } @Test @DisplayName("isEmpty возвращает true") void isEmpty() { assertTrue(list.isEmpty()); } } @Nested @DisplayName("Когда в списке есть элементы") class WhenNotEmpty { @BeforeEach void addItems() { list.add("a"); list.add("b"); } @Test @DisplayName("размер равен двум") void sizeIsTwo() { assertEquals(2, list.size()); } }}Параметризованные тесты
Один тест для набора входных данных. Для них нужна зависимость junit-jupiter (она уже включает junit-jupiter-params).
import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertFalse;import static org.junit.jupiter.api.Assertions.assertTrue;import com.example.Calculator;import org.junit.jupiter.params.ParameterizedTest;import org.junit.jupiter.params.provider.CsvSource;import org.junit.jupiter.params.provider.MethodSource;import org.junit.jupiter.params.provider.ValueSource;import java.util.stream.Stream;import org.junit.jupiter.params.provider.Arguments;class ParameterizedCalculatorTest { private final Calculator calculator = new Calculator(); @ParameterizedTest @ValueSource(ints = {2, 3, 5, 7, 11, 13}) void primesAreDetected(int n) { assertTrue(calculator.isPrime(n)); } @ParameterizedTest @ValueSource(ints = {-5, 0, 1, 4, 9, 15}) void nonPrimesAreDetected(int n) { assertFalse(calculator.isPrime(n)); } @ParameterizedTest @CsvSource({ "1, 2, 3", "10, -5, 5", "0, 0, 0" }) void addsNumbers(int a, int b, int expected) { assertEquals(expected, calculator.add(a, b)); } @ParameterizedTest @MethodSource("divisionCases") void dividesNumbers(int a, int b, int expected) { assertEquals(expected, calculator.divide(a, b)); } static Stream<Arguments> divisionCases() { return Stream.of( Arguments.of(10, 2, 5), Arguments.of(9, 3, 3), Arguments.of(7, 2, 3) ); }}Другие источники данных: @EnumSource, @CsvFileSource, @NullSource, @EmptySource.
Тестирование исключений
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;import static org.junit.jupiter.api.Assertions.assertThrows;import org.junit.jupiter.api.Test;class ExceptionTest { @Test void parseThrowsOnInvalidInput() { assertThrows(NumberFormatException.class, () -> Integer.parseInt("abc")); } @Test void parseDoesNotThrowOnValidInput() { assertDoesNotThrow(() -> Integer.parseInt("42")); }}Пример: тест своего класса
Класс:
package com.example;import java.util.ArrayList;import java.util.List;public class ShoppingCart { public record Item(String name, double price, int quantity) { } private final List<Item> items = new ArrayList<>(); public void add(String name, double price, int quantity) { if (price < 0) { throw new IllegalArgumentException("Цена не может быть отрицательной"); } if (quantity <= 0) { throw new IllegalArgumentException("Количество должно быть положительным"); } items.add(new Item(name, price, quantity)); } public double total() { return items.stream().mapToDouble(i -> i.price() * i.quantity()).sum(); } public int count() { return items.stream().mapToInt(Item::quantity).sum(); } public boolean isEmpty() { return items.isEmpty(); }}Тест:
package com.example;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;import static org.junit.jupiter.api.Assertions.assertTrue;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.Test;class ShoppingCartTest { private ShoppingCart cart; @BeforeEach void setUp() { cart = new ShoppingCart(); } @Test void newCartIsEmpty() { assertTrue(cart.isEmpty()); assertEquals(0, cart.total()); } @Test void totalSumsAllItems() { cart.add("Хлеб", 2.5, 2); cart.add("Молоко", 1.2, 3); assertEquals(8.6, cart.total(), 0.0001); assertEquals(5, cart.count()); } @Test void rejectsNegativePrice() { assertThrows(IllegalArgumentException.class, () -> cart.add("Товар", -1, 1)); } @Test void rejectsZeroQuantity() { assertThrows(IllegalArgumentException.class, () -> cart.add("Товар", 1, 0)); }}Принципы хороших тестов
- Структура Arrange-Act-Assert: подготовка данных, вызов, проверка.
- Независимость: тест не зависит от других тестов и внешнего состояния.
- Одна идея на тест: при падении сразу понятно, что сломалось.
- Понятные имена:
divisionByZeroThrows, а неtest1. - Быстрота: юнит-тесты должны выполняться за миллисекунды.
- Граничные случаи: ноль, пустые данные,
null, максимальные значения. - Тестируйте поведение, а не внутреннюю реализацию.
Моки
Когда класс зависит от внешнего сервиса, базы или сети, зависимость подменяют заглушкой. Самая популярная библиотека — Mockito:
<dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>5.14.0</version> <scope>test</scope></dependency>package com.example;public interface PriceService { double getPrice(String product);}package com.example;public class OrderCalculator { private final PriceService priceService; public OrderCalculator(PriceService priceService) { this.priceService = priceService; } public double calculate(String product, int quantity) { return priceService.getPrice(product) * quantity; }}package com.example;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.mockito.Mockito.mock;import static org.mockito.Mockito.verify;import static org.mockito.Mockito.when;import org.junit.jupiter.api.Test;class OrderCalculatorTest { @Test void calculatesTotalUsingPriceService() { PriceService service = mock(PriceService.class); when(service.getPrice("кофе")).thenReturn(3.5); OrderCalculator calculator = new OrderCalculator(service); assertEquals(10.5, calculator.calculate("кофе", 3), 0.0001); verify(service).getPrice("кофе"); }}Если сможете обойтись без мока, например, простой реализацией интерфейса прямо в тесте, так лучше: код проще.
Подход TDD
Test-Driven Development: сначала пишется падающий тест, затем минимальный код, чтобы он прошел, затем рефакторинг. Цикл «красный → зеленый → рефакторинг» помогает проектировать код так, чтобы его было удобно проверять.
Покрытие кода
Показывает, какие строки выполнялись тестами. В IntelliJ IDEA: Run with Coverage. Для сборки используют плагин JaCoCo. Высокий процент покрытия не гарантирует качества, но низкий сигнализирует о непроверенном коде.
