Integration tests с Testcontainers
Цель урока
Сделать полноценный CRUD для задач с правильными HTTP-статусами (201/204/404), обработкой ошибок, и подготовить Task к подключению JPA.
Теория · для собеса
1 Проблема ▸
Unit/Slice-тесты не проверяют:
- Реальные SQL-запросы (специфика PostgreSQL: JSONB, ON CONFLICT, RETURNING)
- Миграции Flyway
- Связи между слоями (Spring-конфигурация)
2 Решение — Testcontainers ▸
Testcontainers — библиотека, которая поднимает Docker-контейнеры на время теста:
- PostgreSQL, MySQL, Redis, Kafka, Elasticsearch, ...
- Каждый тест (или класс) — чистый контейнер
- Автоматически стартует/гасит
3
@SpringBootTest + @Testcontainers
▸
@SpringBootTest— поднимает полный Spring-контекст@Testcontainers— включает JUnit 5 extension@Container static PostgreSQLContainer— контейнер переиспользуется между тестами
---
Практика: улучшаем CRUD
-
1Зависимости
dependencies { testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.testcontainers:junit-jupiter:1.20.3") testImplementation("org.testcontainers:postgresql:1.20.3") testImplementation("org.springframework.boot:spring-boot-testcontainers") }⚠️ Требуется Docker на машине, где запускаются тесты.
-
2
test/.../AbstractIntegrationTest.java(базовый класс)package com.taskflow; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; 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; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") @Testcontainers public abstract class AbstractIntegrationTest { @Container static PostgreSQLContainer> postgres = new PostgreSQLContainer<>("postgres:16-alpine") .withDatabaseName("taskflow_test") .withUsername("test") .withPassword("test"); @DynamicPropertySource static void postgresProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); } }@DynamicPropertySource— подставляет URL контейнера вapplication-test.yml(override). -
3
application-test.ymlspring: jpa: hibernate: ddl-auto: validate # ← миграции из Flyway show-sql: false flyway: enabled: true clean-disabled: falseFlyway применит миграции при старте контекста → реальная схема в контейнере.
-
4
test/.../repository/TaskRepositoryIT.java(Integration Test)package com.taskflow.repository; import com.taskflow.AbstractIntegrationTest; import com.taskflow.model.Task; import com.taskflow.model.User; import com.taskflow.model.User.Role; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; class TaskRepositoryIT extends AbstractIntegrationTest { @Autowired private TaskRepository taskRepository; @Autowired private UserRepository userRepository; private User alice; @BeforeEach void setUp() { alice = new User("alice", "alice@example.com", "hash", Role.USER); alice = userRepository.save(alice); } @Test void shouldSaveAndFindTask() { Task task = new Task("Test", "Desc"); task.setOwner(alice); Task saved = taskRepository.save(task); Optionalfound = taskRepository.findById(saved.getId()); assertThat(found).isPresent(); assertThat(found.get().getTitle()).isEqualTo("Test"); assertThat(found.get().getOwner().getUsername()).isEqualTo("alice"); } @Test void shouldFindByOwnerId() { Task t1 = new Task("T1", "D"); t1.setOwner(alice); taskRepository.save(t1); Task t2 = new Task("T2", "D"); t2.setOwner(alice); taskRepository.save(t2); List aliceTasks = taskRepository.findByOwnerId(alice.getId()); assertThat(aliceTasks).hasSize(2); } @Test void shouldEnforceUniqueUsername() { User bob = new User("bob", "bob@e.com", "h", Role.USER); userRepository.save(bob); User duplicate = new User("bob", "bob2@e.com", "h", Role.USER); org.junit.jupiter.api.Assertions.assertThrows( org.springframework.dao.DataIntegrityViolationException.class, () -> userRepository.saveAndFlush(duplicate) ); } } -
5
test/.../controller/TaskControllerIT.java(full E2E)package com.taskflow.controller; import com.fasterxml.jackson.databind.ObjectMapper; import com.taskflow.AbstractIntegrationTest; import com.taskflow.dto.CreateTaskRequest; import com.taskflow.dto.LoginRequest; import com.taskflow.model.User; import com.taskflow.model.User.Role; import com.taskflow.repository.UserRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; import org.springframework.beans.factory.annotation.Autowired; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; class TaskControllerIT extends AbstractIntegrationTest { @Autowired private WebApplicationContext context; @Autowired private ObjectMapper objectMapper; @Autowired private UserRepository userRepository; @Autowired private PasswordEncoder passwordEncoder; private MockMvc mockMvc; private String aliceToken; @BeforeEach void setUp() { mockMvc = MockMvcBuilders.webAppContextSetup(context) .apply(springSecurity()) .build(); userRepository.deleteAll(); User alice = new User("alice", "alice@example.com", passwordEncoder.encode("password123"), Role.USER); userRepository.save(alice); aliceToken = loginAndGetToken("alice", "password123"); } private String loginAndGetToken(String username, String password) throws Exception { LoginRequest req = new LoginRequest(username, password); MvcResult result = mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isOk()) .andReturn(); return objectMapper.readTree(result.getResponse().getContentAsString()) .get("accessToken").asText(); } @Test void shouldCreateAndGetTask() throws Exception { CreateTaskRequest req = new CreateTaskRequest("IT task", "E2E test"); // Create MvcResult createResult = mockMvc.perform(post("/api/tasks") .header("Authorization", "Bearer " + aliceToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(req))) .andExpect(status().isCreated()) .andReturn(); Long taskId = objectMapper.readTree(createResult.getResponse().getContentAsString()) .get("id").asLong(); // Get mockMvc.perform(get("/api/tasks/{id}", taskId) .header("Authorization", "Bearer " + aliceToken)) .andExpect(status().isOk()) .andExpect(jsonPath("$.title").value("IT task")) .andExpect(jsonPath("$.ownerUsername").value("alice")); } @Test void shouldReturn401WithoutToken() throws Exception { mockMvc.perform(get("/api/tasks")) .andExpect(status().isUnauthorized()); } } -
6Конфигурация Gradle
tasks.named("test") { useJUnitPlatform() // Integration тесты обычно дольше — выделяем больше памяти maxHeapSize = "1g" } -
7Запуск
# Все тесты ./gradlew test # Только integration (по паттерну имени) ./gradlew test --tests "*IT" # С coverage ./gradlew test jacocoTestReport⚠️ Integration тесты требуют Docker. Если Docker нет —
*ITтесты упадут с ошибкой инициализации контейнера. Разделяйте unit/slice (*Test) и integration (*IT) для CI. -
8Стратегия для CI
# На PR — только быстрые ./gradlew test --tests "*Test" # На main — все (после merge) ./gradlew test---
Зачем это на собесе
После урока ты должен уметь ответить на:
5 вопросов на углубление
Раскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.
Разбор внутри: один контейнер на класс и скорость интеграционных тестов.
Разбор внутри: несовместимый SQL и баги, которые всплывают только на Postgres.
Разбор внутри: rollback, cleanup scripts и test pollution.
Разбор внутри: подстановка URL контейнера до старта Spring context.
Разбор внутри: локальные ошибки Testcontainers и поведение CI.
Готов идти дальше?
Выбери вопросы, которые тебе интересны, и изучи их. Потом — к следующему уроку.
🚀 Перейти к Уроку 20Сначала пройди все секции и выбери хотя бы 1 вопрос
⬅️ Назад к Уроку 18