Приветствую тебя, уважаемый читатель Хабра! Меня зовут Артур Вартанян и я работаю в компании “Рексофт” 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 нужного сервиса в контейнере за небольшое время.

Комментарии (6)


  1. kacetal
    00.00.0000 00:00
    +3

    Вообще уже достаточно давно существует аннотация которая позволяет инжектить проперти без всей этой возни с наследованием и статическими вложенным класом.

    @DynamicPropertySource
        static void datasourceProperties(DynamicPropertyRegistry registry) {
            registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl);
            registry.add("spring.datasource.password", postgreSQLContainer::getPassword);
            registry.add("spring.datasource.username", postgreSQLContainer::getUsername);
        }


    1. rogue06 Автор
      00.00.0000 00:00

      Спасибо за замечание!
      Буду иметь ввиду.


  1. IvanVakhrushev
    00.00.0000 00:00

    @rogue06, можете рассказать, в каких случаях будет работать withResure(true)? Есть ли какая-то статистика по ускорению?
    На своих проектах не заметил никакого прироста.


    1. kacetal
      00.00.0000 00:00
      +1

      Ни в каких, так как для него ещё нужно добавить проперти файл .testcontainers.properties с проперти testcontainers.reuse.enable=true в домашнюю директорию.


  1. DonAlPAtino
    00.00.0000 00:00

    А исходниками проекта не поделитесь? А то что-то совсем не взлетает контейнер. Даже на самом примитивном тесте

    class SpringShopTests extends SpringBootApplicationTest{ @Test void contextLoads() { }}

    хотя рядом docker-compose все поднимает.


  1. VladZn
    00.00.0000 00:00

    Для Spring Boot проще использовать стартеры от Playtika https://github.com/PlaytikaOSS/testcontainers-spring-boot