JUnit — тестирование Java-приложений
JUnit — стандартный фреймворк для модульного тестирования в Java. В этом курсе мы разберём JUnit 5 (Jupiter): от первого теста до параметризованных проверок и интеграции с Mockito.
1. Что такое JUnit и зачем тестировать
Тестирование — это процесс проверки того, что код работает корректно. JUnit позволяет писать автоматические тесты, которые запускаются при каждом изменении и мгновенно сообщают об ошибках.
Преимущества модульного тестирования:
- Раннее обнаружение ошибок — баг ловится на этапе написания, а не в продакшене.
- Безопасный рефакторинг — тесты подтверждают, что логика не сломалась.
- Живая документация — тест показывает, как код используется.
- Экономия времени — ручное тестирование заменяется автоматическим запуском.
JUnit 4 vs JUnit 5 (Jupiter)
| Особенность | JUnit 4 | JUnit 5 (Jupiter) |
|---|---|---|
| Пакет | org.junit | org.junit.jupiter.api |
| Аннотация теста | @Test | @Test |
| Игнорирование теста | @Ignore | @Disabled |
| Диапазон значений | @RunWith + @Parameterized | @ParameterizedTest |
| Вложенные тесты | не поддерживается | @Nested |
| Расширения | @RunWith + Rule | @ExtendWith |
| Минимальная версия Java | Java 5+ | Java 8+ |
Жизненный цикл теста
- @BeforeAll — выполняется один раз перед всеми тестами класса.
- @BeforeEach — выполняется перед каждым тестовым методом.
- @Test — сам тест.
- @AfterEach — выполняется после каждого тестового метода.
- @AfterAll — выполняется один раз после всех тестов класса.
2. Подключение JUnit
Maven dependency
Добавь в секцию <dependencies> файла pom.xml:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>Для Maven 3.8.5+ можно использовать BOM и не указывать версию:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.10.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>Убедись, что плагин surefire настроен (обычно Maven добавляет его автоматически):
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>Структура папок
Следуй стандартной структуре Maven:
src/
main/
java/
com/example/calculator/
Calculator.java
test/
java/
com/example/calculator/
CalculatorTest.javasrc/test/java и по умолчанию содержать в имени слово Test (например, CalculatorTest.java).Запуск
mvn testВ IntelliJ IDEA нажми зелёный треугольник рядом с тестовым методом или классом. Выборочная проверка доступна через контекстное меню.
3. Первый тест
Создадим класс Calculator и его тест:
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public int subtract(int a, int b) {
return a - b;
}
public int multiply(int a, int b) {
return a * b;
}
public int divide(int a, int b) {
if (b == 0) {
throw new ArithmeticException("Division by zero");
}
return a / b;
}
}import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class CalculatorTest {
@Test
void add_returnsSum() {
Calculator calc = new Calculator();
assertEquals(5, calc.add(2, 3));
}
@Test
void subtract_returnsDifference() {
Calculator calc = new Calculator();
assertEquals(4, calc.subtract(7, 3));
}
@Test
void multiply_returnsProduct() {
Calculator calc = new Calculator();
assertEquals(12, calc.multiply(3, 4));
}
@Test
void divide_returnsQuotient() {
Calculator calc = new Calculator();
assertEquals(3, calc.divide(9, 3));
}
@Test
void divide_byZero_throwsException() {
Calculator calc = new Calculator();
assertThrows(ArithmeticException.class, () -> calc.divide(1, 0));
}
}Ключевые моменты:
- Аннотация
@Testпомечает метод как тест. - Имя метода описывает, что проверяется, например
add_returnsSum. - Статические методы
assertEquals,assertThrows— из пакетаAssertions. - Порядок тестов не гарантирован — каждый тест должен работать независимо.
4. Аннотации жизненного цикла
Аннотации позволяют выполнять код до и после тестов — например, для настройки базы данных или очистки ресурсов.
| Аннотация | Когда выполняется | Ограничения |
|---|---|---|
| @BeforeAll | Один раз перед всеми тестами | Метод должен быть static (или static в @TestInstance) |
| @BeforeEach | Перед каждым тестом | Нет ограничений |
| @AfterEach | После каждого теста | Нет ограничений |
| @AfterAll | Один раз после всех тестов | Метод должен быть static |
| @Disabled("причина") | Пропуск теста | Укажи причину в параметре |
Пример с таймером и счётчиком:
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
public class LifecycleTest {
private Calculator calc;
@BeforeAll
static void initSuite() {
System.out.println("=== Все тесты запущены ===");
}
@BeforeEach
void setUp() {
calc = new Calculator();
System.out.println("Новый экземпляр Calculator создан");
}
@AfterEach
void tearDown() {
System.out.println("Тест завершён");
}
@AfterAll
static void finishSuite() {
System.out.println("=== Все тесты пройдены ===");
}
@Test
void testAdd() {
assertEquals(10, calc.add(4, 6));
}
@Test
void testMultiply() {
assertEquals(20, calc.multiply(4, 5));
}
}@TestInstance(Lifecycle.PER_CLASS) позволяет использовать нестатические @BeforeAll и @AfterAll, создавая один экземпляр тестового класса на весь набор тестов.5. Проверки и ассерты
JUnit предоставляет семейство статических методов для проверки ожидаемых результатов. Все они находятся в классе Assertions.
Основные ассерты
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
public class AssertionTest {
@Test
void exampleAssertions() {
Calculator calc = new Calculator();
assertEquals(6, calc.add(2, 4), "Сумма 2+4 должна быть 6");
assertTrue(calc.add(1, 1) > 0, "Результат должен быть положительным");
assertNotNull(calc, "Объект не должен быть null");
assertArrayEquals(
new int[]{3, 6, 9},
new int[]{3, 6, 9}
);
assertThrows(ArithmeticException.class, () -> calc.divide(5, 0));
}
}Проверка исключений
@Test
void divide_byZero_throwsException() {
Calculator calc = new Calculator();
ArithmeticException ex = assertThrows(
ArithmeticException.class,
() -> calc.divide(10, 0)
);
assertEquals("Division by zero", ex.getMessage());
}Проверка времени выполнения
@Test
void heavyOperation_mustFinishInTime() {
assertTimeout(java.time.Duration.ofSeconds(2), () -> {
Thread.sleep(100);
new Calculator().add(1, 1);
});
}Параметр message во всех ассертах опционален, но помогает при диагностике ошибок.
6. Параметризованные тесты
Параметризованные тесты позволяют запускать один и тот же тест с набором данных, исключая дублирование кода.
@ValueSource
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
public class CalculatorParamTest {
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void addOne_alwaysIncreases(int value) {
Calculator calc = new Calculator();
assertEquals(value + 1, calc.add(value, 1));
}
}@MethodSource
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
public class CalculatorMethodTest {
static Stream<Arguments> addPairs() {
return Stream.of(
Arguments.of(1, 2, 3),
Arguments.of(0, 0, 0),
Arguments.of(-1, 1, 0),
Arguments.of(10, 20, 30)
);
}
@ParameterizedTest
@MethodSource("addPairs")
void add_variousPairs(int a, int b, int expected) {
Calculator calc = new Calculator();
assertEquals(expected, calc.add(a, b));
}
}@CsvSource
@ParameterizedTest
@CsvSource({
"10, 2, 5",
"20, 4, 5",
"100, 10, 10",
"7, 3, 2"
})
void divide_variousPairs(int a, int b, int expected) {
Calculator calc = new Calculator();
assertEquals(expected, calc.divide(a, b));
}@CsvSource для простых наборов данных, @MethodSource — когда нужны сложные объекты или вычисления. @ValueSource удобен для проверки одного аргумента.7. Вложенные тесты и именование
Аннотация @Nested группирует связанные тесты в логические блоки. @DisplayName задаёт человекочитаемое имя.
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("Калькулятор")
public class CalculatorNestedTest {
private final Calculator calc = new Calculator();
@Nested
@DisplayName("Сложение")
class Addition {
@Test
@DisplayName("складывает два положительных числа")
void add_positiveNumbers() {
assertEquals(5, calc.add(2, 3));
}
@Test
@DisplayName("работает с отрицательными числами")
void add_negativeNumbers() {
assertEquals(-5, calc.add(-2, -3));
}
}
@Nested
@DisplayName("Деление")
class Division {
@Test
@DisplayName("возвращает целую часть")
void divide_integerResult() {
assertEquals(3, calc.divide(9, 3));
}
@Test
@DisplayName("выбрасывает исключение при делении на ноль")
void divide_byZero() {
assertThrows(ArithmeticException.class, () -> calc.divide(1, 0));
}
}
}В отчёте тесты отображаются в виде дерева: Калькулятор > Сложение > складывает два положительных числа. Это значительно улучшает читаемость.
8. Mockito — моки для зависимостей
Моки (mocks) подменяют реальные объекты в тестах, изолируя тестируемый код от внешних зависимостей: базы данных, API, файловой системы.
Добавь зависимость в pom.xml:
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>Допустим, у нас есть сервис и репозиторий:
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public String getUserName(long id) {
String name = userRepository.findNameById(id);
if (name == null) {
throw new IllegalArgumentException("User not found");
}
return name.toUpperCase();
}
}public interface UserRepository {
String findNameById(long id);
}Тест с моком:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
public class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
void getUserName_returnsUpperCase() {
when(userRepository.findNameById(1L)).thenReturn("anna");
String result = userService.getUserName(1L);
assertEquals("ANNA", result);
verify(userRepository, times(1)).findNameById(1L);
}
@Test
void getUserName_throwsWhenNotFound() {
when(userRepository.findNameById(99L)).thenReturn(null);
assertThrows(IllegalArgumentException.class, () -> userService.getUserName(99L));
}
}Основные методы Mockito:
@Mock— создаёт подменённый объект.@InjectMocks— создаёт объект и внедряет моки через конструктор.when(...).thenReturn(...)— задаёт поведение мока.verify(mock, times(n))— проверяет, сколько раз вызван метод.
9. Покрытие кода
Покрытие кода показывает, какая доля строк, ветвлений и методов протестирована. Для Maven используется плагин JaCoCo.
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>После запуска mvn test открой отчёт:
target/site/jacoco/index.htmlКак читать отчёт JaCoCo
| Метрика | Описание |
|---|---|
| Instructions | Доля.bytecode-инструкций, покрытых тестами |
| Branches | Покрытие ветвлений (if/switch) |
| Lines | Покрытие строк кода |
| Methods | Покрытие методов |
| Classes | Покрытие классов |
10. Тестирование Spring-приложений
Spring Boot предоставляет встроенные инструменты для интеграционного тестирования.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
public class ApplicationTests {
@Autowired
private ApplicationContext context;
@Test
void contextLoads() {
assertNotNull(context);
}
}Для тестирования REST-контроллеров без запуска сервера:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest
@AutoConfigureMockMvc
public class CalculatorControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void getAdd_returnsJson() throws Exception {
mockMvc.perform(get("/api/add?a=2&b=3"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.result").value(5));
}
}Для подмены бинов Spring используй @MockBean вместо @Mock из Mockito.
11. Лучшие практики
AAA-паттерн
Каждый тест должен чётко следовать трём шагам:
@Test
void add_twoPositiveNumbers_returnsSum() {
// Arrange
Calculator calc = new Calculator();
int a = 3;
int b = 4;
int expected = 7;
// Act
int result = calc.add(a, b);
// Assert
assertEquals(expected, result);
}- Arrange — подготовка: создание объектов, настройка моков.
- Act — действие: вызов тестируемого метода.
- Assert — проверка: сравнение результата с ожидаемым.
Рекомендации
- Маленькие тесты — один тест проверяет одну вещь.
- Понятные имена — используй подчёркивание:
divide_byZero_throwsException. - Независимость — тесты не должны зависеть друг от друга и порядка выполнения.
- Быстрота — модульные тесты должны работать за миллисекунды.
- Детерминированность — один и тот же тест всегда даёт один результат.
- Тестируй поведение, а не реализацию — проверяй результат, а не внутренние вызовы.
12. Тестирование исключений
Проверка того, что метод правильно бросает исключение, — такой же сценарий, как и обычные вычисления. JUnit 5 предлагает три способа, и каждый уместен в своей ситуации.
@Test
void divideByZero_throwsException() {
ArithmeticException ex = assertThrows(
ArithmeticException.class,
() -> calculator.divide(10, 0)
);
assertEquals("/ by zero", ex.getMessage());
}Альтернатива — проверка с возвратом исключения для дополнительных проверок:
@Test
void wrongPassword_throwsAuthenticationException() {
AuthenticationException ex = assertThrows(
AuthenticationException.class,
() -> auth.login("user", "bad")
);
assertTrue(ex.getMessage().contains("пароль"));
}assertThrows — он читается однозначно и не требует пустых катчей.Exception.class — плохая практика: слишком широкое ожидание может скрыть неожиданные ошибки.13. Порядок и жизненный цикл тестов
По умолчанию JUnit создаёт новый экземпляр тестового класса для каждого метода. За это отвечает @TestInstance(Lifecycle.PER_METHOD) — поведение по умолчанию.
@TestInstance(Lifecycle.PER_CLASS)
class CalculatorServiceTest {
private final CalculatorService service;
CalculatorServiceTest() {
service = new CalculatorService();
}
@Test
void add_returnsSum() {
assertEquals(7, service.add(3, 4));
}
}При PER_CLASS экземпляр один: можно использовать поля без static, а конструктор «заранее» подготовить объект. Подходит для медленной инициализации.
| Режим | Экземпляров | Когда удобно |
|---|---|---|
| PER_METHOD | один на тест | изоляция по умолчанию |
| PER_CLASS | один на класс | тяжёлая инициализация |
PER_CLASS поля не сбрасываются между тестами. Если тесты зависят от порядка — это повод подумать о дизайне.14. Параметризованные тесты
Проверить метод на разных входных данных можно в одном тесте. JUnit 5 для этого имеет @ParameterizedTest и источники аргументов.
@ParameterizedTest
@CsvSource({
"2, 3, 5",
"0, 0, 0",
"-1, 1, 0"
})
void add_returnsSum(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}@ParameterizedTest
@MethodSource("validEmails")
void validEmails_areAccepted(String email) {
assertTrue(validator.isEmail(email));
}
static Stream<String> validEmails() {
return Stream.of("a@b.ru", "user@example.com", "x@y.io");
}Другие источники: @ValueSource — простые значения, @EnumSource — перечисления, @NullSource и @EmptySource — пограничные случаи.
@NullSource и @EmptySource с @ValueSource: тривиальные ошибки нулевых ссылок ловятся на раз.15. Интеграционные тесты и Testcontainers
Модульные тесты изолируют логику, но для проверки связки «приложение + БД + внешний сервис» нужны интеграционные. Тестованный контейнер поднимает Docker-образ и тестирует в реальных условиях.
@SpringBootTest
@Testcontainers
class UserRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("test");
@Autowired
UserRepository userRepository;
@Test
void saves_and_finds_user() {
userRepository.save(new User("Иван"));
assertEquals(1, userRepository.findAll().size());
}
}Контейнер поднимается один раз на класс, а URL из его порта подставляется в datasource — через spring.test.replace или динамические свойства.
- реальные СУБД, Redis, Kafka — без моков;
- параллельный запуск — каждая интеграция получает свой контейнер;
- нужен только Docker на машине или в CI.
16. AssertJ: выразительные ассерты
Цепочки AssertJ делают проверки читаемее, чем набор atomic assert: код сам описывает ожидаемое состояние.
import static org.assertj.core.api.Assertions.*;
assertThat(user.getEmail()).isEqualTo("i@example.com")
.contains("@").endsWith(".com");
assertThat(result.getTotal()).isGreaterThan(0)
.isLessThanOrEqualTo(100);- ассерты для коллекций:
containsExactly,extractingполя; - современные проверки:
isPresentдля Optional,hasSize; - сообщения об ошибках красивее, чем у JUnit — проще отлаживать.
assertThat(list).extracting(User::getName)
.containsExactly("Ирина", "Олег");assertThrows для исключений — для них используйте assertThatThrownBy(...) с проверкой причин (разделы 5, 12).17. Тестирование веб-слоя: MockMvc и RestAssured
Spring-слой контроллеров тестируют без запуска всего приложения через MockMvc (mock сервлет) или RestAssured (реальные HTTP-запросы к запущенному сервису).
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired MockMvc mvc;
@Test
void returnsUser() throws Exception {
mvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").isString());
}
}- MockMvc — быстрый, изолированный, проверяет до и после обработки;
- RestAssured (или WebClient) проверяет реальный стек: маршрутизацию, сериализацию, ошибки;
- jsonPath и xpath — удобные матчеры к ответу.
@MockBean) — иначе тест случайно потянет базу.18. Параллельный запуск тестов и @Execution
Современные машины легко гоняют тесты параллельно. JUnit 5 поддерживает параллельность по конфигурации, JUnit 4 — только плагинами.
// junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
// на уровне класса
@Execution(ExecutionMode.CONCURRENT)
class ParallelTests { ... }- по умолчанию режимы: same_thread (класс внутри) и concurrent (все классы);
- настройте число потоков:
parallel.config.fixed.parallelism=4; - шаринг состояний между тестами — источник гонок. Разделяйте через статики только безопасно.
19. Мутационное тестирование с PIT
Обычное покрытие показывает, какие строки выполнения тестов, но не что тесты заметят ошибку. PIT (pitest) меняет код (мутации) и проверяет, упадут ли тесты — так видна «серая» зона.
// pom.xml — плагин pitest-maven
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.15.0</version>
</plugin>
mvn org.pitest:pitest-maven:mutationCoverage| Метрика | Смысл |
|---|---|
| Mutation Score | доля «убейте мутанта» тестами |
| Survived | мутации, не замеченные тестами |
| Killed | мутации, обнаруженные |
20. Awaitility: тесты времени и асинхронности
Проверка асинхронного кода через «положди 3 секунды» — источник флаки-тестов: на слабой машине трёх мало, на быстрой — много. Awaitility ждёт условие с таймаутом и опросом.
await().atMost(5, SECONDS)
.pollInterval(100, MILLISECONDS)
.until(() -> cache.contains("token"));
// если условие не наступает за отведённое
// время — тест падает с понятным описанием- until(Supplier) ждёт true без произвольных Thread.sleep;
- atMost заменяет «магические» паузы — тест быстрее и стабильнее;
- pollInterval задаёт частоту опроса условия (по умолчанию ~100 мс).
21. Тестирование файлов, IO и кодировок
Парсеры CSV/JSON, чтение конфигов, работа с большими файлами — классические кандидаты на тесты. Здесь важны тестовые данные и фикстуры не меньше самого кода.
@TempDir Path tempDir; // JUnit 5: автоудаление
@Test
void parsesUtf8() throws Exception {
Path f = tempDir.resolve("in.csv");
Files.writeString(f, "имя,сумма\nИван,100", UTF_8);
List rows = parser.parse(f);
assertEquals("Иван", rows.get(0).name());
}
- @TempDir даёт изолированную папку — чистить вручную не нужно;
- фикстуры — в test/resources, чтение через getResourceAsStream;
- явно задавайте кодировку (UTF_8), чтобы поймать «кривые» данные на раннем этапе.
22. BDD и спецификации: Cucumber
BDD переносит тесты на язык бизнеса: Given/When/Then описывает поведение, а шаги превращаются в код. Требования и реализация живут в одном артефакте.
Feature: Скидка для суммы
Scenario: Больше 1000 — скидка 10%
Given корзина с суммой 1200
When считаю скидку
Then результат равен 120
@Given("корзина с суммой {int}")
public void cart(int sum) { cart = sum; }- .feature-файл читается заказчиком — это «живой» документ;
- cucumber-junit отображает сценарии в JUnit-тесты;
- шаги переиспользуются между сценариями — меньше кода.
23. Тестирование унаследованного кода (legacy)
Покрыть чужой «тёмный» код тестами и не сломаться — отдельный навык. Правило: сначала санитарная сеть на текущее поведение, затем рефакторинг — по одному шагу за раз.
// 1. тест-«слепок» фактического поведения
@Test
void preservedBehaviour() {
Legacy legacy = new Legacy();
assertEquals("server-1:8080",
legacy.buildHost("1", "8080"));
}
// 2. затем правьте логику, сверяясь с тестом.- закрепляйте текущие выходы как ожидаемые — до изменений;
- инъекция в legacy — через package-private сеттеры или конструктор;
- нерешённые случаи помечайте @Disabled с комментарием-долгом.
Что дальше
Теперь ты умеешь писать модульные тесты в JUnit 5. Рекомендуемые следующие шаги: изучить Gradle для управления сборкой, углубиться в Spring Boot для тестирования веб-приложений и освоить интеграционные тесты с Testcontainers.