Как избавиться от шаблонов в интеграционных тестах с помощью DBUnit и пользовательской аннотации.
8 ноября 2022 г.Привет! Меня зовут Вячеслав Аксенов, я backend-разработчик, специализирующийся на разработке сложных backend-систем на Java и Kotlin. Также я пишу много кода для себя, который вы можете найти на моем GitHub: https://github.com/v-aksenov а>
О чем эта статья
Во время своей работы я часто вижу интеграцию службы и базы данных. А конкретно — с тестированием этой интеграции. Во время интеграционного тестирования базы данных каждый раз необходимо делать следующее:
* Установить начальное состояние таблиц * Запустить тест с конкретным запросом * Утвердить ответ * Утверждать содержимое таблиц после прохождения тестов
В этой статье я хотел бы предложить решение, которое я применил к своему последнему рабочему проекту, который оказался отличным. А это уменьшило количество шаблонов в десятки раз.
Пример экспериментального сервиса
Давайте рассмотрим пример, основанный на производственном коде. Существует веб-сервис Spring Boot, написанный на Java. Уровень контроллера основан на обычной реализации через аннотацию @RestController В качестве базы данных используется Postgresql. Для интеграции службы с базой данных используется оболочка Spring Data Jpa поверх JDBC для более быстрого взаимодействия.
Контроллер:
@PutMapping(ManagerApi.CONTACT)
@Operation(summary = "Update contact")
public ContactResponse updateContact(
@RequestBody UpsertContactRequest request,
@RequestParam UUID id
) {
return contactService.update(id, request);
}
Модели:
public record ContactResponse(
UUID id,
String name,
String phoneNumber
) {
}
public record UpsertContactRequest(
@NonNull String name,
@NonNull String phoneNumber
) {
}
Объект базы данных:
@Entity
@Table(name = "contacts", schema = "cms")
@Builder
@Setter
public class ContactEntity extends AbstractEntity {
@Id
@GeneratedValue(generator = "UUID")
@GenericGenerator(name = "UUID", strategy = "org.hibernate.id.UUIDGenerator")
@Column(updatable = false, nullable = false)
protected UUID id;
@Column
private String name;
@Column(name = "phone_number", length = 10)
private String phoneNumber;
}
Репозиторий:
@Repository
public interface ContactJpaRepository
extends JpaRepository<ContactEntity, UUID> {
}
Как мы можем это проверить?
JUnit + mockMvc + Спринт-тест
Использовать репозитории из уровня репозитория непосредственно в тестах, установить состояние базы данных в тесте, сгенерировать модель для запроса как DTO, сделать вызов через mockMvc, затем утвердить ответ и состояние базы данных с помощью JUnit.< /p>
Пример теста:
@SpringBootTest
public class SimpleContactTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ContactJpaRepository contactJpaRepository;
@Autowired
private ObjectMapper objectMapper;
@Test
@SneakyThrows
public void updateContactTest() {
var oldEntity = ContactEntity.builder()
.name("old name")
.phoneNumber("0000000000")
.build();
var savedEntity = contactJpaRepository.save(oldEntity);
var request = new CreateContactRequest("name", "1112223344");
var contentAsString = mockMvc.perform(
MockMvcRequestBuilders.put(
ManagerApi.CONTACT,, savedEntity.getId())
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.content(objectMapper.writeValueAsBytes(request)))
.andExpect(MockMvcResultMatchers.status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
var response = objectMapper.readValue(contentAsString, ContactResponse.class);
Assertions.assertEquals(request.name(), response.name());
Assertions.assertEquals(request.phoneNumber(), response.phoneNumber());
Assertions.assertNotNull(response.id());
Optional<ContactEntity> entity = contactJpaRepository.findById(response.id());
Assertions.assertTrue(entity.isPresent());
Assertions.assertEquals(response.name(), entity.get().getName());
Assertions.assertEquals(response.phoneNumber(), entity.get().getPhoneNumber());
}
}
Плюсы такого подхода: полная прозрачность происходящего, отсутствие дополнительных библиотек
Минусы: тест просто огромный. Многословие, которое растет в геометрической прогрессии, не дает возможности быстро развиваться с качественным результатом. Также необходимо следить за тем, чтобы тест после или до своей работы очищал данные из базы данных, если она статична.
Однако при всей своей простоте и ясности это чрезвычайно многословный подход. Если вы используете его в больших проектах, вы рискуете потерять много времени.
<цитата>Оптимизируйте регулярно используемую логику — это сэкономит вам огромное количество времени в будущем.
Давайте улучшим его с помощью популярной библиотеки DBUnit.
DBUnit — это платформа с открытым исходным кодом, помогающая решать такие проблемы, как заполнение баз данных и таблиц, а также сравнение таблиц и наборов данных с базой данных. Это также расширение для JUnit. В отличие от предыдущего метода - подготовку состояния БД и проверку на соответствие требуемому состоянию выполняет DBUnit.
Пример теста:
@DBUnit(
caseInsensitiveStrategy = Orthography.LOWERCASE,
batchedStatements = true,
allowEmptyFields = true,
schema = "cms"
)
@SpringBootTest
public class SimpleContactTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
@SneakyThrows
@DataSet(
cleanBefore = true,
value = "controller/contact/update.success/dataset.json"
)
@ExpectedDataSet("controller/contact/update.success/dataset-expected.json")
public void updateContactTest() {
var request = new CreateContactRequest("name", "1112223344");
var contentAsString = mockMvc.perform(
MockMvcRequestBuilders.put(
ManagerApi.CONTACT,
"7624f434-cbc5-11ec-9d64-0242ac120002")
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.content(objectMapper.writeValueAsBytes(request)))
.andExpect(MockMvcResultMatchers.status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
var response = objectMapper.readValue(contentAsString, ContactResponse.class);
Assertions.assertEquals(request.name(), response.name());
Assertions.assertEquals(request.phoneNumber(), response.phoneNumber());
Assertions.assertNotNull(response.id());
}
}
Дополнительные файлы, описывающие состояние базы данных:
## controller/contact/update.success/dataset.json
{
"contacts": [
{
"id": "7624f434-cbc5-11ec-9d64-0242ac120002",
"name": "old name",
"phone_number": "0000000000"
}
]
}
## controller/contact/update.success/dataset-expected.json
{
"contacts": [
{
"id": "7624f434-cbc5-11ec-9d64-0242ac120002",
"name": "name",
"phone_number": "1112223344"
}
]
}
Как мы видим, нам удалось полностью исключить из тестов работу со слоем контроллера и базой данных. Это значительно сокращает шаблонный код. Минусы: есть дополнительная зависимость, с которой нужно научиться работать. Сложность конфигурации библиотеки для очень настраиваемых скриптов. Добавлены дополнительные файлы конфигурации
Давайте добавим пользовательскую аннотацию, чтобы указать запрос/ответ
Чтобы отказаться от описания моделей в java-коде для запросов и проверки ответов, вы можете написать свою аннотацию, которая будет использовать уже описанные модели из файла и использовать аннотацию @ParametrizedTest.
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD, })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ArgumentsSource(JsonFileSourceProvider.class)
public @interface JsonFileSource {
String file() default "";
String expectFile() default "";
}
Класс, реализующий чтение из файла:
public class JsonFileSourceProvider
implements AnnotationConsumer<JsonFileSource>, ArgumentsProvider {
private final List<String> resources = new ArrayList<>();
@Override
public void accept(JsonFileSource jsonFileSource) {
addResource(jsonFileSource.file());
addResource(jsonFileSource.expectFile());
}
@Override
public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
return Stream.of(resources)
.map(
r -> r.stream()
.map(this::getJsonResource)
.toArray()
)
.map(Arguments::of);
}
private void addResource(String resource) {
if (!resource.isEmpty()) {
this.resources.add(resource);
}
}
private String getJsonResource(String file) {
try {
return new String(
Files.readAllBytes(
ResourceUtils.getFile(String.format("classpath:%s", file)).toPath()
)
);
} catch (final IOException err) {
return null;
}
}
}
С этой аннотацией тест становится намного проще:
@DBUnit(
caseInsensitiveStrategy = Orthography.LOWERCASE,
batchedStatements = true,
allowEmptyFields = true,
schema = "cms"
)
@SpringBootTest
public class SimpleContactTest {
@Autowired
private MockMvc mockMvc;
@SneakyThrows
@ParameterizedTest
@DataSet(
cleanBefore = true,
value = "controller/contact/update.success/dataset.json"
)
@ExpectedDataSet("controller/contact/update.success/dataset-expected.json")
@JsonFileSource(
file = "controller/contact/update.success/request.json",
expectFile = "controller/contact/update.success/response.json"
)
public void updateContactTest(String request, String response) {
mockMvc.perform(
MockMvcRequestBuilders.put(
ManagerApi.CONTACT, "7624f434-cbc5-11ec-9d64-0242ac120002")
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.content(request))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().json(response));
}
}
А все описание моделей запроса и ответа хранится в файлах:
## controller/contact/update.success/request.json
{
"name": "old name",
"phone_number": "0000000000"
}
## controller/contact/update.success/response.json
{
"name": "name",
"phone_number": "1112223344"
}
Преимущества такого подхода трудно переоценить. Мы видим практически полное исключение шаблона из тела теста. Это становится особенно заметно на производственном проекте, где количество тестов исчисляется сотнями. Тест стал как минимум в три раза меньше. Он также становится хорошо структурированным.
Минусы: требуется написать аннотацию и класс для чтения файлов. А также сами файлы нужно хранить в ресурсах проекта и хорошо структурировать.
Вывод:
Таким образом, за счет использования библиотеки DBUnit, а также добавления собственной аннотации для чтения моделей из файлов ресурсов нам удалось сократить объем тела теста более чем в три раза. Помимо файлов, описывающих запросы, ответы службы и состояние базы данных, они становятся очень наглядными и удобными для навигации.
Следующим шагом по сокращению шаблона может быть выделение mockMvc в отдельный абстрактный класс. Но это другая история. Надеюсь, советы из этой статьи будут вам полезны и вдохновят вас на улучшение условий написания интеграционных тестов в ваших больших и малых приложениях Spring. Спасибо за внимание!
Оригинал