Практический кейс: как быстро развернуть Testcontainer PostgreSQL для Spring Boot API

от автора

Приветствую тебя, уважаемый читатель Хабра! Меня зовут Артур Вартанян и я работаю в компании “Рексофт” Java-разработчиком.

Тема с testcontainer-ами относительно не новая, первые статьи на англоязычных ресурсах встречаются с 2016 года, но не смотря на это, до сих пор на просторах веба крайне мало гайдов для их развертывания из коробки. В большинстве своем это туториалы, где собрана солянка из зависимостей и аннотаций, которые мало того, что не нужны, но еще и могут запутать разработчика, решившего  с ними познакомиться. В этой статье я опишу свой практический кейс по развертыванию тестовых контейнеров для базы данных PostgreSQL. Основная задача их использования — быстрый deploy нужного сервиса в контейнере за небольшое время. В дополнении для наглядности запустим туда FlyWay миграции.

Мы не будем говорить о плюсах и минусах контейнеризации и интеграционных тестов, а сразу приступим к делу.

Нам потребуется:

  • Docker. Он нужен для того, чтобы запускать контейнер и управлять им.

  • Spring MVC API (REST) с некоторым количеством тестов. Приложение, которое будет запускать тесты в контейнере, должно иметь хотя бы один интеграционный тест, иначе смысла в использовании контейнера нет.

Класс контроллера:

@RestController @RequiredArgsConstructor @RequestMapping("/api/authentication") public class AuthenticationController {      private final AuthenticationService authenticationService;      @PostMapping(value = "/login")     public ResponseEntity<LoginResponseDTO> login(@RequestBody @Validated LoginCredentialDTO loginCredentialDTO)             throws NamingException {         return new ResponseEntity<>(authenticationService.login(loginCredentialDTO), HttpStatus.OK);     } }

Класс тестов:

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;  import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.Assertions; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import ru.project.path.dto.LoginCredentialDTO;  import java.time.LocalDateTime; import java.util.List;   class AuthenticationControllerTest {      @Autowired     MockMvc mockMvc;      @Autowired     ObjectMapper objectMapper;       @Test     void loginOkWithLogging() throws Exception {         LoginCredentialDTO loginCredentialDTO = generateDTOWithCorrectCredentials();         mockMvc.perform(post("/api/authentication/login")                         .content(objectMapper.writeValueAsString(loginCredentialDTO))                         .contentType(MediaType.APPLICATION_JSON))                 .andExpect(status().isOk());     }      @Test     void loginWithBadCredentials() throws Exception {         mockMvc.perform(post("/api/authentication/login")                 .content(objectMapper.writeValueAsString(generateDTOWithIncorrectCredentials()))                 .contentType(MediaType.APPLICATION_JSON)         ).andExpect(status().isBadRequest());     }      @Test     void loginWithBadValidation() throws Exception {         mockMvc.perform(post("/api/authentication/login")                 .content(objectMapper.writeValueAsString(generateDTOWithBadValidationCredentials()))                 .contentType(MediaType.APPLICATION_JSON)         ).andExpect(status().isBadRequest());     } }

Для того, чтобы переписать код под использование testcontainers, необходимо добавить всего лишь 3 зависимости (для примера используется Gradle / версии актуальны на момент написания статьи):

   testImplementation 'org.springframework.boot:spring-boot-starter-test:2.7.2'     testImplementation "org.testcontainers:postgresql:1.17.6"     testImplementation "org.testcontainers:junit-jupiter:1.17.6"
  • spring-boot-starter-test — для поднятия контекста тестов

  • testcontainers:postgresql — PostgreSQL для контейнера

  • testcontainers:junit-jupiter — JUnit Jupiter для контейнера

Если проект не специфичный и не должен поддерживать связку legacy тестов вперемешку с новыми, то других зависимостей не требуется! Иначе из-за одной неверно подтянутой аннотации testcontainer-ы могут просто не подняться, и понять в чем дело будет тяжело, так как логи об ошибке не будут явно указывать на проблему зависимостей. Все свои другие библиотеки от JUnit, которые вы использовали до добавления testcontainer-ов нужно удалить — они ни к чему.

Теперь, после добавления нужных библиотек, необходимо создать класс, который будет отвечать за конфигурирование и настройку контейнера:

import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.util.TestPropertyValues; import org.springframework.context.ApplicationContextInitializer; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.TestPropertySource; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers;  /**  * General class for test containers.  */ @SpringBootTest @Testcontainers @AutoConfigureMockMvc(addFilters = false) @ContextConfiguration(initializers = {SpringBootApplicationTest.Initializer.class}) @TestPropertySource(properties = {"spring.config.location=classpath:application-properties.yml"}) public class SpringBootApplicationTest {      private static final String DATABASE_NAME = "spring-app";      @Container     public static PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:11.1")             .withReuse(true)             .withDatabaseName(DATABASE_NAME);      static class Initializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {         public void initialize(ConfigurableApplicationContext configurableApplicationContext) {             TestPropertyValues.of(                     "CONTAINER.USERNAME=" + postgreSQLContainer.getUsername(),                     "CONTAINER.PASSWORD=" + postgreSQLContainer.getPassword(),                     "CONTAINER.URL=" + postgreSQLContainer.getJdbcUrl()             ).applyTo(configurableApplicationContext.getEnvironment());         }     } }

@SpringBootTest — поднимает ApplicationContext для запуска тестов и имитирует среду для тестирования близкую к полноценному запуску приложения.

@Testcontainers — аннотация для JUpiter интеграционных тестов. Находит все поля, помеченные аннотацией @Container, и вызывает их методы контроля за жизненным циклом для контейнера.

@AutoConfigureMockMvc — в тестах я часто использую MockMVC класс, поэтому для его настройки из  коробки я использую данную аннотацию.

@ContextConfiguration — аннотация определяет метаданные уровня класса, которые используются для определения того, как загружать и настраивать ApplicationContext для интеграционных тестов. На самом деле, тут можно обойтись и без данной аннотации, так как мы уже используем @SpringBootTest, но с ним код будет чище и появится возможность сразу создавать переменные окружения для конфигураций ApplicationContext-а. 

@TestPropertySource — указываем путь нахождения файла application.properties для тестов.

Статичное поле postgreSQLContainer, помеченное аннотацией @Container, по сути является флажком для аннотации @TestContainer, который понимает, что необходимо запустить тестовый контейнер PostgreSQL и управлять его жизненным циклом.

Тут же вызываются методы withReuse() и withDatabaseName().

withResure() с флажком true — держит контейнер в активном состоянии до тех пор, пока не исполнятся все тестовые методы, а также дает возможность переиспользовать контейнер для каждого теста без его затухания и поднятия нового экземпляра.

withDatabaseName() — получает на вход название базы данных (можно указать рандомный и также вынести в переменную окружения — см.ниже).

Вложенный статичный класс Initializer реализует ApplicationContextInitializer и при инициализации создает переменные окружения. В TestPropertyValues  мы их и создаем по принципу ключ-значение.

По сути после поднятия контекста у нас будут доступны три переменные, которые мы можем использовать там, где это необходимо, например, при накатке FlyWay миграций.

Класс самих тестов также немного преобразуется и станет наследником SpringBootApplicationTest, одновременно меняя свои аннотации на JUnit Jupiter:

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;  import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import ru.project.path.SpringBootApplicationTest;  import java.time.LocalDateTime; import java.util.List;  /**  * Test class for AuthenticationController  */ class AuthenticationControllerTest extends SpringBootApplicationTest {      @Autowired     MockMvc mockMvc;      @Autowired     ObjectMapper objectMapper;         @Test     void loginOkWithLogging() throws Exception {         LoginCredentialDTO loginCredentialDTO = generateDTOWithCorrectCredentials();         mockMvc.perform(post("/api/authentication/login")                         .content(objectMapper.writeValueAsString(loginCredentialDTO))                         .contentType(MediaType.APPLICATION_JSON))                 .andExpect(status().isOk());     }      @Test     void loginWithBadCredentials() throws Exception {         mockMvc.perform(post("/api/authentication/login")                 .content(objectMapper.writeValueAsString(generateDTOWithIncorrectCredentials()))                 .contentType(MediaType.APPLICATION_JSON)         ).andExpect(status().isBadRequest());     }      @Test     void loginWithBadValidation() throws Exception {         mockMvc.perform(post("/api/authentication/login")                 .content(objectMapper.writeValueAsString(generateDTOWithBadValidationCredentials()))                 .contentType(MediaType.APPLICATION_JSON)         ).andExpect(status().isBadRequest());     } }

Напоследок application-properties.yml файл (создаем в тестах новый файл в папке ресурсов):

#Spring Boot spring:  datasource:     own:      username: ${CONTAINER.USERNAME}      password: ${CONTAINER.PASSWORD}      url: ${CONTAINER.URL}

Так как приложение во время работы должно использовать базы данных от тестконтейнера, необходимо прописать для него строку подключения. Как уже отмечалось выше, в переменные окружения при поднятии тестового контекста уже все было вынесено, а именно:

CONTAINER.URL — строка подключения к БД

CONTAINER.USERNAME — имя пользователя

CONTAINER.PASSWORD — пароль пользователя

Подставив их в application-properties.yml файл, можно получить полностью настроенный контекст приложения.

P.S. При указании в application-properties значений с окружения, используется запись вида: ${НАЗВАНИЕ_ПЕРЕМЕННОЙ}.

Дополнение:

Для большей наглядности на базу PostgreSQL от тестконтейнера накатим FlyWay миграции, чтобы получить базу идентичную с приложением.

Для этого в тестовый application-properties.yml файл нужно добавить всего лишь пару строк кода, где также будут указаны данные для подключения и запуска миграций:

flyway:    user: ${CONTAINER.USERNAME}    password: ${CONTAINER.PASSWORD}    url: ${CONTAINER.URL}

При запуске тестовых классов или методов, в Docker (Docker Desktop в моем случае) можно будет увидеть поднятый контейнер, который сразу же после завершения тестов потухнет.

Надеюсь данная статья была для Вас полезна и в дальнейшем будет помогать тратить меньше времени на запуск и настройку тестовых контейнеров, ведь один из главных смыслов их использования — это быстрый deploy нужного сервиса в контейнере за небольшое время.


ссылка на оригинал статьи https://habr.com/ru/post/715496/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *