Изображение — Edge2Edge Media — Unsplash.com

Наверняка, многие из вас работали или хотя бы слышали о том, что есть разработчики, которые работают над проектом в одиночку. Ну как в одиночку… Есть скрам, аналитик, продакт, еще кто-то вплоть до директора, а вот программист один, даже тестировщика нет. В этом случае оптимальным видом тестирования, на мой взгляд, является интеграционное тестирование с  использованием тест-контейнеров.

Привет, Хабр! Меня зовут Николай Пискунов — ведущий разработчик в подразделении 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. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.