Как облегчить жизнь программисту при написании тестов
Наверняка, многие из вас работали или хотя бы слышали о том, что есть разработчики, которые работают над проектом в одиночку. Ну как в одиночку… Есть скрам, аналитик, продакт, еще кто-то вплоть до директора, а вот программист один, даже тестировщика нет. В этом случае оптимальным видом тестирования, на мой взгляд, является интеграционное тестирование с использованием тест-контейнеров.
Привет, Хабр! Меня зовут Николай Пискунов — ведущий разработчик в подразделении 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. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.