УРОК 19 / 30 0%

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. 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. 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. 3
    application-test.yml
    
    spring:
      jpa:
        hibernate:
          ddl-auto: validate   # ← миграции из Flyway
        show-sql: false
      flyway:
        enabled: true
        clean-disabled: false
    

    Flyway применит миграции при старте контекста → реальная схема в контейнере.

  4. 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);
    
            Optional found = 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. 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. 6
    Конфигурация Gradle
    
    tasks.named("test") {
        useJUnitPlatform()
        // Integration тесты обычно дольше — выделяем больше памяти
        maxHeapSize = "1g"
    }
    
  7. 7
    Запуск
    
    # Все тесты
    ./gradlew test
    
    # Только integration (по паттерну имени)
    ./gradlew test --tests "*IT"
    
    # С coverage
    ./gradlew test jacocoTestReport
    

    ⚠️ Integration тесты требуют Docker. Если Docker нет — *IT тесты упадут с ошибкой инициализации контейнера. Разделяйте unit/slice (*Test) и integration (*IT) для CI.

  8. 8
    Стратегия для CI
    
    # На PR — только быстрые
    ./gradlew test --tests "*Test"
    
    # На main — все (после merge)
    ./gradlew test
    

    ---

🎯

Зачем это на собесе

После урока ты должен уметь ответить на:

Production-like — реальный PostgreSQL, не H2. H2 ведёт себя иначе (синтаксис SQL, JSONB не поддерживается полностью).
Реальные миграции — Flyway запускается, проверяем что V1, V2, V3 применяются.
End-to-end — JSON → Controller → Service → JPA → DB → JSON. Никаких сюрпризов на проде.
Каждый тест — чистая БД — нет «тест1 прошёл, потому что тест2 уже создал данные».

5 вопросов на углубление

Раскрой вопрос и нажми «🤔 Хочу разобрать подробнее» — он попадёт в страницу ответов.

1
Static container в Testcontainers — зачем он нужен?

Разбор внутри: один контейнер на класс и скорость интеграционных тестов.

2
H2 vs Testcontainers PostgreSQL — почему H2 обманывает?

Разбор внутри: несовместимый SQL и баги, которые всплывают только на Postgres.

3
Как изолировать данные между интеграционными тестами?

Разбор внутри: rollback, cleanup scripts и test pollution.

4
Что делает @DynamicPropertySource?

Разбор внутри: подстановка URL контейнера до старта Spring context.

5
Что будет, если Docker не запущен?

Разбор внутри: локальные ошибки Testcontainers и поведение CI.

Готов идти дальше?

Выбери вопросы, которые тебе интересны, и изучи их. Потом — к следующему уроку.

🚀 Перейти к Уроку 20

Сначала пройди все секции и выбери хотя бы 1 вопрос

⬅️ Назад к Уроку 18
🎉
Новый тир
Новый тир достигнут!