Приветствую тебя, уважаемый читатель Хабра! Меня зовут Артур Вартанян и я работаю в компании “Рексофт” 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)
IvanVakhrushev
00.00.0000 00:00@rogue06, можете рассказать, в каких случаях будет работать
withResure(true)
? Есть ли какая-то статистика по ускорению?
На своих проектах не заметил никакого прироста.kacetal
00.00.0000 00:00+1Ни в каких, так как для него ещё нужно добавить проперти файл
.testcontainers.properties
с пропертиtestcontainers.reuse.enable=true
в домашнюю директорию.
DonAlPAtino
00.00.0000 00:00А исходниками проекта не поделитесь? А то что-то совсем не взлетает контейнер. Даже на самом примитивном тесте
class SpringShopTests extends SpringBootApplicationTest{ @Test void contextLoads() { }}
хотя рядом docker-compose все поднимает.
VladZn
00.00.0000 00:00Для Spring Boot проще использовать стартеры от Playtika https://github.com/PlaytikaOSS/testcontainers-spring-boot
kacetal
Вообще уже достаточно давно существует аннотация которая позволяет инжектить проперти без всей этой возни с наследованием и статическими вложенным класом.
@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);
}
rogue06 Автор
Спасибо за замечание!
Буду иметь ввиду.