Merge branch 'feature/json-assert-compare-arrays' of https://github.com/sebastian-mrozek/ebean into sebastian-mrozek-feature/json-assert-compare-arrays

This commit is contained in:
Robin Bygrave
2021-03-16 09:14:06 +13:00
7 changed files with 264 additions and 47 deletions
@@ -0,0 +1,38 @@
package io.ebean.test;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class CompareResult {
private final boolean applicable;
private final List<String> errors;
public static final CompareResult NO_ERRORS = new CompareResult(true, Collections.emptyList());
public static final CompareResult NOT_APPLICABLE = new CompareResult(false, Collections.emptyList());
public static CompareResult error(String error) {
return new CompareResult(true, Collections.singletonList(error));
}
public static CompareResult errors(List<String> errors) {
return new CompareResult(true, errors);
}
private CompareResult(boolean applicable, List<String> errors) {
this.applicable = applicable;
this.errors = new ArrayList<>(errors);
}
public boolean isApplicable() {
return applicable;
}
public boolean hasErrors() {
return !errors.isEmpty();
}
public List<String> getErrors() {
return errors;
}
}
@@ -3,28 +3,26 @@ package io.ebean.test;
import com.fasterxml.jackson.databind.JsonNode;
import org.assertj.core.api.Assertions;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.Map;
import java.util.Stack;
import java.util.*;
import java.util.stream.Collectors;
/**
* Perform traversal of JsonNodes comparing against an expected JsonNode that
* typically contains a subset of the data (typically excludes any generated properties
* like when modified timestamps etc).
*/
class JsonAssertContains {
public class JsonAssertContains {
private final Stack<String> path = new Stack<>();
private final LinkedList<String> errors = new LinkedList<>();
static void assertContains(JsonNode actualJsonNode, JsonNode expectedJsonNode) {
new JsonAssertContains().contains(actualJsonNode, expectedJsonNode);
}
private void contains(JsonNode actualJsonNode, JsonNode expectedJsonNode) {
checkRecursive(null, actualJsonNode, expectedJsonNode);
if (!errors.isEmpty()) {
CompareResult result = checkRecursive(null, actualJsonNode, expectedJsonNode);
if (result.hasErrors()) {
List<String> errors = result.getErrors();
String errorsString = String.join("\n", errors);
errorsString += "\nExpected JSON fields: " + expectedJsonNode;
errorsString += "\nActual JSON: " + actualJsonNode;
@@ -32,55 +30,128 @@ class JsonAssertContains {
}
}
private void checkRecursive(String name, JsonNode actualJsonNode, JsonNode expectedJsonNode) {
private CompareResult checkRecursive(String name, JsonNode actualJsonNode, JsonNode expectedJsonNode) {
if (name != null) {
path.push(name);
}
if (checkNull(actualJsonNode, expectedJsonNode)) {
if (checkType(actualJsonNode, expectedJsonNode)) {
if (checkArray(actualJsonNode, expectedJsonNode)) {
if (checkObject(actualJsonNode, expectedJsonNode)) {
checkValue(actualJsonNode, expectedJsonNode);
}
}
}
CompareResult result = checkNull(actualJsonNode, expectedJsonNode);
if (result.isApplicable()) {
return pop(name, result);
}
result = checkType(actualJsonNode, expectedJsonNode);
if (result.isApplicable()) {
return pop(name, result);
}
result = checkArray(actualJsonNode, expectedJsonNode);
if (result.isApplicable()) {
return pop(name, result);
}
result = checkObject(actualJsonNode, expectedJsonNode);
if (result.isApplicable()) {
return pop(name, result);
}
result = checkValue(actualJsonNode, expectedJsonNode);
if (result.isApplicable()) {
return pop(name, result);
}
return CompareResult.NOT_APPLICABLE;
}
private CompareResult pop(String name, CompareResult result) {
if (name != null) {
path.pop();
}
return result;
}
private boolean checkNull(JsonNode actualJsonNode, JsonNode expectedJsonNode) {
private CompareResult checkNull(JsonNode actualJsonNode, JsonNode expectedJsonNode) {
if (actualJsonNode == null) {
errors.add(String.format("Expected field '%s' to be '%s' but was null", path(), expectedJsonNode));
return false;
return CompareResult.error(String.format("Expected field '%s' to be '%s' but was null", path(), expectedJsonNode));
}
return true;
return CompareResult.NOT_APPLICABLE;
}
private boolean checkType(JsonNode actualJsonNode, JsonNode expectedJsonNode) {
private CompareResult checkType(JsonNode actualJsonNode, JsonNode expectedJsonNode) {
if (!expectedJsonNode.getNodeType().equals(actualJsonNode.getNodeType())) {
errors.add(String.format("Expected field '%s' to be of type '%s' but was '%s'", path(), expectedJsonNode.getNodeType(), actualJsonNode.getNodeType()));
return false;
return CompareResult.error(String.format("Expected field '%s' to be of type '%s' but was '%s'", path(), expectedJsonNode.getNodeType(), actualJsonNode.getNodeType()));
}
return true;
return CompareResult.NOT_APPLICABLE;
}
private boolean checkArray(JsonNode actualJsonNode, JsonNode expectedJsonNode) {
private CompareResult checkArray(JsonNode actualJsonNode, JsonNode expectedJsonNode) {
if (!expectedJsonNode.isArray()) {
return true;
return CompareResult.NOT_APPLICABLE;
}
for (int i = 0; i < expectedJsonNode.size(); i++) {
checkRecursive("[" + i + "]", actualJsonNode.get(i), expectedJsonNode.get(i));
Map<Integer, Set<Integer>> matchingIndexes = findMatchingIndexes(actualJsonNode, expectedJsonNode);
List<Integer> unmatchedIndexes = listUnmatchedIndexes(expectedJsonNode.size(), matchingIndexes);
List<Map.Entry<Integer, Set<Integer>>> remainingEntries = removeMultipleMatches(matchingIndexes);
if (!remainingEntries.isEmpty()) {
unmatchedIndexes.addAll(remainingEntries.stream().map(Map.Entry::getKey).collect(Collectors.toList()));
}
// do not continue (object or scalar type check)
return false;
List<String> errors = unmatchedIndexes.stream()
.map(index -> String.format("Unable to match expected element '%s[%d]' in the actual array", path(), index))
.collect(Collectors.toList());
return CompareResult.errors(errors);
}
private boolean checkObject(JsonNode actualJsonNode, JsonNode expectedJsonNode) {
if (!expectedJsonNode.isObject()) {
return true;
private List<Integer> listUnmatchedIndexes(int size, Map<Integer, Set<Integer>> matchingIndexes) {
List<Integer> unmatched = new LinkedList<>();
for (int i = 0; i < size; i++) {
if (!matchingIndexes.containsKey(i)) {
unmatched.add(i);
}
}
return unmatched;
}
private List<Map.Entry<Integer, Set<Integer>>> removeMultipleMatches(Map<Integer, Set<Integer>> matchingIndexes) {
List<Map.Entry<Integer, Set<Integer>>> entries = new ArrayList<>(matchingIndexes.entrySet());
entries.sort(Comparator.comparingInt(entry -> entry.getValue().size()));
ListIterator<Map.Entry<Integer, Set<Integer>>> iterator = entries.listIterator();
while (iterator.hasNext()) {
Map.Entry<Integer, Set<Integer>> next = iterator.next();
if (!next.getValue().isEmpty()) {
iterator.remove();
Integer aMatchingIndex = next.getValue().stream().findFirst().get();
removeAllMatchingIndexesOf(aMatchingIndex, entries);
}
}
return entries;
}
private void removeAllMatchingIndexesOf(Integer aMatchingIndex, List<Map.Entry<Integer, Set<Integer>>> matchingIndexes) {
matchingIndexes.forEach(entry -> entry.getValue().remove(aMatchingIndex));
}
private Map<Integer, Set<Integer>> findMatchingIndexes(JsonNode actualJsonNode, JsonNode expectedJsonNode) {
Map<Integer, Set<Integer>> matchingElementsIndexes = new HashMap<>();
for (int e = 0; e < expectedJsonNode.size(); e++) {
for (int a = 0; a < actualJsonNode.size(); a++) {
CompareResult result = checkRecursive("[" + e + "]", actualJsonNode.get(a), expectedJsonNode.get(e));
if (result.isApplicable() && !result.hasErrors()) {
matchingElementsIndexes.computeIfAbsent(e, key -> new HashSet<>()).add(a);
}
}
}
return matchingElementsIndexes;
}
private CompareResult checkObject(JsonNode actualJsonNode, JsonNode expectedJsonNode) {
if (!expectedJsonNode.isObject()) {
return CompareResult.NOT_APPLICABLE;
}
List<String> errors = new LinkedList<>();
Iterator<Map.Entry<String, JsonNode>> expectedFields = expectedJsonNode.fields();
while (expectedFields.hasNext()) {
Map.Entry<String, JsonNode> expectedField = expectedFields.next();
@@ -89,17 +160,18 @@ class JsonAssertContains {
if (actualNode == null) {
errors.add(String.format("Expected field '%s' to be present", path(expectedKey)));
} else {
checkRecursive(expectedKey, actualNode, expectedField.getValue());
CompareResult result = checkRecursive(expectedKey, actualNode, expectedField.getValue());
errors.addAll(result.getErrors());
}
}
// do not continue (scalar type check)
return false;
return CompareResult.errors(errors);
}
private void checkValue(JsonNode actualJsonNode, JsonNode expectedJsonNode) {
private CompareResult checkValue(JsonNode actualJsonNode, JsonNode expectedJsonNode) {
if (!expectedJsonNode.equals(actualJsonNode)) {
errors.add(String.format("Expected field '%s' to be equal to '%s' but was '%s'", path(), expectedJsonNode, actualJsonNode));
return CompareResult.error(String.format("Expected field '%s' to be equal to '%s' but was '%s'", path(), expectedJsonNode, actualJsonNode));
}
return CompareResult.NO_ERRORS;
}
String path(String expectedKey) {
@@ -1,10 +1,12 @@
package io.ebean.test;
import com.fasterxml.jackson.databind.JsonNode;
import org.assertj.core.api.Assertions;
import org.junit.Test;
import java.util.stream.Stream;
import static io.ebean.test.Json.readNode;
import static io.ebean.test.Json.readNodeFromResource;
import static org.assertj.core.api.Assertions.assertThat;
@@ -33,18 +35,21 @@ public class JsonAssertContainsTest {
JsonAssertContains.assertContains(original, expected);
} catch (AssertionError e) {
String exceptionMessage = e.getMessage();
System.out.println(exceptionMessage);
Stream.of("Expected field 'someString1' to be equal to '\"aaaa\"' but was '\"string1\"",
"Expected field 'someValue1' to be equal to '99' but was '1'",
"Expected field 'someArray1[0]' to be of type 'STRING' but was 'NUMBER",
"Expected field 'someArray2[0].value1' to be of type 'ARRAY' but was 'NUMBER'",
"Expected field 'someArray2[0].value2' to be of type 'OBJECT' but was 'STRING'",
"Expected field 'someArray2[0].array1[0]' to be '\"1\"' but was null",
"Expected field 'someArray2[0].object1.val5' to be present",
"Expected field 'someArray2[0].object1.val6' to be present",
"Expected field 'someArray2[0].object2' to be of type 'NULL' but was 'OBJECT'",
"Expected field 'someArray2[0].objectNull' to be of type 'OBJECT' but was 'NULL'")
"Unable to match expected element 'someArray1[0]' in the actual array",
"Expected field 'someObject1.value1' to be of type 'ARRAY' but was 'NUMBER'",
"Expected field 'someObject1.value2' to be of type 'OBJECT' but was 'STRING'",
"Unable to match expected element 'someObject1.array1[0]' in the actual array",
"Expected field 'someObject1.object1.val5' to be present",
"Expected field 'someObject1.object1.val6' to be present",
"Expected field 'someObject1.object2' to be of type 'NULL' but was 'OBJECT'",
"Expected field 'someObject1.objectNull' to be of type 'OBJECT' but was 'NULL'")
.forEach(assertionError -> assertThat(exceptionMessage).contains(assertionError));
return;
}
Assertions.fail("Expected an exception to be thrown");
}
@@ -59,7 +64,9 @@ public class JsonAssertContainsTest {
Stream.of("Expected field 'someNull' to be of type 'NULL' but was 'STRING'",
"Expected field 'extra' to be present")
.forEach(assertionError -> assertThat(exceptionMessage).contains(assertionError));
return;
}
Assertions.fail("Expected an exception to be thrown");
}
@Test
@@ -72,7 +79,9 @@ public class JsonAssertContainsTest {
String exceptionMessage = e.getMessage();
Stream.of("Expected field 'some' to be of type 'NUMBER' but was 'STRING'")
.forEach(assertionError -> assertThat(exceptionMessage).contains(assertionError));
return;
}
Assertions.fail("Expected an exception to be thrown");
}
@Test
@@ -83,4 +92,39 @@ public class JsonAssertContainsTest {
assertThat(contains.path("a")).isEqualTo("a");
assertThat(contains.path("b")).isEqualTo("b");
}
@Test
public void assertContainsNumbersArrayShuffled() {
JsonNode array = readNode("[2, 54, 13, 10]");
JsonNode arrayShuffled = readNode("[2, 13, 10, 54]");
JsonAssertContains.assertContains(arrayShuffled, array);
}
@Test
public void assertContainsObjectsArrayShuffled() {
JsonNode array = readNodeFromResource("/contains/array-objects.json");
JsonNode arrayShuffled = readNodeFromResource("/contains/array-objects-shuffled.json");
JsonAssertContains.assertContains(arrayShuffled, array);
}
@Test
public void assertArrayElementsNotFound() {
JsonNode original = readNodeFromResource("/contains/array-multi-match.json");
JsonNode actual = readNodeFromResource("/contains/array-multi-match-duplicate-props.json");
try {
JsonAssertContains.assertContains(actual, original);
} catch (AssertionError e) {
String exceptionMessage = e.getMessage();
Stream.of("Unable to match expected element '[5]' in the actual array",
"Unable to match expected element '[4]' in the actual array")
.forEach(assertionError -> assertThat(exceptionMessage).contains(assertionError));
return;
}
Assertions.fail("Expected an exception to be thrown");
}
}
@@ -0,0 +1,18 @@
[
{
"b": 2,
"c": 3
},
{
"d": 4,
"e": 5
},
{
"a": 1,
"b": 2,
"c": 3
},
{
"b": 2
}
]
@@ -0,0 +1,20 @@
[
{
"a": 1
},
{
"b": 2
},
{
"c": 3
},
{
"d": 4
},
{
"e": 5
},
{
"f": 6
}
]
@@ -0,0 +1,14 @@
[
{
"id": "tyu",
"c": 3
},
{
"id": "zxy",
"a": 1
},
{
"id": "123",
"b": 2
}
]
@@ -0,0 +1,11 @@
[
{
"a": 1
},
{
"b": 2
},
{
"c": 3
}
]