Современная разработка промышленных информационных систем зачастую включает разработку и поддержку интеграционных тестов. Кодовая база проекта, относящаяся к интеграционным тестам, может быть достаточно большой, как и затрачиваемое время на ее развитие.
В статье описан подход, связанный с ускорением разработки и поддержки интеграционных тестов. Для того, чтобы этого добиться, предлагается использовать единый формат тестовых данных и вывод результата в консоль. Если вас заинтересовало, как это может помочь, приглашаю прочитать данную статью.
Все примеры с кодом написаны на Java, но без использования каких-либо фреймворков и специфичных библиотек. Это сделано для того, чтобы статья была понятна читателям вне зависимости от их применяемого стека технологий.
В рамках данной статьи стоит воспринимать Java не как конкретный язык с доступными в нем средствами разработки, а как псевдо-язык с синтаксисом, максимально приближенным к Java. Это также означает, что некоторые возможности Java, доступные из стандартной библиотеки, могли быть намерено проигнорированы для того, чтобы повысить понятность кода для читателей, незнакомых с Java.
Также, чтобы повысить понятность кода, некоторые общепризнанные полезные практики по промышленной разработке могли быть намерено проигнорированы, если они по мнению автора не влияли на суть излагаемого материала.
1. Описание предметной области
Для того чтобы разобраться в сути предлагаемого подхода, рассмотрим, как могут разрабатываться тесты. Для этого далее будет определена предметная область.
Пусть есть интернет-магазин, в котором можно осуществлять заказы товаров. И для этого интернет-магазина есть следующая схема базы данных:
user - таблица пользователей
id: UUID
login: String
password: String
product - таблица продаваемых товаров
id: UUID
name: String
price: Integer
total: Integer
delivery_address - таблица адреса доставки товаров
id: UUID
country: String
city: String
street: String
order - таблица заказов
id: UUID
user_id: UUID
delivery_address_id: UUID
order_item - таблица позиций заказа
id: UUID
order_id: UUID
product_id: UUID
total: Integer
И пусть веб-сервер интернет-магазина среди прочих предоставляет следующие два REST-метода:
Создать заказ
Посмотреть информацию о заказе по его id
Далее будут рассмотрены подходы по разработке тестов для этих двух REST-методов.
2. Тесты с уникальным форматом данных
Рассмотрим первый подход, как можно разработать интеграционные тесты.
Ниже представлены упрощенные примеры с кодом вспомогательных классов, необходимых для разработки интеграционных тестов. Раздел 2.1. можно пропустить, перейдя сразу к разделу 2.2., так как по мнению автора статьи сигнатуры методов вспомогательных классов интуитивно понятны.
2.1. Примеры вспомогательных классов
publicclassDbTestClient{
publicvoidexecute(String sql){
//отправка мутирующего (insert, update или delete) запроса в СУБД
}
public List<Map<String, Object>> select(String sql) {
//отправка select запроса в СУБД//затем преобразование результата в массив содержащий строки
}
}
Листинг 2.1.1.
DbTestClient - класс, позволяющий отправлять мутирующие запросы в СУБД через метод execute. Также данный класс позволяет получать данные из БД посредством отправки select запросов через метод select.
publicclassRestTestClient{
public RestResponse send(RestRequest request){
//отправка REST запроса и получение ответа
}
}
RestTestClient - класс, позволяющий отправить REST-запросы, на которые он возвращает REST-ответы. REST-запросы представлены классом RestRequest. REST-ответы представлены классом RestResponse.
JsonUtils - вспомогательный класс, позволяющий извлекать значения из строки в JSON формате по JSONPath.
2.2. Примеры тестов
Ниже представлен тест для создания заказа.
publicclassCreateOrderTest{
private DbTestClient dbTestClient;
private RestTestClient restTestClient;
publicvoidcreateOrderTest(){
//Given// (1)
dbTestClient.execute("""
insert into product (id, name, price, total) values (
'00000003-0000-0000-0000-000000000001',
'Brush',
100,
1000
), (
'00000003-0000-0000-0000-000000000002',
'Canvas',
1000,
500
)
""");
dbTestClient.execute("""
insert into user (id, login, password) values (
'00000004-0000-0000-0000-000000000001',
'user',
'qwerty'
)
""");
//When// (2)var request = new RestRequest();
request.setUrl("http://user:qwerty@localhost:8080/orders");
request.setHttpMethod("POST");
request.setBody("""
{
"id": "00000001-0000-0000-0000-000000000001",
"items": [
{
"id": "00000002-0000-0000-0000-000000000001",
"product_id": "00000003-0000-0000-0000-000000000001",
"total": "5"
},
{
"id": "00000002-0000-0000-0000-000000000002",
"product_id": "00000003-0000-0000-0000-000000000002",
"total": "10"
}
],
"delivery_address": {
"id": "00000005-0000-0000-0000-000000000001",
"country": "Russia",
"city": "Saint-Petersburg",
"street": "Palace Square, 2"
}
}
""");
var response = restTestClient.send(request);
//Then// (3)
assertEquals(201, response.getStatusCode());
var orders = dbTestClient.select("""
select * from order
where id = '00000001-0000-0000-0000-000000000001'
""");
assertEquals(1, orders.size());
var order = orders.get(0);
assertEquals("00000001-0000-0000-0000-000000000001", order.get("id"));
assertEquals("00000004-0000-0000-0000-000000000001", order.get("user_id"));
assertEquals("00000005-0000-0000-0000-000000000001", order.get("delivery_address_id"));
var deliveryAddresses = dbTestClient.select("""
select * from delivery_address
where id = '00000005-0000-0000-0000-000000000001'
""");
assertEquals(1, deliveryAddresses.size());
var deliveryAddress = deliveryAddresses.get(0);
assertEquals("00000005-0000-0000-0000-000000000001", deliveryAddress.get("id"));
assertEquals("Russia", deliveryAddress.get("country"));
assertEquals("Saint-Petersburg", deliveryAddress.get("city"));
assertEquals("Palace Square, 2", deliveryAddress.get("street"));
var items = dbTestClient.select("""
select * from order_item
where order_id = '00000001-0000-0000-0000-000000000001'
order by product_id
""");
assertEquals(2, items.size());
var brushItem = items.get(0);
assertEquals("00000002-0000-0000-0000-000000000001", brushItem.get("id"));
assertEquals("00000001-0000-0000-0000-000000000001", brushItem.get("order_id"));
assertEquals("00000003-0000-0000-0000-000000000001", brushItem.get("product_id"));
assertEquals(5, brushItem.get("total"));
var canvasItem = items.get(1);
assertEquals("00000002-0000-0000-0000-000000000002", canvasItem.get("id"));
assertEquals("00000001-0000-0000-0000-000000000001", canvasItem.get("order_id"));
assertEquals("00000003-0000-0000-0000-000000000002", canvasItem.get("product_id"));
assertEquals(10, canvasItem.get("total"));
// (4)
}
}
Листинг 2.2.1.
Блок кода из листинга 2.2.1. между метками (1) и (2) приводит систему (в данном случае только базу данных) к тестируемому состоянию (Given). Затем блок кода между метками (2) и (3) подготавливает и отправляет REST-запрос (When). В заключении в блоке кода между метками (3) и (4) происходит проверка возвращаемого REST-ответа, а также происходит проверка состояния системы (в данном случае только состояния некоторых таблиц базы данных) после выполнения запроса (Then).
Листинг 2.2.2. имеет аналогичную структуру. Между метками (1) и (2) перевод системы к тестируемому состоянию (Given). Между метками (2) и (3) подготовка и отправка REST-запроса (When). Между метками (3) и (4) проверка REST-ответа (Then).
Что является принципиальным в этих двух примерах в рамках данного подхода для дальнейшего сравнения с подходом, описанным в будущих разделах? Принципиальным является то, что для каждого блока кода используется свой уникальный формат для создания и проверки данных:
Для приведения системы к тестируемому состоянию данные создаются с помощью insert инструкций в терминах SQL.
Для подготовки REST-запроса данные создаются в JSON формате.
Для проверки состояния базы данных после выполнения select запроса используются стандартные коллекции (динамический массив и ассоциативный массив) языка Java. Они используются в комбинации с assertEquals.
Для проверки REST-ответа используется инструмент для извлечения данных из JSON с помощью JSONPath и затем применяется assertEquals для извлеченного значения для каждого поля.
Проблема здесь в том, что при последовательной разработке этих тестов практически нет возможности переиспользовать какие-либо части.
Ниже графически представлен подход по разработке этих двух тестов:
Рис. 2.2.3.
Разные цвета соответствуют разным форматом подготовки или проверки данных.
3. Тесты с единым форматом данных
Рассмотрим альтернативный подход к разработке тестов. Как уже, скорее всего, стало понятно, подход будет заключаться в использовании единого формата данных. Поэтому в начале рассмотрим вспомогательные классы, позволяющие достичь этой цели.
3.1. Примеры вспомогательных классов
В качестве единого формата данных будет использован JSON формат. Но при этом необязательно использовать именно JSON. Главное, чтобы формат был один.
publicclassDbTestClient{
//rows - строки для вставки в JSON форматеpublicvoidinsert(String tableName, String rows){
//преобразование rows из JSON формата в insert запросы в sql формате//затем отправка insert запросов в СУБД для их выполнения
}
public String select(String sql){
//отправка select запроса в СУБД//затем преобразование результата от СУБД в JSON формат
}
}
Листинг 3.1.1.
DbTestClient - класс, взаимодействующий с СУБД. Но в отличие от класса из Листинга 2.1.1.:
метод insert осуществляет только вставку и принимает описание вставляемых строк в JSON формате;
метод select возвращает результат выполнения select запроса в JSON формате.
Также в тестах будет использован RestTestClient. Для него не будет приведено примеров кода, так как он и его дополнительные классы ничем не отличаются от листингов: 2.1.2., 2.1.3., 2.1.4. Данные классы остаются без изменений, так как они уже работают с JSON форматом.
Ниже представлен вспомогательный класс JsonAsserter.
publicclassJsonAsserter{
// expected - ожидаемая строка в JSON формате, относительно которой происходит проверка.// actual - актуальное значение строки в JSON формате, которое проверяется относительно соответствия с expected.publicstaticvoidassertEquals(String expected, String actual){
// сравнение expected и actual в JSON формате
}
}
Листинг 3.1.2.
JsonAsserter - класс, позволяющий сравнивать на равенство две строки, содержащие данные в JSON формате. Важным требованием к методу JsonAsserter#assertEquals является то, что он должен сообщать в формате JSONPath, по какому пути в JSON происходит расхождение по данным.
3.2. Примеры тестов
В данном разделе описан подход пошаговой разработки тестов. Ниже представлен первый шаг разработки теста для REST-метода по созданию заказа.
publicclassCreateOrderTest{
private DbTestClient dbTestClient;
private RestTestClient restTestClient;
publicvoidcreateOrderTest(){
//Given// (1)
dbTestClient.insert("product", """
[
{
"id": "00000003-0000-0000-0000-000000000001",
"name": "Brush",
"price": "100"
"total": 1000
},
{
"id": "00000003-0000-0000-0000-000000000002",
"name": "Canvas",
"price": "1000"
"total": 500
}
]
""");
dbTestClient.insert("user", """
[
{
"id": "00000004-0000-0000-0000-000000000001",
"login": "user",
"password": "qwerty"
}
]
""");
//When// (2)var request = new RestRequest();
request.setUrl("http://user:qwerty@localhost:8080/orders");
request.setHttpMethod("POST");
request.setBody("""
{
"id": "00000001-0000-0000-0000-000000000001",
"items": [
{
"id": "00000002-0000-0000-0000-000000000001",
"product_id": "00000003-0000-0000-0000-000000000001",
"total": "5"
},
{
"id": "00000002-0000-0000-0000-000000000002",
"product_id": "00000003-0000-0000-0000-000000000002",
"total": "10"
}
],
"delivery_address": {
"id": "00000005-0000-0000-0000-000000000001",
"country": "Russia",
"city": "Saint-Petersburg",
"street": "Palace Square, 2"
}
}
""");
var response = restTestClient.send(request);
//Then// (3)
assertEquals(201, response.getStatusCode());
var order = dbTestClient.select("""
select * from order
where id = '00000001-0000-0000-0000-000000000001'
""");
// (4)
System.out.println("Order:\n" + order);
var deliveryAddress = dbTestClient.select("""
select * from delivery_address
where id = '00000005-0000-0000-0000-000000000001'
""");
// (5)
System.out.println("Delivery address:\n" + deliveryAddress);
var items = dbTestClient.select("""
select * from order_item
where order_id = '00000001-0000-0000-0000-000000000001'
order by product_id
""");
// (6)
System.out.println("Items:\n" + items);
}
}
Листинг 3.2.1.
Блок кода из листинга 3.2.1. между метками (1) и (2) приводит систему к тестируемому состоянию, заполняя базу данных (Given). Только теперь для этого данные отправляются в JSON формате. Затем блок кода между метками (2) и (3) подготавливает и отправляет REST-запрос (When). До этого момента не было ничего необычного. Но начиная с метки (4), будет описана первая полезная возможность, позволяющая значительно упростить разработку тестов.
Вместо того чтобы написать проверку состояния базы данных после выполнения REST-запроса, состояние отдельных таблиц базы данных выводится в консоль в строках кода после меток (4), (5) и (6).
Разработка текущего теста еще не завершена. Следующим этапом будет запуск теста, который выведет полезную информацию в консоль запущенного процесса.
Ниже представлен вывод в консоль после запуска теста.
Следующим этапом необходимо проверить вывод в консоли. После проверки необходимо скопировать и вставить строковые литералы в разрабатываемый тест в метод JsonAsserter.assertEquals(String, String), внеся при этом необходимые исправления в скопированный текст, если он содержит ошибки. В данном примере в выводе в консоль содержится некорректное значение для поля street.
Результат этих действий представлен ниже.
publicclassCreateOrderTest{
private DbTestClient dbTestClient;
private RestTestClient restTestClient;
publicvoidcreateOrderTest(){
//Given// (1)//...//When// (2)// ...var response = restTestClient.send(request);
//Then // (3)
assertEquals(201, response.getStatusCode());
var order = dbTestClient.select("""
select * from order
where id = '00000001-0000-0000-0000-000000000001'
""");
System.out.println("Order:\n" + order);
// (4)
JsonAsserter.assertEquals(
"""
[
{
"id": "00000001-0000-0000-0000-000000000001",
"user_id": "00000004-0000-0000-0000-000000000001",
"delivery_address_id": "00000005-0000-0000-0000-000000000001"
}
]
""",
order
);
var deliveryAddress = dbTestClient.select("""
select * from delivery_address
where id = '00000005-0000-0000-0000-000000000001'
""");
System.out.println("Delivery address:\n" + deliveryAddress);
// (5)
JsonAsserter.assertEquals(
"""
[
{
"id": "00000005-0000-0000-0000-000000000001",
"country": "Russia",
"city": "Saint-Petersburg",
"street": "Palace Square, 2"
}
]
""",
deliveryAddress
);
var items = dbTestClient.select("""
select * from order_item
where order_id = '00000001-0000-0000-0000-000000000001'
order by product_id
""");
System.out.println("Items:\n" + items);
// (6)
JsonAsserter.assertEquals(
"""
[
{
"id": "00000002-0000-0000-0000-000000000001",
"order_id": "00000001-0000-0000-0000-000000000001",
"product_id": "00000003-0000-0000-0000-000000000001",
"total": "5"
},
{
"id": "00000002-0000-0000-0000-000000000002",
"order_id": "00000001-0000-0000-0000-000000000001",
"product_id": "00000003-0000-0000-0000-000000000002",
"total": "10"
}
]
""",
items
);
}
}
Листинг 3.2.3.
Код между метками (1) и (3) не изменился, поэтому он представлен в сокращенном варианте. Изменилось в тесте только то, что были добавлены вызовы метода JsonAsserter.assertEquals(String, String) в строках кода с метками (4), (5) и (6)(Then). И что самое важное, этот добавленный код в большой степени был скопирован из вывода консоли.
Если данные задаются, отправляются, принимаются, выводятся и проверяются в едином формате, то часть теста можно не писать вручную, а переиспользовать. Более того, та часть теста, которая проверяет результат выполнения, почти всегда может быть автоматически сгенерирована в выводе консоли.
Данный способ разработки тестов подходит для взаимодействия не только с базой данных, но и брокеров сообщений, моков/стабов соседних сервисов и других инфраструктурных внешних компонентов. То есть для любых инфраструктурных компонентов, если выводимые ими данные привести к единому формату (в рамках данной статьи JSON).
Важно отметить, что за счет того, что большая часть теста, отвечающая за проверку, может быть сгенерирована, то становится неважным, насколько эта часть велика. Количество трудозатрат для написания теста теперь в меньшей степени зависит от количества проверяемых данных.
Ниже графически представлен подход по разработке этого теста:
Рис. 3.2.4.
При использовании данного подхода, достаточно важно, чтобы копируемый вывод из консоли полностью проверялся и при необходимости корректировался. Если все участники разработки тестов будут придерживаться этого правила, то можно достичь хороших результатов. В противном случае, если вывод будет бездумно копироваться, можно получить тесты, которые не находят дефекты, а фиксируют поведение системы на момент написания тестов. Скорее всего, это не то, что ожидается от тестирования.
Вывод консоли может содержать ошибки. Но если он хотя бы частично является верным, то он уже может быть полезен. Ведь если ошибка только в какой-то одной части полей, то наличие готовой структуры и верной другой части полей уже значительно упрощают задачу написания теста. А если результат полностью некорректен, то нет препятствий к тому, чтобы все-таки написать все вручную.
Данный подход приносит меньше пользы при использовании его с подходом при котором тесты разрабатываются независимо от тестируемого кода, в том числе, когда тесты разрабатываются до тестируемого кода (TDD). Это следует из того, что нет возможности вывести на консоль результат выполнения теста.
Копирование вывода консоли не единственное, в чем можно получить преимущество от использования единого формата данных. Ниже представлено поэтапное написание теста для REST-метода, относящегося к получению информации о заказе.
В тесте написанном ранее, относящемся к созданию заказа (см. Листинг 3.2.3), проверяются данные созданного заказа в строках кода с метками (4), (5) и (6)(Then). Эти данные хорошо подходят в качестве начального состояния для написания теста, относящегося к получению заказа. То же самое можно сказать про Json-ы между метками (1) и (2)(Given) из Листинга 3.2.1. Их также можно использовать для написания начального состояния для теста, относящегося к получению заказа.
Поэтому вместо того, чтобы вручную создавать Json-ы из Листинга 3.2.5, их все можно скопировать из предыдущего теста (см. Листинг 3.2.1 и Листинг 3.2.3). Стоит обратить внимание, что это возможно благодаря единому формату данных.
Примечание. Копирование не единственный способ переиспользования. В статье используется копирование для наглядности примеров. Для исключения нежелательного дублирования кода следует использовать соответствующие практики.
Ниже графически представлен первый этап по разработке теста:
Рис. 3.2.6.
Далее представлен следующий этап разработки теста.
Для теста добавлен блок кода (When), отвечающий за отправку запроса. Также добавлена первая часть блока кода, включающая проверку REST-ответа. Но вместо того, чтобы написать вручную ожидаемое (expected) тело ответа, в строчке кода после метки (6) указан вывод в консоль. И по аналогии с предыдущим тестом текущий тест запускается, чтобы получить вывод в консоль.
Ниже представлен вывод в консоль после запуска теста.
Вывод результата в консоль предварительно визуально проверяется, а затем копируется и вставляется в разрабатываемый тест. После вставки в скопированный текст вносятся правки, если вывод содержал ошибки. В данном примере есть ошибка в поле city.
Ниже представлен тест после выполнения описанных действий.
Ниже графически представлен последний этап по разработке теста:
Рис. 3.2.10.
Таким образом, при написании этого теста все данные для вставки и проверки были либо переиспользованы из предыдущего теста, либо скопированы из вывода консоли. Такого результата нельзя было достичь в примерах из раздела 2. из-за уникального формата данных для каждого отдельного блока кода.
Ниже графически представлен подход по разработке всего теста:
Рис. 3.2.11.
4. Тестирование не только на равенство
При тестировании не всегда система приходит в состояние, которое можно проверить только на равенство. Могут быть поля со значениями, которые могут, например, зависеть от времени. Также могут быть поля, которые могут генерироваться случайным образом. В таких случаях обычно проверяют не на равенство, а проверяют, что значение находится в диапазоне допустимых значений, или проверяют, что значение не пустое (не null).
Поэтому для инструмента, сравнивающего две структуры данных в одном формате, важно наличие возможности включения нестрого сравнения. Чтобы, например, c актуальным (actual) результатом сравнивались на равенство только те поля, которые есть в ожидаемом (expected) результате. Остальные поля, которые нельзя проверять на равенство, можно проверить соответствующим другим инструментом.
Ниже представлен пример сигнатуры метода с возможностью выборы строго и нестрого сравнения.
publicclassJsonAsserter{
// expected - ожидаемая строка в JSON формате, относительно которой происходит проверка.// actual - актуальное значение строки в JSON формате, которое проверяется относительно соответствия с expected.// isStrict - флаг определяющий режим сравненияpublicstaticvoidassertEquals(String expected, String actual, boolean isStrict){
// сравнение expected и actual в JSON формате
}
}
Для java существует готовая библиотека для сравнения данных в JSON формате со строгим и нестрогим сравнением. См. JSONassert.
5. Заключение
Общий формат данных повышает возможности по переиспользованию тестовых данных и в значительной степени упрощает процесс написания части теста, отвечающей за проверку результата (Then), используя вывод в консоль.
Но также стоит отметить две существенные характеристики данного подхода. Во-первых, подход в меньшей степени подходит для использования с TDD, когда тесты создаются до разработки кодовой базы, которая могла бы сгенерировать результат для части (Then). Во-вторых, подход требует от участников проекта дисциплины. Участники проекта должны проверять вывод в консоль, прежде чем использовать его в качестве данных для блока (Then).
Если вы не используете TDD, а участники проекта готовы внимательно относиться к проверке генерируемого результата, то, скорее всего, вы сможете извлечь много пользы из данного подхода.
Также стоит отметить, что для использования данного подхода вам придется либо найти, либо создать инструменты, которые позволят с каждым потребителем или источником данных работать через один единый формат данных.