Как облегчить жизнь программисту при написании тестов

от автора

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

Изображение — 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. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.


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


Комментарии

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

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