Когда одних юнит-тестов уже недостаточно, на сцену выходят интеграционные. В этой статье от команды Amplicode мы покажем, как протестировать REST API в Spring Boot с использованием современного стека: генерация тестов через Amplicode, автоматический запуск окружения с помощью Docker Compose Starter и поддержки со стороны LLM-инструментов от Яндекса.
Статья также доступна в формате видео на YouTube, VK Видео и RUTUBE, так что можно и смотреть, и читать — как вам удобнее!
Тема тестирования одна из важнейших для разработчика, потому что именно благодаря тестам мы получаем следующие преимущества:
Уверенность в правильности работы кода
Возможность безопасного рефакторинга
Сокращение времени на поиск ошибок
Уменьшение количества дефектов в продакшене
Документация ожидаемого поведения системы
Упрощение поддержки и расширения проекта
Быстрая обратная связь о работоспособности системы
Повышение качества разрабатываемого продукта
Повышение надежности и стабильности системы
Можете сами продолжить в комментариях, если я что-то забыл :)
А за счёт того, что Spring придерживается слоеной архитектуры, у нас появляется великолепная возможность тестировать каждый слой нашего приложения в отдельности. Следующие аннотации и технологии могут помочь в написании юнит тестов для каждого из уровней:

Но даже если мы протестируем каждый уровень нашего приложения в отдельности, мы не можем быть уверенными в том, что вместе эти уровни тоже работают корректно.
Решить эту проблему помогают интеграционные тесты, проверяющие работу всех уровней вместе, а также взаимодействие с внешними сервисами, системами и инструментами.
Обзор приложения и программа действий
Сегодня мы возьмём небезызвестный проект spring petclinic и напишем интеграционные тесты для CRUD REST контроллера.
Как всегда, давайте сначала посмотрим, с каким приложением нам придется работать.
Из стартеров у нас присутствуют Spring Web, Spring Data JPA, в качестве системы версионирования базы данных выбран Flyway, также подключены актуаторы и Kafka, а в качестве базы данных используется PostgreSQL.

Тестировать мы сегодня будем OwnerRestController
, который подробнее рассмотрим чуть позже.
Отдельное внимание хотел бы уделить тому, как устроена работа с окружением на этом проекте.

Есть файл services.yaml, в котором объявлены сервисы Kafka и PostgreSQL, а также есть docker compose файлы для девелопмента и продакшена, которые расширяют сервисы из файла services.yaml. Про такой способ настройки окружения я уже рассказывал в статье "Лучший способ создания нескольких окружений для Spring Boot приложения с помощью Docker Compose".
Перейдем к контроллеру.
OwnerRestController
@RestController
@RequestMapping("/rest/owners")
public class OwnerRestController {
private final OwnerRepository ownerRepository;
private final OwnerMapper ownerMapper;
private final ObjectMapper objectMapper;
public OwnerRestController(OwnerRepository ownerRepository,
OwnerMapper ownerMapper,
ObjectMapper objectMapper) {
this.ownerRepository = ownerRepository;
this.ownerMapper = ownerMapper;
this.objectMapper = objectMapper;
}
@PostMapping
public OwnerDto create(@RequestBody @Valid OwnerDto ownerDto) {
if (ownerDto.getId() != null) {
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Id must be null");
}
Owner owner = ownerMapper.toEntity(ownerDto);
Owner saved = ownerRepository.save(owner);
return ownerMapper.toOwnerDto(saved);
}
@GetMapping("/{id}")
public OwnerMinimalDto getOne(@PathVariable Integer id) {
Optional<Owner> ownerOptional = ownerRepository.findById(id);
return ownerMapper.toOwnerMinimalDto(ownerOptional.orElseThrow(() ->
new ResponseStatusException(HttpStatus.NOT_FOUND, "Entity with id %s not found".formatted(id))));
}
@GetMapping
public PagedModel<OwnerMinimalDto> getAll(@ModelAttribute OwnerFilter ownerFilter,
Pageable pageable) {
Page<Owner> owners = ownerRepository.findAll(ownerFilter.toSpecification(), pageable);
var ownerDtoPage = owners.map(ownerMapper::toOwnerMinimalDto);
return new PagedModel<>(ownerDtoPage);
}
@PutMapping("/{id}")
public OwnerDto update(@PathVariable Integer id,
@RequestBody @Valid OwnerDto dto) {
if (!dto.getId().equals(id)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Id in request body and path variable must be equal");
}
Owner owner = ownerRepository.findById(id)
.orElseThrow(() ->
new ResponseStatusException(HttpStatus.NOT_FOUND, "Entity with id %s not found".formatted(id))
);
ownerMapper.updateWithNull(dto, owner);
Owner resultOwner = ownerRepository.save(owner);
return ownerMapper.toOwnerDto(resultOwner);
}
@PatchMapping("/{id}")
public OwnerDto patch(@PathVariable Integer id,
@RequestBody JsonNode patchNode) throws IOException {
if (patchNode.get("id") == null || patchNode.get("id").asInt() != id) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Id in request body and path variable must be equal");
}
Owner owner = ownerRepository.findById(id)
.orElseThrow(() ->
new ResponseStatusException(HttpStatus.NOT_FOUND, "Entity with id %s not found".formatted(id))
);
OwnerDto ownerDto = ownerMapper.toOwnerDto(owner);
objectMapper.readerForUpdating(ownerDto)
.readValue(patchNode);
ownerMapper.updateWithNull(ownerDto, owner);
Owner resultOwner = ownerRepository.save(owner);
return ownerMapper.toOwnerDto(resultOwner);
}
@DeleteMapping("/{id}")
public OwnerDto delete(@PathVariable Integer id) {
Owner owner = ownerRepository.findById(id)
.orElse(null);
if (owner != null) {
ownerRepository.delete(owner);
}
return ownerMapper.toOwnerDto(owner);
}
}
На самом деле этот контроллер был разработан ранее для статьи "Создаём CRUD REST API в Spring Boot быстро и просто вместе с Amplicode", и его работоспособность была проверена уже тогда при помощи ConneKt (HTTP клиента от Amplicode). Тогда же было показано, как формировать запросы к этому контроллеру и писать команды assert для эндпоинтов.

Но это все же было скорее ручное, или, точнее, полуавтоматизированное тестирование. Само приложение и окружение для него поднимались вручную. Однако, любому production-ready приложению необходимы полностью автоматизированные интеграционные тесты. Приступим к их написанию.
Настройка окружения для тестов
Очевидно, что мы не хотим вручную поднимать базу каждый раз, когда нам нужно запустить тесты. Хотелось бы, чтобы все необходимые сервисы стартовали автоматически каждый раз, когда я буду запускать тесты. Здесь тоже существует несколько подходов, можно использовать Testcontainers, но сегодня мы расскажем про Docker Compose Starter, который появился не так давно, в Spring Boot 3.1.
Мы можем для наших тестов указать docker compose файл, в котором будут объявлены все необходимые нам сервисы, и тогда в момент запуска все эти сервисы будут подниматься и использоваться.
В приложении уже существует довольно серьезная конфигурация docker compose файлов, и этим фактом грех не воспользоваться.
Первым делом подключим стартер. Для этого перейдем в build.gradle и добавим необходимую зависимость.
dependencies {
implementation (
'org.springframework.boot:spring-boot-docker-compose'
)
}
И теперь нам необходимо отключить интеграцию с docker compose, как бы странно это ни звучало. В противном случае необходимо было бы настраивать ее для различных профилей — прод, дев, указывать, какой файл использовать для какого профиля, что выходит за рамки данной статьи.
Поэтому перейдем в application.properties и напишем там следующее:
spring.docker.compose.enabled=false
Для тестирования нашего контроллера создадим класс OwnerRestControllerTest
и добавим следующие аннотации:
@SpringBootTest
@TestPropertySource(properties = {
"spring.docker.compose.enabled=true",
"spring.docker.compose.skip.in-tests=false",
"spring.docker.compose.stop.command=down",
"spring.docker.compose.file=docker-compose-tests.yaml"
})
@AutoConfigureMockMvc
public class OwnerRestControllerTest {
...
}
Заострять внимание на довольно тривиальных аннотациях @SpringBootTest
и @AutoConfigureMockMvc
я не буду. Расскажу поподробнее только про @TestPropertySource
.
Здесь свойство spring.docker.compose.enabled
разрешает использование docker compose файлов в момент запуска тестов. Включить эту фичу явно необходимо потому, что мы её ранее выключили в основном файле приложения application.properties
.
Свойство stop.command
выставляется в down
вместо значения по умолчанию scope
во избежание сохранения информации между различными запусками тестов, благодаря этому свойству контейнер после каждого запуска всех тестов будет уничтожаться, и будет создаваться новый.
Последним шагом указывается название файла, который будет использоваться для наших тестов: docker-compose-tests.yaml
.
Теперь нам остается только создать этот docker compose файл.
docker-compose-tests.yaml файл
services:
postgres-test:
extends:
service: postgres
file: services.yaml
ports:
- "5432:5432"
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
POSTGRES_USER: postgres
kafka-test:
extends:
service: kafka
file: services.yaml
ports:
- "9094:9094"
environment:
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka-test:29094,PLAINTEXT_HOST://localhost:9094
KAFKA_LISTENERS: PLAINTEXT://kafka-test:29094,PLAINTEXT_HOST://0.0.0.0:9094,CONTROLLER://kafka-test:9093
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka-test:9093
healthcheck:
test: kafka-topics --bootstrap-server localhost:9094 --list
interval: 10s
timeout: 5s
start_period: 30s
retries: 5
Окружение готово. Наконец-то можем приступить непосредственно к написанию тестов.
Тестируем POST-эндпоинт
@PostMapping
public OwnerDto create(@RequestBody @Valid OwnerDto ownerDto) {
if (ownerDto.getId() != null) {
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Id must be null");
}
Owner owner = ownerMapper.toEntity(ownerDto);
Owner saved = ownerRepository.save(owner);
return ownerMapper.toOwnerDto(saved);
}
Метод для POST’а довольно тривиален, ожидаем на вход DTO, проверяем, что поле id
принимает значение null
, а если не так, то выбрасываем исключение, так как айдишники в нашем приложении сущностям мы будем назначать самостоятельно, а точнее этим будет заниматься БД. Далее всё просто — мапим DTO в сущность, сохраняем и мапим результат обратно в DTO, чтобы вернуть ответ пользователю.
Для ускорения написания кода воспользуюсь действием "Create MocvkMvc Test" от Amplicode:

А вот и наш первый тест:
@Test
public void create() throws Exception {
String ownerDto = """
{
"id": 0,
"firstName": "",
"lastName": "",
"address": "",
"city": "",
"telephone": ""
}""";
mockMvc.perform(post("/rest/owners")
.content(ownerDto)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status()
.isOk())
.andDo(print());
}
Здесь есть предзаполненное DTO со структурой и, собственно, само выполнение запроса с последующим expect-ом, в котором ожидается возврат статуса “OK”.
Первое, что здесь необходимо сделать — это заполнить DTO, который сгенерировал Amplicode. Он корректно сгенерировал поля, отталкиваясь от модели, но значения в них пока не проставлены.
Заниматься этим вручную? В 2025-м году? Ну, это прямо моветон какой-то! Сейчас так никто не делает, все отдают такую работу на откуп LLM. Поэтому я воспользуюсь плагином SourceCraft Code Assistant от Яндекса.

Заполнить данными существующий DTO не получится, но можно дать LLM напутствие, намек на то, какой DTO нам нужен. Просто начнем писать String johnDoeDto, и вуаля, искусственный интеллект догадался что именно мы хотим.

Конечно, за ИИ нужно присматривать, он может допустить ошибки при генерации кода, например, сделать опечатки в названиях полей, но в подавляющем большинстве случаев он все делает, как надо, если дать ему хороший референс.
Такой способ подготовки тестовых данных послужил отличным примером комбинации работы различных инструментов. Мы использовали детерминированную логику генерации от Amplicode, который великолепно знает, какая модель нам нужна, какие значения полей будет необходимо подставить и как в точности называются поля. Затем мы отдали это LLM, которая по примеру заполнила наш DTO нужными данными.
Осталось написать вызовы метода andExpect()
, в чем нам тоже будет помогать LLM. Единственное, в чем LLM нам не сможет помочь. В итоге мы получим следующие expect'ы для нашего POST-запроса:
@Test
public void create() throws Exception {
String ownerDto = """
{
"firstName": "John",
"lastName": "Doe",
"address": "123 Main St",
"city": "Anytown",
"telephone": "5555555555"
}
""";
mockMvc.perform(post("/rest/owners")
.content(ownerDto)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("John"))
.andExpect(jsonPath("$.lastName").value("Doe"))
.andExpect(jsonPath("$.address").value("123 Main St"))
.andExpect(jsonPath("$.city").value("Anytown"))
.andExpect(jsonPath("$.telephone").value("5555555555"))
.andExpect(jsonPath("$.id").isNumber())
.andDo(print());
}
Итак, мы выполнили запрос и проверили ответ. Однако, интеграционные тесты также должны проверять то, с чем мы взаимодействуем. В данном случае мы взаимодействуем с базой данных. Поэтому нам необходимо убедиться, что значение в этой базе данных действительно сохранилось. Поэтому давайте проверим что именно сохранилось в базе:
@Test
public void create() throws Exception {
String ownerDto = """
{
"firstName": "John",
"lastName": "Doe",
"address": "123 Main St",
"city": "Anytown",
"telephone": "5555555555"
}
""";
mockMvc.perform(post("/rest/owners")
.content(ownerDto)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("John"))
.andExpect(jsonPath("$.lastName").value("Doe"))
.andExpect(jsonPath("$.address").value("123 Main St"))
.andExpect(jsonPath("$.city").value("Anytown"))
.andExpect(jsonPath("$.telephone").value("5555555555"))
.andExpect(jsonPath("$.id").isNumber())
.andReturn();
String contentAsString = mvcResult.getResponse()
.getContentAsString();
Integer id = JsonPath.parse(contentAsString)
.read("$.id");
Owner owner = ownerRepository.findById(id)
.orElseThrow();
assertThat(owner.getFirstName()).isEqualTo("John");
assertThat(owner.getLastName()).isEqualTo("Doe");
assertThat(owner.getAddress()).isEqualTo("123 Main St");
assertThat(owner.getCity()).isEqualTo("Anytown");
assertThat(owner.getTelephone()).isEqualTo("5555555555");
ownerRepository.delete(owner);
}
Следующий тест тоже будет для метода POST, так как у нас имеется второе ветвление, которое мы еще не проверили. Это та ситуация, когда мы передаем DTO с id, и в этом случае мы должны выбросить исключение.
Аналогичным образом генерируем тест с помощью Amplicode и дорабатываем под наши нужды:
@Test
public void createIdNotNull() throws Exception {
String ownerDto = """
{
"id": 1,
"firstName": "John",
"lastName": "Doe",
"address": "123 Main St",
"city": "Anytown",
"telephone": "5555555555"
}""";
mockMvc.perform(post("/rest/owners")
.content(ownerDto)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status()
.isUnprocessableEntity());
int size = ownerRepository.findAll()
.size();
assertThat(size).isEqualTo(0);
}
Для текущего теста важно убедиться в том, что мы не оставляем ничего лишнего в базе данных после прогона теста. Для этого получим все записи и проверим, что size
равен 0
.
Тестируем GET-эндпоинт
Следующим давайте напишем тест для метода GET.
@GetMapping("/{id}")
public OwnerMinimalDto getOne(@PathVariable Integer id) {
Optional<Owner> ownerOptional = ownerRepository.findById(id);
return ownerMapper.toOwnerMinimalDto(ownerOptional.orElseThrow(() ->
new ResponseStatusException(HttpStatus.NOT_FOUND,
"Entity with id %s not found".formatted(id)))
);
}
Генерируем заготовку теста, как и раньше, но, в отличии от тестов для метода POST, здесь нам понадобятся какие-то данные в базе, с которыми мы будем взаимодействовать. Эти данные мы будем запрашивать из тела теста через эндпоинт.
Есть несколько способов вставить данные в базу:
на этапе поднятия контейнера, указав инит скрипты в файле docker compose
воспользоваться Spring Data JPA репозиторием и вставить данные в тесте напрямую
использовать аннотацию
@Sql
.
Для целей данной статьи мы воспользуемся третьим вариантом.
Напишите в комментариях, каким способом предпочитаете пользоваться лично вы!
Добавим над тестом аннотацию @Sql
и укажем название скрипта, который будет использоваться, а также executionPhase
:
@Sql(scripts = "classpath:insert-owners.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
Указанный здесь скрипт, insert-owners.sql
, будет выполняться до нашего тестового метода и именно в нем мы будем вставлять в базу новые записи. Также нам понадобится скрипт, который будет называться delete-owners.sql
, который будет выполняться после нашего теста и будет удалять все те данные, которые мы вставили.
@Sql(scripts = "classpath:delete-owners.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
Удаляем мы данные для того, чтобы сделать тесты максимально изолированными друг от друга и чтобы данные одного теста не накладывались на другой.
Amplicode позволяет создать нам несуществующий пока что sql файл прямо из окна редактора кода.

Далее в созданном файле снова воспользуемся возможностями Amplicode, чтобы сгенерировать insert для таблицы owners.

В дальнейшем, когда у нас появится пример команды insert, мы можем показать его LLM, чтобы она сгенерировала еще несколько подобных SQL-выражений.
INSERT INTO owners (id, first_name, last_name, address, city, telephone)
VALUES (1, 'John', 'Doe', '123 Main Street', 'New York', '5555555555');
INSERT INTO owners (id, first_name, last_name, address, city, telephone)
VALUES (2, 'Jane', 'Doe', '456 Main Street', 'New York', '5555555556');
INSERT INTO owners (id, first_name, last_name, address, city, telephone)
VALUES (3, 'Jim', 'Brown', '789 Main Street', 'New York', '5555555557');
INSERT INTO owners (id, first_name, last_name, address, city, telephone)
VALUES (4, 'Jill', 'Will', '101 Main Street', 'New York', '5555555558');
INSERT INTO owners (id, first_name, last_name, address, city, telephone)
VALUES (5, 'Jack', 'White', '123 Main Street', 'New York', '5555555559');
Остается только создать файл delete-owners.sql
и написать delete
выражение для owners
включив в список на удаление ровно те id
, которые мы указали в файле insert-owners.sql
.
DELETE FROM owners WHERE id IN (1, 2, 3, 4, 5);
Теперь напишем необходимые expect’ы внутри нашего теста.
@Test
@Sql(scripts = "classpath:insert-owners.sql",
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "classpath:delete-owners.sql",
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
public void getOne() throws Exception {
mockMvc.perform(get("/rest/owners/{0}", "1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("John"))
.andExpect(jsonPath("$.lastName").value("Doe"))
.andExpect(jsonPath("$.address").value("123 Main Street"))
.andExpect(jsonPath("$.city").value("New York"))
.andExpect(jsonPath("$.telephone").value("5555555555"))
.andExpect(jsonPath("$.id").value(1));
}
Следующий тест тоже будет для метода GET, для того же эндпоинта, но теперь проверим сценарий “Not Found”.
Тест для ненайденной записи довольно тривиален. Достаточно просто сгенерировать стандартный тест и заменить ожидаемый статус на NotFound.
@Test
public void getOneNotFound() throws Exception {
mockMvc.perform(get("/rest/owners/{0}", "0"))
.andExpect(status().isNotFound());
}
Тестируем GET-эндпоинт для получения многих записей
Следующий на очереди тест getAll()
, тестирующий реализацию метода GET, которая позволяет получить множество записей по id
.
@GetMapping
public PagedModel<OwnerMinimalDto> getAll(@ModelAttribute OwnerFilter ownerFilter,
Pageable pageable) {
Page<Owner> owners = ownerRepository.findAll(ownerFilter.toSpecification(), pageable);
var ownerDtoPage = owners.map(ownerMapper::toOwnerMinimalDto);
return new PagedModel<>(ownerDtoPage);
}
В целом, его проверка должна заключаться в проверке корректной работы фильтров по отдельности и вместе, пагинации и тд. Но все они будут довольно тривиальные, сильно похожие друг на друга. Поэтому давайте напишем только один, остальные вы можете реализовать самостоятельно, если будет желание попрактиковаться. Напоминаю, что исходный проект опубликован на GitHub.
Сгенерируем тест, вставим данные, как мы это делали ранее, после чего удалим лишние проверки, оставив только LastNameContains
.
Будем проверять тот факт, что среди фамилий имеется значение Doe
. Мы оставили только две записи с такой фамилией, поэтому в полученном DTO должно быть именно два элемента. Тест примет такой вид, и этот вид будет окончательным:
@Test
@Sql(scripts = "classpath:insert-owners.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "classpath:delete-owners.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
public void getAll() throws Exception {
mockMvc.perform(get("/rest/owners")
.param("lastNameContains", "Doe"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[*].lastName").value(everyItem(is("Doe"))))
.andExpect(jsonPath("$.content.length()").value(2));
}
Тестируем PATCH-эндпоинт
Наконец, перейдем к самым интересным методам — PUT и PATCH. Их единственная разница в том, что PUT обновляет запись целиком, а PATCH лишь частично. При этом метод PATCH позволяет даже делать некоторые значения равными null
. Поэтому мы проверим именно его. PUT можно реализовать по аналогии, так как он проще и имеет меньше corner case’ов.
@PatchMapping("/{id}")
public OwnerDto patch(@PathVariable Integer id,
@RequestBody JsonNode patchNode) throws IOException {
if (patchNode.get("id") == null || patchNode.get("id").asInt() != id) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Id in request body and path variable must be equal");
}
Owner owner = ownerRepository.findById(id)
.orElseThrow(() ->
new ResponseStatusException(HttpStatus.NOT_FOUND, "Entity with id %s not found".formatted(id))
);
OwnerDto ownerDto = ownerMapper.toOwnerDto(owner);
objectMapper.readerForUpdating(ownerDto)
.readValue(patchNode);
ownerMapper.updateWithNull(ownerDto, owner);
Owner resultOwner = ownerRepository.save(owner);
return ownerMapper.toOwnerDto(resultOwner);
}
Для этого теста нам тоже понадобятся входные данные, поэтому вставим строки, обращающиеся к уже использованными ранее SQL-файлам.
@Sql(scripts = "classpath:insert-owners.sql",
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD
)
@Sql(scripts = "classpath:delete-owners.sql",
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD
)
Сгенерируем уже знакомый нам JSON при помощи LLM. Потом поменяем имя на Johny
, установим адрес в null
и переименуем город в Spring Ville
. Остальные значения мы менять не хотим, поэтому просто удалим соответствующие строки из кода теста. Также укажем значение поля id
.
Останется лишь написать expect’ы. Их тоже подсказывает LLM, но за ней необходимо внимательно следить, чтобы убедиться, что все ее подсказки правильные. Также необходимо проверить, что значения в базе тоже поменялись. Окончательный вид теста для метода PATCH будет таким:
@Test
@Sql(scripts = "classpath:insert-owners.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "classpath:delete-owners.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
public void patch() throws Exception {
String patchNode = """
{
"id": 1,
"firstName": "Johny",
"address": null,
"city": "Spring Ville"
}""";
mockMvc.perform(MockMvcRequestBuilders.patch("/rest/owners/{0}", "1")
.content(patchNode)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Johny"))
.andExpect(jsonPath("$.address").value(nullValue()))
.andExpect(jsonPath("$.city").value("Spring Ville"))
.andExpect(jsonPath("$.telephone").value("5555555555"))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.lastName").value("Doe"));
Owner owner = ownerRepository.findById(1)
.orElseThrow();
assertThat(owner.getFirstName()).isEqualTo("Johny");
assertThat(owner.getAddress()).isNull();
assertThat(owner.getCity()).isEqualTo("Spring Ville");
assertThat(owner.getTelephone()).isEqualTo("5555555555");
assertThat(owner.getLastName()).isEqualTo("Doe");
}
Тестируем DELETE-эндпоинт
@DeleteMapping("/{id}")
public OwnerDto delete(@PathVariable Integer id) {
Owner owner = ownerRepository.findById(id)
.orElse(null);
if (owner != null) {
ownerRepository.delete(owner);
}
return ownerMapper.toOwnerDto(owner);
}
Генерируем тест, вставляем уже хорошо знакомые две строки для обращения к SQL-файлам, проверяем, что запись, которую мы удаляем не будет найдена. Получается вот такой довольно короткий тест:
@Test
@Sql(scripts = "classpath:insert-owners.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "classpath:delete-owners.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
public void delete() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.delete("/rest/owners/{0}", "1"))
.andExpect(status().isOk());
assertThat(ownerRepository.findById(1)).isNotPresent();
}
Решить повседневную задачу написания тестов с использованием возможностей Amplcode и подсказок от ИИ получилось довольно быстро. Осталось лишь запустить тесты и удостовериться, что все работает.

Заключение
Сегодня мы рассмотрели, как написать полноценные интеграционные тесты для REST API на Spring Boot: от генерации тестов с помощью Amplicode до автоматического запуска окружения через Docker Compose Starter, дополнив процесс возможностями LLM. В следующих материалах мы продолжим тему и покажем альтернативный подход к настройке окружения — с использованием Testcontainers.

Подписывайтесь на наши Telegram и YouTube, чтобы не пропустить новые материалы про Amplicode, Spring и связанные с ним технологии!
А если вы хотите попробовать Amplicode в действии – то можете установить его абсолютно бесплатно уже сейчас, как в IntelliJ IDEA/GigaIDE, так и в VS Code.