Перейти к основному содержимому

Руководство по библиотеке ModelAssert для JSON

· 11 мин. чтения

1. Обзор

При написании автоматических тестов для программного обеспечения, использующего JSON, нам часто требуется сравнить данные JSON с некоторым ожидаемым значением.

В некоторых случаях мы можем обрабатывать фактический и ожидаемый JSON как строки и выполнять сравнение строк, но это имеет много ограничений.

В этом руководстве мы рассмотрим, как писать утверждения и сравнивать значения JSON с помощью ModelAssert . Мы увидим, как создавать утверждения для отдельных значений в документе JSON и как сравнивать документы. Мы также рассмотрим, как обрабатывать поля, точные значения которых невозможно предсказать, например даты или идентификаторы GUID.

2. Начало работы

ModelAssert — это библиотека подтверждения данных с синтаксисом, подобным AssertJ, и функциями, сравнимыми с JSONAssert . Он основан на Джексоне для анализа JSON и использует выражения JSON Pointer для описания путей к полям в документе.

Давайте начнем с написания нескольких простых утверждений для этого JSON:

{
"name": "ForEach",
"isOnline": true,
"topics": [ "Java", "Spring", "Kotlin", "Scala", "Linux" ]
}

2.1. Зависимость

Для начала добавим ModelAssert в наш pom.xml :

<dependency>
<groupId>uk.org.webcompere</groupId>
<artifactId>model-assert</artifactId>
<version>1.0.0</version>
<scope>test</scope>
</dependency>

2.2. Утверждение поля в объекте JSON

Давайте представим, что пример JSON был возвращен нам как String, и мы хотим проверить, что поле имени равно ForEach :

assertJson(jsonString)
.at("/name").isText("ForEach");

Метод assertJson будет считывать JSON из различных источников, включая String , File , Path и JsonNode Джексона . Возвращаемый объект является утверждением, после которого мы можем использовать свободный DSL (предметно-ориентированный язык) для добавления условий.

Метод at описывает место в документе, где мы хотим сделать утверждение поля. Затем isText указывает, что мы ожидаем текстовый узел со значением ForEach .

Мы можем утвердить путь в массиве тем , используя немного более длинное выражение указателя JSON:

assertJson(jsonString)
.at("/topics/1").isText("Spring");

Хотя мы можем писать утверждения полей одно за другим, мы также можем объединить их в одно утверждение :

assertJson(jsonString)
.at("/name").isText("ForEach")
.at("/topics/1").isText("Spring");

2.3. Почему сравнение строк не работает

Часто нам нужно сравнить целый документ JSON с другим. Сравнение строк, хотя и возможное в некоторых случаях, часто прерывается ненужными проблемами форматирования JSON :

String expected = loadFile(EXPECTED_JSON_PATH);
assertThat(jsonString)
.isEqualTo(expected);

Такое сообщение об ошибке является распространенным:

org.opentest4j.AssertionFailedError: 
expected: "{
"name": "ForEach",
"isOnline": true,
"topics": [ "Java", "Spring", "Kotlin", "Scala", "Linux" ]
}"
but was : "{"name": "ForEach","isOnline": true,"topics": [ "Java", "Spring", "Kotlin", "Scala", "Linux" ]}"

2.4. Семантическое сравнение деревьев

Чтобы выполнить сравнение всего документа, мы можем использовать isEqualTo :

assertJson(jsonString)
.isEqualTo(EXPECTED_JSON_PATH);

В этом случае строка фактического JSON загружается с помощью assertJson , а ожидаемый документ JSON — файл, описанный путем — загружается внутри isEqualTo . Сравнение производится на основе данных. ``

2.5. Различные форматы

ModelAssert также поддерживает объекты Java, которые Джексон может преобразовать в JsonNode , а также формат yaml .

Map<String, String> map = new HashMap<>();
map.put("name", "foreach");

assertJson(map)
.isEqualToYaml("name: foreach");

Для обработки yaml используется метод isEqualToYaml для указания формата строки или файла. Для этого требуется assertYaml , если источником является yaml :

assertYaml("name: foreach")
.isEqualTo(map);

3. Полевые утверждения

До сих пор мы видели некоторые основные утверждения. Давайте рассмотрим больше DSL.

3.1. Утверждение в любом узле

DSL для ModelAssert позволяет добавлять почти все возможные условия к любому узлу в дереве. Это связано с тем, что деревья JSON могут содержать узлы любого типа на любом уровне.

Давайте посмотрим на некоторые утверждения, которые мы могли бы добавить к корневому узлу нашего примера JSON:

assertJson(jsonString)
.isNotNull()
.isNotNumber()
.isObject()
.containsKey("name");

Поскольку объект утверждения имеет эти методы, доступные в его интерфейсе, наша IDE предложит различные утверждения, которые мы можем добавить, как только мы нажмем «.» ключ.

В этом примере мы добавили множество ненужных условий, так как последнее условие уже подразумевает ненулевой объект.

Чаще всего мы используем выражения JSON Pointer из корневого узла для выполнения утверждений на узлах ниже по дереву:

assertJson(jsonString)
.at("/topics").hasSize(5);

Это утверждение использует hasSize для проверки того, что массив в поле темы состоит из пяти элементов. Метод hasSize работает с объектами, массивами и строками. Размер объекта — это количество его ключей, размер строки — это количество символов, а размер массива — это количество его элементов.

Большинство утверждений, которые нам нужно сделать для полей, зависят от точного типа поля. Мы можем использовать методы number , array , text , booleanNode и object , чтобы перейти к более конкретному подмножеству утверждений, когда мы пытаемся написать утверждения для определенного типа. Это необязательно, но может быть более выразительным:

assertJson(jsonString)
.at("/isOnline").booleanNode().isTrue();

Когда мы нажимаем «.» key в нашей IDE после booleanNode , мы видим только параметры автозаполнения для логических узлов.

3.2. Текстовый узел

Когда мы утверждаем текстовые узлы, мы можем использовать isText для сравнения с использованием точного значения. В качестве альтернативы мы можем использовать textContains для утверждения подстроки:

assertJson(jsonString)
.at("/name").textContains("ael");

Мы также можем использовать регулярные выражения через совпадения :

assertJson(jsonString)
.at("/name").matches("[A-Z].+");

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

3.3. Номер узла

Для числовых узлов DSL предоставляет несколько полезных числовых сравнений:

assertJson("{count: 12}")
.at("/count").isBetween(1, 25);

Мы также можем указать ожидаемый числовой тип Java:

assertJson("{height: 6.3}")
.at("/height").isGreaterThanDouble(6.0);

Метод isEqualTo зарезервирован для сопоставления всего дерева, поэтому для сравнения числового равенства мы используем isNumberEqualTo :

assertJson("{height: 6.3}")
.at("/height").isNumberEqualTo(6.3);

3.4. Узел массива

Мы можем проверить содержимое массива с помощью isArrayContaining :

assertJson(jsonString)
.at("/topics").isArrayContaining("Scala", "Spring");

Это проверяет наличие заданных значений и позволяет фактическому массиву содержать дополнительные элементы. Если мы хотим установить более точное совпадение, мы можем использовать isArrayContainingExactlyInAnyOrder :

assertJson(jsonString)
.at("/topics")
.isArrayContainingExactlyInAnyOrder("Scala", "Spring", "Java", "Linux", "Kotlin");

Мы также можем сделать так, чтобы это требовало точного порядка:

assertJson(ACTUAL_JSON)
.at("/topics")
.isArrayContainingExactly("Java", "Spring", "Kotlin", "Scala", "Linux");

Это хороший способ проверки содержимого массивов, содержащих примитивные значения. Если массив содержит объекты, вместо этого мы можем использовать isEqualTo .

4. Сопоставление всего дерева

Хотя мы можем создавать утверждения с несколькими специфическими для поля условиями, чтобы проверить, что находится в документе JSON, нам часто нужно сравнить весь документ с другим.

Метод isEqualTo (или isNotEqualTo ) используется для сравнения всего дерева. Это можно комбинировать с at , чтобы перейти к поддереву фактического перед выполнением сравнения:

assertJson(jsonString)
.at("/topics")
.isEqualTo("[ \"Java\", \"Spring\", \"Kotlin\", \"Scala\", \"Linux\" ]");

Сравнение всего дерева может привести к проблемам, если JSON содержит данные, которые:

  • то же самое, но в другом порядке
  • состоит из некоторых значений, которые невозможно предсказать

Метод where используется для настройки следующей операции isEqualTo , чтобы обойти это.

4.1. Добавить ограничение порядка ключей

Давайте посмотрим на два документа JSON, которые кажутся одинаковыми:

String actualJson = "{a:{d:3, c:2, b:1}}";
String expectedJson = "{a:{b:1, c:2, d:3}}";

Следует отметить, что это не строго формат JSON. ModelAssert позволяет нам использовать нотацию JavaScript для JSON , а также проводной формат, который обычно заключает имена полей в кавычки.

Эти два документа имеют точно такие же ключи под «a» , но в другом порядке. Их утверждение не удастся, так как ModelAssert по умолчанию использует строгий порядок ключей .

Мы можем ослабить правило порядка ключей, добавив конфигурацию where :

assertJson(actualJson)
.where().keysInAnyOrder()
.isEqualTo(expectedJson);

Это позволяет любому объекту в дереве иметь порядок ключей, отличный от ожидаемого документа, и при этом совпадать.

Мы можем локализовать это правило для определенного пути:

assertJson(actualJson)
.where()
.at("/a").keysInAnyOrder()
.isEqualTo(expectedJson);

Это ограничивает keysInAnyOrder только полем «a» в корневом объекте.

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

4.2. Ослабление ограничений массива

Если у нас есть массивы, в которых порядок значений может варьироваться, то мы можем ослабить ограничение порядка массива для всего сравнения:

String actualJson = "{a:[1, 2, 3, 4, 5]}";
String expectedJson = "{a:[5, 4, 3, 2, 1]}";

assertJson(actualJson)
.where().arrayInAnyOrder()
.isEqualTo(expectedJson);

Или мы можем ограничить это ограничение путем, как мы сделали с keysInAnyOrder .

4.3. Игнорирование путей

Возможно, наш реальный документ содержит какие-то поля, которые либо неинтересны, либо непредсказуемы. Мы можем добавить правило для игнорирования этого пути:

String actualJson = "{user:{name: \"ForEach\", url:\"http://www.foreach.com\"}}";
String expectedJson = "{user:{name: \"ForEach\"}}";

assertJson(actualJson)
.where()
.at("/user/url").isIgnored()
.isEqualTo(expectedJson);

Следует отметить, что путь, который мы выражаем, всегда относится к указателю JSON внутри фактического файла .

Дополнительное поле «url» в фактическом теперь игнорируется.

4.4. Игнорировать любой GUID

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

Синтаксис пути позволяет нам описать, где применяются наши правила, используя подстановочные знаки. Когда мы добавляем условие at или путь к тому, где нашего сравнения, мы также можем предоставить любое из утверждений поля выше, чтобы использовать вместо параллельного сравнения с ожидаемым документом.

Допустим, у нас было поле id , которое появлялось в нескольких местах в нашем документе и представляло собой GUID, который мы не могли предсказать.

Мы могли бы игнорировать это поле с правилом пути:

String actualJson = "{user:{credentials:[" +
"{id:\"a7dc2567-3340-4a3b-b1ab-9ce1778f265d\",role:\"Admin\"}," +
"{id:\"09da84ba-19c2-4674-974f-fd5afff3a0e5\",role:\"Sales\"}]}}";
String expectedJson = "{user:{credentials:" +
"[{id:\"???\",role:\"Admin\"}," +
"{id:\"???\",role:\"Sales\"}]}}";

assertJson(actualJson)
.where()
.path("user","credentials", ANY, "id").isIgnored()
.isEqualTo(expectedJson);

Здесь наше ожидаемое значение может иметь что угодно для поля id , потому что мы просто проигнорировали любое поле, указатель JSON которого начинается с «/user/credentials» , затем имеет один узел (индекс массива) и заканчивается на «/id» .

4.5. Совпадение с любым GUID

Одним из вариантов является игнорирование полей, которые мы не можем предсказать. Вместо этого лучше сопоставить эти узлы по типу и, возможно, также по какому-то другому условию, которому они должны соответствовать. Давайте переключимся на принудительное соответствие этих GUID шаблону GUID и позволим узлу id появляться в любом листовом узле дерева:

assertJson(actualJson)
.where()
.path(ANY_SUBTREE, "id").matches(GUID_PATTERN)
.isEqualTo(expectedJson);

Подстановочный знак ANY_SUBTREE соответствует любому количеству узлов между частями выражения пути. GUID_PATTERN происходит из класса ModelAssert Patterns , который содержит некоторые общие регулярные выражения для сопоставления таких вещей, как числа и штампы даты.

4.6. Настройка isEqualTo

Комбинация where с выражением path или at позволяет нам переопределить сравнения в любом месте дерева. Мы либо добавляем встроенные правила для сопоставления объекта или массива, либо указываем конкретные альтернативные утверждения для использования для отдельных или классов путей в рамках сравнения.

Там, где у нас есть общая конфигурация, повторно используемая в различных сравнениях, мы можем извлечь ее в метод:

private static <T> WhereDsl<T> idsAreGuids(WhereDsl<T> where) {
return where.path(ANY_SUBTREE, "id").matches(GUID_PATTERN);
}

Затем мы можем добавить эту конфигурацию к конкретному утверждению с помощью configureBy :

assertJson(actualJson)
.where()
.configuredBy(where -> idsAreGuids(where))
.isEqualTo(expectedJson);

5. Совместимость с другими библиотеками

ModelAssert был создан для взаимодействия. До сих пор мы видели утверждения в стиле AssertJ. У них может быть несколько условий, и они не будут выполнены при первом невыполненном условии.

Однако иногда нам нужно создать объект сопоставления для использования с другими типами тестов.

5.1. Хэмкрест Матчер

Hamcrest — это основная вспомогательная библиотека утверждений, поддерживаемая многими инструментами. Мы можем использовать DSL ModelAssert для создания сопоставителя Hamcrest :

Matcher<String> matcher = json()
.at("/name").hasValue("ForEach");

Метод json используется для описания сопоставителя, который будет принимать строку с данными JSON. Мы также можем использовать jsonFile для создания Matcher , который ожидает утверждения содержимого File . Класс JsonAssertions в ModelAssert содержит несколько методов построения, подобных этому, чтобы начать создание сопоставителя Hamcrest.

DSL для выражения сравнения идентичен assertJson , но сравнение не выполняется до тех пор, пока что-то не использует сопоставитель.

Поэтому мы можем использовать ModelAssert с MatcherAssert от Hamcrest :

MatcherAssert.assertThat(jsonString, json()
.at("/name").hasValue("ForEach")
.at("/topics/1").isText("Spring"));

5.2. Использование с Spring Mock MVC

При использовании проверки тела ответа в Spring Mock MVC мы можем использовать встроенные в Spring утверждения jsonPath . Однако Spring также позволяет нам использовать сопоставители Hamcrest для утверждения строки , возвращаемой в качестве содержимого ответа. Это означает, что мы можем выполнять сложные утверждения контента, используя ModelAssert.

5.3. Используйте с Мокито

Mockito уже совместим с Hamcrest. Однако ModelAssert также предоставляет собственный ArgumentMatcher . Это можно использовать как для настройки поведения заглушек, так и для проверки обращений к ним:

public interface DataService {
boolean isUserLoggedIn(String userDetails);
}

@Mock
private DataService mockDataService;

@Test
void givenUserIsOnline_thenIsLoggedIn() {
given(mockDataService.isUserLoggedIn(argThat(json()
.at("/isOnline").isTrue()
.toArgumentMatcher())))
.willReturn(true);

assertThat(mockDataService.isUserLoggedIn(jsonString))
.isTrue();

verify(mockDataService)
.isUserLoggedIn(argThat(json()
.at("/name").isText("ForEach")
.toArgumentMatcher()));
}

В этом примере Mockito argThat используется как при настройке макета, так и при проверке . Внутри этого мы используем построитель стиля Hamcrest для сопоставителя — json . Затем мы добавляем к нему условия, конвертируя в Mockito ArgumentMatcher в конце с помощью toArgumentMatcher .

6. Заключение

В этой статье мы рассмотрели необходимость семантического сравнения JSON в наших тестах.

Мы увидели, как ModelAssert можно использовать для создания утверждения для отдельных узлов в документе JSON, а также для целых деревьев. Затем мы увидели, как настроить сравнение деревьев, чтобы учесть непредсказуемые или несущественные различия.

Наконец, мы увидели, как использовать ModelAssert с Hamcrest и другими библиотеками.

Как всегда, пример кода из этого руководства доступен на GitHub .