Тестирование в Java: от модульных до сквозных тестов
Качество кода — один из главных показателей профессионализма разработчика. В мире Java тестирование давно стало не просто хорошей практикой, а стандартом индустрии. Без надежных тестов сложно поддерживать большие проекты, внедрять новые фичи и уверенно рефакторить код. В этой статье мы разберем три ключевых уровня тестирования: unit (модульное), integration (интеграционное) и e2e (сквозное). Вы узнаете, чем они отличаются, как их правильно писать на Java и какие инструменты использовать.
Пирамида тестирования и почему это важно
Прежде чем углубляться в детали, стоит вспомнить концепцию пирамиды тестирования (Mike Cohn). Она визуализирует идеальное соотношение тестов разных уровней:
- Unit-тесты (основание пирамиды) — самые быстрые, дешевые и многочисленные. Их должно быть около 70%.
- Integration-тесты (середина) — проверяют взаимодействие компонентов. Их около 20%.
- E2E-тесты (вершина) — самые медленные и дорогие, но критически важные для проверки пользовательских сценариев. Их около 10%.
Соблюдение этой пропорции позволяет получить максимальную уверенность в коде при минимальных затратах времени на прогон тестов.
1. Unit-тестирование в Java
Unit-тесты проверяют изолированно один «юнит» кода — как правило, метод или класс. Их главная цель — убедиться, что логика метода работает корректно, независимо от внешних зависимостей (базы данных, API, файловой системы). Для изоляции используются mock-объекты.
Основные инструменты
- JUnit 5 — стандартный фреймворк для написания тестов.
- Mockito — библиотека для создания mock-объектов и проверки взаимодействий.
- AssertJ — библиотека для fluent-ассертов (повышает читаемость).
Пример unit-теста с JUnit 5 и Mockito
Допустим, у нас есть сервис UserService, который зависит от UserRepository:
public class UserService {
private final UserRepository repository;
public UserService(UserRepository repository) {
this.repository = repository;
}
public User findById(Long id) {
return repository.findById(id)
.orElseThrow(() -> new UserNotFoundException("User not found"));
}
}
Тест для этого метода:
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 java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository repository;
@InjectMocks
private UserService userService;
@Test
void testFindById_UserExists() {
// Given
Long userId = 1L;
User expectedUser = new User(userId, "Alice");
when(repository.findById(userId)).thenReturn(Optional.of(expectedUser));
// When
User actualUser = userService.findById(userId);
// Then
assertNotNull(actualUser);
assertEquals("Alice", actualUser.getName());
verify(repository).findById(userId);
}
@Test
void testFindById_UserNotFound() {
// Given
Long userId = 99L;
when(repository.findById(userId)).thenReturn(Optional.empty());
// When & Then
assertThrows(UserNotFoundException.class, () -> userService.findById(userId));
}
}
Best practices:
- Тестируйте один сценарий за раз (один assert на тест — не догма, но к этому стоит стремиться).
- Используйте понятные имена тестов (
testFindById_UserExists). - Не тестируйте private-методы напрямую — тестируйте их через публичные методы.
2. Интеграционное тестирование в Java
Интеграционные тесты проверяют, как разные части системы работают вместе. В отличие от unit-тестов, здесь мы поднимаем реальные или близкие к реальным зависимости: базу данных, очереди сообщений, внешние сервисы (через тестовые контейнеры).
Инструменты для интеграционных тестов
- Spring Boot Test (
@SpringBootTest) — для тестирования Spring-контекста. - Testcontainers — позволяет запускать Docker-контейнеры (PostgreSQL, Redis и др.) прямо из тестов.
- @DataJpaTest — для тестирования JPA-репозиториев.
Пример интеграционного теста с Spring Boot и Testcontainers
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@Testcontainers
class UserRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserRepository userRepository;
@Test
void testSaveAndFindUser() {
// Given
User user = new User(null, "Bob");
// When
User savedUser = userRepository.save(user);
User foundUser = userRepository.findById(savedUser.getId()).orElse(null);
// Then
assertThat(foundUser).isNotNull();
assertThat(foundUser.getName()).isEqualTo("Bob");
}
}
Важно: Интеграционные тесты медленнее unit-тестов, поэтому их стоит запускать отдельно (например, с Maven профилем integration-test).
3. E2E (End-to-End) тестирование
E2E-тесты (сквозные) имитируют