
Наверняка, многие из вас работали или хотя бы слышали о том, что есть разработчики, которые работают над проектом в одиночку. Ну как в одиночку… Есть скрам, аналитик, продакт, еще кто-то вплоть до директора, а вот программист один, даже тестировщика нет. В этом случае оптимальным видом тестирования, на мой взгляд, является интеграционное тестирование с использованием тест-контейнеров.
Привет, Хабр! Меня зовут Николай Пискунов — ведущий разработчик в подразделении Big Data. И сегодня в блоге beeline cloud поговорим о Spring boot и интеграционном тестировании. Расскажу, как упростить жизнь при написании тестов.
Погружаемся в детали...
Допустим, что у нас есть контроллер со стандартными CRU-операциями:
@RestController @RequiredArgsConstructor @RequestMapping(value = "/api/v1") @FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true) public class FooController { FooService fooService; @PostMapping public ResponseEntity<FooDto> create(@Valid @RequestBody FooDtoRequest request) { return ResponseEntity.ok(fooService.create(request)); } @GetMapping public ResponseEntity<PagedFooDto> readAll(@RequestParam(value = "page", defaultValue = "0") Integer page, @RequestParam(value = "pageSize", defaultValue = "15") Integer pageSize) { return ResponseEntity.ok(fooService.getFooDtoFromDB(page, pageSize)); } @GetMapping(value = "/{uuid}") public ResponseEntity<FooDto> readOne(@PathVariable UUID uuid) { return ResponseEntity.ok(fooService.getOneFooDtoFromDB(uuid)); } @PutMapping(value = "/{uuid}") public ResponseEntity<FooDto> update(@Valid @RequestBody FooDtoRequest request, @PathVariable UUID uuid) { FooDto response = fooService.update(request, uuid); return ResponseEntity.ok(response); } @DeleteMapping(value = "/{uuid}") public ResponseEntity<Map<String, String>> delete(@PathVariable UUID uuid) { fooService.delete(uuid); return ResponseEntity.ok(Map.of("status", "deleted")); } }
И требуемые нам объекты выглядят так (для простоты пусть поля в этих объектах будут одинаковые).
Request:
public record FooDtoRequest( UUID id, @NotBlank(message = "field must not be blank") String fooFieldOne, @NotBlank(message = "field must not be blank") String fooFieldTwo ) { @Builder public FooDtoRequest {} }
Response:
public record FooDto ( UUID id, String fooFieldOne, String fooFieldTwo ) { @Builder public FooDto {} }
За контроллером расположен стандартный сервис-класс, который выполняет CRUD-операции с записями в базе данных. Эндпоинты, реализованные в этом контроллере, мы и будем покрывать интеграционными тестами.
В проектах я чаще всего использую maven. Подключаем зависимости, необходимые для проведения тестирования:
<dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <version>${testcontainers.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>spock</artifactId> <version>${testcontainers.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.testcontainers</groupId> <artifactId>postgresql</artifactId> <version>${testcontainers.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>io.rest-assured</groupId> <artifactId>rest-assured</artifactId> <version>${rest-assured.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> </exclusion> </exclusions> </dependency>
Тест-класс необходимо пометить аннотациями:
@Slf4j @DirtiesContext @Testcontainers @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class FooTests { … }
В схеме примера нам потребуется тестовая БД, я использую Postgresql.
testcontainers поднимают докер-контейнеры, поэтому выбираем нужный тэг на сайте dockerhub.Вызов, старт, остановка контейнера происходит прямо из нашего тестового класса, для этого достаточно добавить:
@Container public static final JdbcDatabaseContainer<?> postgreSQLContainer = new PostgisContainerProvider() .newInstance("15-3.4") .withDatabaseName("tests-db") .withUsername("sa") .withPassword("sa");
После того как контейнер проинициализируется, иногда требуется выполнить какой-либо SQL скрипт. Например, заполнить данными созданные таблицы. Для этого достаточно разместить файл с SQL командами в папке resources и добавить “.withInitScript("test.sql")”:
@Container public static final JdbcDatabaseContainer<?> postgreSQLContainer = new PostgisContainerProvider() .newInstance("15-3.4") .withDatabaseName("tests-db") .withUsername("sa") .withPassword("sa") .withInitScript("test.sql");
Testcontainers так же позволяют нам динамически управлять характеристиками приложения. В нашем примере динамически будут меняться данные для подключения к базе данных:
@DynamicPropertySource private static void datasourceConfig(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl); registry.add("spring.datasource.username", postgreSQLContainer::getUsername); registry.add("spring.datasource.password", postgreSQLContainer::getPassword); }
На этом этапе мы готовы писать тесты, а сам класс должен выглядеть примерно так:
@Slf4j @DirtiesContext @Testcontainers @AutoConfigureMockMvc @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) public class FooTests { @LocalServerPort Integer port; @Container public static final JdbcDatabaseContainer<?> postgreSQLContainer = new PostgisContainerProvider() .newInstance("15-3.4") .withDatabaseName("tests-db") .withUsername("sa") .withPassword("sa"); @DynamicPropertySource private static void datasourceConfig(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl); registry.add("spring.datasource.username", postgreSQLContainer::getUsername); registry.add("spring.datasource.password", postgreSQLContainer::getPassword); } @BeforeEach void setUp() { RestAssured.baseURI = "http://localhost:" + port; } }
К примеру, у нас есть список тест-кейсов — они должны корректно отработать, чтобы считалось, что приложение готово увидеть свет.
Предлагаю начать с положительных тестов и добавить запись в наш сервис. Для этого используем метод given() из библиотеки RestAssured:
import static io.restassured.RestAssured.given;
И добавим первый тест:
@Test void goodTestCases() { FooDtoRequest request = FooDtoRequest.builder() .fooFieldOne("fooFieldOne") .fooFieldTwo("fooFieldTwo") .build(); given() .contentType(ContentType.JSON) .body(b) // задаем тело запроса .when() .post("/api/v1") // выполняем запрос .then() .statusCode(200) // проверяем статус ответа // проверяем корректность заполнения полей ответа .body("fooFieldOne", equalTo(request.fooFieldOne())) .body("fooFieldTwo", equalTo(requestb.fooFieldTwo())) .log(); }
Теперь нужно получить запись после создания. Для этого после запроса на создание добавим запрос на получение.
Получение происходит по урлу “/api/v1/{uuid}”. Где uuid — это идентификатор только что созданной сущности, которая возвращается на POST-запрос. Чтобы его получить, нужно слегка изменить первый запрос:
FooDto response = given() .contentType(ContentType.JSON) .body(request) .when() .post("/api/v1") .as(FooDto.class);
Теперь это объект, из которого можно получить id и ничто не мешает выполнить GET-запрос:
given() .contentType(ContentType.JSON) .pathParam("uuid", response.id()) .when() .get("/api/v1/{uuid}") .then() .statusCode(200) // проверяем корректность заполнения полей ответа .body("fooFieldOne", equalTo(request.fooFieldOne())) .body("fooFieldTwo", equalTo(request.fooFieldTwo()));
Затем обновим объект:
request = FooDtoRequest.builder() .fooFieldOne("NEWFieldOne") .fooFieldTwo("NEWFieldTwo") .build(); given() .contentType(ContentType.JSON) .body(request) .pathParam("uuid", response.id()) .when() .put("/api/v1/{uuid}") .then() .statusCode(200) // проверяем корректность заполнения полей ответа .body("fooFieldOne", equalTo(request.fooFieldOne())) .body("fooFieldTwo", equalTo(request.fooFieldTwo()));
И удалим:
given() .contentType(ContentType.JSON) .pathParam("uuid", response.id()) .when() .delete("/api/v1/{uuid}") .then() .statusCode(200);
Итак, один из положительных сценариев тестирования мы провели. Улучшить его можно, например, проверками данных непосредственно в БД.
Теперь мы уверены в том, что получим полностью рабочий проект, который отвечает запрашиваемым критериям. Эту схему можно масштабировать на регрессионное тестирование и на автоматический запуск, а так же легко передать QA. Т.е. покрыть типы интеграционного тестирования до выхода на стейдж.
beeline cloud— secure cloud provider. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.

