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

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


  1. jdev
    18.03.2024 02:16
    +3

    Последние 4 года 90% моих тестов такие и есть - интеграционные, с БД в тестконтейнере и запросами по ХТТП.

    И так как я работаю по ТДД и запускаю тесты по нескольку раз в минуту, мне пришлось научиться делать такие тесты более быстрыми, чем тесты на моках.


    Два оснонвых секрета:
    1) Не использовать @DynamicPropertySource, потому что это приводит к инвалидации контекста в кэше и запуску контекста для каждого тест-кейса
    2) Использовать RAM-диск для постгреса.

    Вместо DynamicPropertySource я использую такой трюк:

    @ContextConfiguration(
        // ...
        initializers = [TestContainerDbContextInitializer::class]
    )
    
    class TestContainerDbContextInitializer : ApplicationContextInitializer<ConfigurableApplicationContext> {
    
        override fun initialize(applicationContext: ConfigurableApplicationContext) {
            // это небольшая функция-расширение, которая просто перетирает 
            applicationContext.overrideProperties(
                "spring.datasource.username" to pgContainer.username,
                // ...
            )
        }
    }

    А чтобы посадить Postgres на рам-диск - такой:

            .withTmpFs(mapOf("/var" to "rw"))
            .withEnv("PGDATA", "/var/lib/postgresql/data-no-mounted")

    В результате, у меня на i7-8700, 32 RAM, SSD интеграционные тесты выполняются от 14мс при тесте с моками в 163 мс:

    А в проекте со скрина, я пошёл ещё радикальнее - отказался от @SpringBootTest и запускаю приложание руками, а в локальной разработке сначала ищу предзапущенную БД:

    val context: ConfigurableApplicationContext by lazy {
        SpringApplicationBuilder(TestsConfig::class.java)
            .profiles("test")
            .build()
            .run()
    }
    
    @Import(
        QYogaApp::class,
        BackgroundsConfig::class,
        TestPasswordEncoderConfig::class,
        TestDataSourceConfig::class,
        TestMinioConfig::class,
        FailingController::class
    )
    @Configuration
    class TestsConfig
    
    private const val DB_USER = "postgres"
    private const val DB_PASSWORD = "password"
    
    val jdbcUrl: String by lazy {
        try {
            val con = DriverManager.getConnection(
                PROVIDED_DB_URL.replace("qyoga", DB_USER),
                DB_USER,
                DB_PASSWORD
            )
            log.info("Provided db found, recreating it")
            con.prepareStatement(
                """
                    DROP DATABASE IF EXISTS qyoga;
                    CREATE DATABASE qyoga;
                """.trimIndent()
            )
                .execute()
            log.info("Provided db found, recreated")
            PROVIDED_DB_URL
        } catch (e: SQLException) {
            log.info("Provided Db not found: ${e.message}")
            pgContainer.jdbcUrl
        }
    }

    Это позволяет сэкономить ещё пару секунд на инициализации тестов, что имеет существенное значение, когда ты делаешь зелёным один тест кейс.


  1. anchoret
    18.03.2024 02:16
    +1

    Возможно, я что-то упустил из виду. Но набор зависимостей выглядит больше, чем "необходимый".

    • Есть подключение jUnit, при этом org.testcontainers:spock, а использование самого Spock не заметил. Да и вряд ли в такой материал его стоило бы добавлять.

    • Зависимость junit:junit на Maven Central 4.13.2 последней версии, которая была выпущена 3 года назад, потому что артефакт был перемещен: org.junit.jupiter:junit-jupiter-api

    • Аннотации от lombok наблюдаются, который не упоминается сам, да и не видно особого повода для их применения в такой подаче "отдельный элементов" кода.

    • Указывать @DirtiesContext, как необходимую аннотацию для тест-класса не самый полезный совет во многих случаях.

    Возможно, материал может дать какие-то стартовые подсказки совсем новичкам, но без попытки все самостоятельно "прогуглить", может направить куда-то не туда.


  1. Throwable
    18.03.2024 02:16

    А в чем проблема указать в application-test.yaml jdbc driver к testcontainers и соответствующий url? Тогда вся инициализация делается автоматически и один раз.

    Общая проблема с медленным запуском все же остаётся. В моем предыдущем проекте я использовал JPA, H2 и Guice. Запуск тестов осуществлялся меньше секунды.