#2173 - ebean-test: Add assertContains - DbJson.of(entityBean).assertContains(...)

This commit is contained in:
Robin Bygrave
2021-02-19 09:29:06 +13:00
parent b4ae721998
commit b7da2cbd97
23 changed files with 727 additions and 31 deletions
+9 -11
View File
@@ -1,21 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<artifactId>ebean-parent</artifactId>
<groupId>io.ebean</groupId>
<version>12.7.1-SNAPSHOT</version>
</parent>
<!-- <parent>-->
<!-- <groupId>org.avaje</groupId>-->
<!-- <artifactId>java8-oss</artifactId>-->
<!-- <version>2.2</version>-->
<!-- </parent>-->
<scm>
<developerConnection>scm:git:git@github.com:ebean-orm/ebean.git</developerConnection>
<tag>ebean-parent-12.6.5</tag>
</scm>
<name>ebean test</name>
<description>Testing support for Ebean</description>
@@ -60,6 +51,13 @@
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson-databind.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-test-docker</artifactId>
@@ -2,34 +2,63 @@ package io.ebean.test;
import io.ebean.DB;
import java.io.IOException;
import java.io.InputStream;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Helper for testing to assert that the JSON form of an entity
* or list of entities match a String / typically test resource.
* or list of entities is as expected.
* <p>
* Using assertContains() we can match the JSON form of an entity
* against a subset of JSON content. Typically the subset of JSON
* excludes generated properties like id, when created and
* when modified properties.
* </p>
*
* <h3>Assert contains</h3>
* <pre>{@code
*
* DbJson.of(order)
* .assertContains("/order-partial.json");
*
* }</pre>
*
*
* <h3>Assert content matches</h3>
* <p>
* Using assertContentMatches() we are doing an exact content match.
* We typically need to replace generated property values. This assert
* will start to fail if the model changes like adding a property to
* the entity so we should use it less widely due to the maintenance
* burden we have with it.
* </p>
*
* <pre>{@code
*
* DbJson.of(timedEntries)
* .replace("id", "eventTime")
* .assertContentMatches("/assertJson/full-1-timed.json");
* DbJson.of(order)
* .replace("id", "whenCreated", "whenModified)
* .assertContentMatches("/order-full.json");
*
* }</pre>
*/
public class DbJson {
/**
* Create a PrettyJson object that has the JSON form of the
* entity bean or beans.
* Create a PrettyJson object that has the JSON form of the entity bean or beans.
*
* <h3>Assert contains</h3>
* <pre>{@code
*
* DbJson.of(timedEntries)
* .replace("id", "eventTime")
* .assertContentMatches("/assertJson/full-1-timed.json");
* DbJson.of(order)
* .assertContains("/order-partial.json");
*
* }</pre>
*
* <h3>Assert content matches</h3>
* <pre>{@code
*
* DbJson.of(order)
* .replace("id", "whenCreated", "whenModified)
* .assertContentMatches("/order-full.json");
*
* }</pre>
*/
@@ -41,12 +70,7 @@ public class DbJson {
* Read the content for the given resource path.
*/
public static String readResource(String resourcePath) {
InputStream is = DbJson.class.getResourceAsStream(resourcePath);
try {
return IOUtils.readUtf8(is).trim();
} catch (IOException e) {
throw new IllegalArgumentException(e);
}
return IOUtils.readResource(resourcePath);
}
/**
@@ -92,7 +116,7 @@ public class DbJson {
}
/**
* Assert the json matches the content at the given resource path.
* Assert the json exactly matches the content at the given resource path.
*
* <pre>{@code
*
@@ -103,7 +127,44 @@ public class DbJson {
* }</pre>
*/
public void assertContentMatches(String resourcePath) {
assertThat(rawJson).isEqualTo(readResource(resourcePath));
assertThat(lineEnd(rawJson)).isEqualTo(lineEnd(readResource(resourcePath)));
}
/**
* Normalise line ending characters to just use new line.
*/
private String lineEnd(String content) {
return content.replace("\r\n", "\n");
}
/**
* Assert the DB json contains the given json content.
* <p>
* With this "contains" check the DB Json can contain more content than what
* it is checked against. Typically the DB json can contain generated properties
* like id values, when created, when modified etc and we leave these out of the
* json content we are checking against.
* </p>
*
* @param json The subset json content that should be contained by the DB json.
*/
public void assertContains(String json) {
Json.assertContains(rawJson, json);
}
/**
* Assert the DB Json contains the Json at the given resource path.
* <p>
* With this "contains" check the DB Json can contain more content than what
* it is checked against. Typically the DB json can contain generated properties
* like id values, when created, when modified etc and we leave these out of the
* json content we are checking against.
* </p>
*
* @param resourcePath The resource path of the JSON content we are checking against.
*/
public void assertContainsResource(String resourcePath) {
assertContains(readResource(resourcePath));
}
}
}
@@ -11,6 +11,18 @@ import java.nio.charset.StandardCharsets;
*/
class IOUtils {
/**
* Read the content for the given resource path.
*/
static String readResource(String resourcePath) {
try {
InputStream is = IOUtils.class.getResourceAsStream(resourcePath);
return IOUtils.readUtf8(is).trim();
} catch (IOException e) {
throw new IllegalArgumentException("Error reading resource " + resourcePath, e);
}
}
/**
* Reads the entire contents of the specified input stream and return them as UTF-8 string.
*/
@@ -0,0 +1,168 @@
package io.ebean.test;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.type.CollectionType;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.io.IOException;
import java.util.List;
/**
* Helper to convert to and from json using Jackson object mapper and
* perform some useful asserts based on json content.
*/
public class Json {
/**
* For reading resource json content into a bean, list or jsonNode in a fluid style.
*/
public static class Resource {
private final String resourcePath;
private Resource(String resourcePath) {
this.resourcePath = resourcePath;
}
/**
* Return as a plain bean.
*/
<T> T asBean(Class<T> cls) {
return Json.read(cls, readResource(resourcePath));
}
/**
* Return as a list of beans.
*/
<T> List<T> asList(Class<T> cls) {
return Json.readList(cls, readResource(resourcePath));
}
/**
* Return as a JsonNode.
*/
JsonNode asNode() {
return Json.readNodeFromResource(resourcePath);
}
}
private static final ObjectMapper MAPPER = initMapper();
/**
* For fluid style reading resource json content and return as a
* bean, list of bean or JsonNode.
* <pre>{@code
*
* PlainBean bean =
* Json.resource("/example/plain-list.json").asBean(PlainBean.class);
*
* List<PlainBean> list =
* Json.resource("/example/plain-list.json").asList(PlainBean.class);
*
* JsonNode jsonNode =
* Json.resource("/example/plain-list.json").asJsonNode();
*
* }</pre>
*
* @param resourcePath The resource path where the json content is read from
* @return The resource to convert to a bean or jsonNode etc
*/
public static Resource resource(String resourcePath) {
return new Resource(resourcePath);
}
/**
* Assert all the fields in the expectedJson are present in actualJson and values match.
*/
public static void assertContains(String actualJson, String expectedJson) {
assertContains(readNode(actualJson), readNode(expectedJson));
}
/**
* Assert all the fields in the expectedJson are present in actualJson and values match.
*/
public static void assertContains(JsonNode actualJsonNode, JsonNode expectedJsonNode) {
JsonAssertContains.assertContains(actualJsonNode, expectedJsonNode);
}
/**
* Read the content for the given resource path.
*/
public static String readResource(String resourcePath) {
return IOUtils.readResource(resourcePath);
}
/**
* Return a bean from json content of a resource path.
*/
public static <T> T readFromResource(Class<T> type, String resourcePath) {
return read(type, readResource(resourcePath));
}
/**
* Return a typed object from json content.
*/
public static <T> T read(Class<T> type, String json) {
try {
return MAPPER.readValue(json, type);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/**
* Return a list of a given type from json content.
*/
public static <T> List<T> readList(Class<T> type, String json) {
final CollectionType collectionType = MAPPER.getTypeFactory().constructCollectionType(List.class, type);
try {
return MAPPER.readValue(json, collectionType);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/**
* Parse json into a generic JsonNode structure from resource.
*/
public static JsonNode readNodeFromResource(String resourcePath) {
return readNode(IOUtils.readResource(resourcePath));
}
/**
* Parse json into a generic JsonNode structure.
*/
public static JsonNode readNode(String json) {
try {
return MAPPER.readTree(json);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/**
* Serialize object to string
*/
public static String toJsonString(Object bean) {
try {
return MAPPER.writeValueAsString(bean);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private static ObjectMapper initMapper() {
return new ObjectMapper()
.registerModule(new JavaTimeModule())
.configure(SerializationFeature.INDENT_OUTPUT, true)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false)
.configure(SerializationFeature.INDENT_OUTPUT, true)
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
}
}
@@ -0,0 +1,118 @@
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;
/**
* 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 {
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()) {
String errorsString = String.join("\n", errors);
errorsString += "\nExpected JSON fields: " + expectedJsonNode;
errorsString += "\nActual JSON: " + actualJsonNode;
Assertions.fail(errorsString);
}
}
private void 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);
}
}
}
}
if (name != null) {
path.pop();
}
}
private boolean 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 true;
}
private boolean 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 true;
}
private boolean checkArray(JsonNode actualJsonNode, JsonNode expectedJsonNode) {
if (!expectedJsonNode.isArray()) {
return true;
}
for (int i = 0; i < expectedJsonNode.size(); i++) {
checkRecursive("[" + i + "]", actualJsonNode.get(i), expectedJsonNode.get(i));
}
// do not continue (object or scalar type check)
return false;
}
private boolean checkObject(JsonNode actualJsonNode, JsonNode expectedJsonNode) {
if (!expectedJsonNode.isObject()) {
return true;
}
Iterator<Map.Entry<String, JsonNode>> expectedFields = expectedJsonNode.fields();
while (expectedFields.hasNext()) {
Map.Entry<String, JsonNode> expectedField = expectedFields.next();
String expectedKey = expectedField.getKey();
JsonNode actualNode = actualJsonNode.get(expectedKey);
if (actualNode == null) {
errors.add(String.format("Expected field '%s' to be present", path(expectedKey)));
} else {
checkRecursive(expectedKey, actualNode, expectedField.getValue());
}
}
// do not continue (scalar type check)
return false;
}
private void 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));
}
}
String path(String expectedKey) {
if (path.isEmpty()) {
return expectedKey;
}
return path() + "." + expectedKey;
}
String path() {
if (path.isEmpty()) {
return "";
}
return String.join(".", path).replace(".[", "[");
}
}
@@ -1,11 +1,13 @@
package io.ebean.test;
import com.fasterxml.jackson.databind.JsonNode;
import io.ebean.DB;
import org.junit.Test;
import org.test.BSimpleWithGen;
import java.util.List;
import static io.ebean.test.DbJson.readResource;
import static org.assertj.core.api.Assertions.assertThat;
@@ -36,6 +38,37 @@ public class DbJsonTest {
//.withPlaceholder("_")
.replace("id", "whenModified")
.assertContentMatches("/bean/example-list.json");
}
@Test
public void assertContains_pass() {
BSimpleWithGen bean = new BSimpleWithGen("something-contains-me", "YeahNah");
DB.save(bean);
BSimpleWithGen found = DB.find(BSimpleWithGen.class, bean.getId());
DbJson.of(found).assertContainsResource("/bean/contains-minimal.json");
DbJson.of(found).assertContains(readResource("/bean/contains-with-version.json"));
DB.delete(bean);
}
@Test
public void asJson() {
BSimpleWithGen bean = new BSimpleWithGen("other");
DB.save(bean);
String asJson = DbJson.of(bean)
.withPlaceholder("\"*Replaced*\"")
.replace("id")
.asJson();
JsonNode node = Json.readNode(asJson);
assertThat(node.get("id").asText()).isEqualTo("*Replaced*");
assertThat(node.get("name").asText()).isEqualTo("other");
DB.delete(bean);
}
}
@@ -0,0 +1,86 @@
package io.ebean.test;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.Test;
import java.util.stream.Stream;
import static io.ebean.test.Json.readNodeFromResource;
import static org.assertj.core.api.Assertions.assertThat;
public class JsonAssertContainsTest {
@Test
public void assertContains_itself() {
JsonNode original = readNodeFromResource("/contains/original.json");
JsonAssertContains.assertContains(original, original);
}
@Test
public void assertContains_subset() {
JsonNode original = readNodeFromResource("/contains/original.json");
JsonNode expected = readNodeFromResource("/contains/original-subset.json");
JsonAssertContains.assertContains(original, expected);
}
@Test
public void testContainsFails() {
JsonNode original = readNodeFromResource("/contains/original.json");
JsonNode expected = readNodeFromResource("/contains/original-subset-modified.json");
try {
JsonAssertContains.assertContains(original, expected);
} catch (AssertionError e) {
String exceptionMessage = e.getMessage();
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'")
.forEach(assertionError -> assertThat(exceptionMessage).contains(assertionError));
}
}
@Test
public void assertContains_checkNull() {
JsonNode original = readNodeFromResource("/contains/check-null-actual.json");
JsonNode expected = readNodeFromResource("/contains/check-null-expected.json");
try {
JsonAssertContains.assertContains(original, expected);
} catch (AssertionError e) {
String exceptionMessage = e.getMessage();
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));
}
}
@Test
public void assertContains_checkType() {
JsonNode original = readNodeFromResource("/contains/check-type-actual.json");
JsonNode expected = readNodeFromResource("/contains/check-type-expected.json");
try {
JsonAssertContains.assertContains(original, expected);
} catch (AssertionError e) {
String exceptionMessage = e.getMessage();
Stream.of("Expected field 'some' to be of type 'NUMBER' but was 'STRING'")
.forEach(assertionError -> assertThat(exceptionMessage).contains(assertionError));
}
}
@Test
public void path_when_empty() {
JsonAssertContains contains = new JsonAssertContains();
assertThat(contains.path()).isEqualTo("");
assertThat(contains.path("a")).isEqualTo("a");
assertThat(contains.path("b")).isEqualTo("b");
}
}
@@ -0,0 +1,75 @@
package io.ebean.test;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.Test;
import org.test.PlainBean;
import java.util.List;
import static io.ebean.test.Json.readNodeFromResource;
import static io.ebean.test.Json.readResource;
import static org.assertj.core.api.Assertions.assertThat;
public class JsonTest {
@Test
public void assertContains_subset() {
JsonNode original = readNodeFromResource("/contains/original.json");
JsonNode expected = readNodeFromResource("/contains/original-subset.json");
Json.assertContains(original, expected);
}
@Test
public void readNode() {
JsonNode original = Json.readNode(readResource("/contains/original.json"));
Json.assertContains(original, original);
}
@Test
public void resource_asNode() {
JsonNode original = Json.resource("/contains/original.json").asNode();
JsonNode nonFluid = Json.readNode(readResource("/contains/original.json"));
assertThat(original).isEqualTo(nonFluid);
}
@Test
public void readBean_resource_asBean() {
// traditional style from resource
PlainBean bean1 = Json.readFromResource(PlainBean.class, "/example/plain.json");
assertThat(bean1.id).isEqualTo(42);
assertThat(bean1.name).isEqualTo("foo");
// traditional style given json content
PlainBean bean2 = Json.read(PlainBean.class, readResource("/example/plain.json"));
assertThat(bean2.id).isEqualTo(42);
assertThat(bean2.name).isEqualTo("foo");
// fluid style - resource asBean()
PlainBean bean3 = Json.resource("/example/plain.json").asBean(PlainBean.class);
assertThat(bean3.id).isEqualTo(42);
assertThat(bean3.name).isEqualTo("foo");
}
@Test
public void readList() {
List<PlainBean> list = Json.readList(PlainBean.class, readResource("/example/plain-list.json"));
String asJson = Json.toJsonString(list);
assertThat(list).hasSize(2);
assertThat(list.get(0).name).isEqualTo("foo");
assertThat(list.get(1).name).isEqualTo("bar");
assertThat(asJson).contains("\"name\" : \"foo\"");
}
@Test
public void resourceAsList() {
// fluid style - resource asList()
List<PlainBean> list = Json.resource("/example/plain-list.json").asList(PlainBean.class);
// traditional style
List<PlainBean> list2 = Json.readList(PlainBean.class, readResource("/example/plain-list.json"));
assertThat(list).isEqualTo(list2);
}
}
@@ -18,6 +18,8 @@ public class BSimpleWithGen {
private String name;
private String other;
@Transient
private Map<String, List<String>> someMap;
@@ -31,6 +33,11 @@ public class BSimpleWithGen {
this.name = name;
}
public BSimpleWithGen(String name, String other) {
this.name = name;
this.other = other;
}
public Integer getId() {
return id;
}
@@ -0,0 +1,22 @@
package org.test;
import java.util.Objects;
public class PlainBean {
public int id;
public String name;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PlainBean plainBean = (PlainBean) o;
return id == plainBean.id && name.equals(plainBean.name);
}
@Override
public int hashCode() {
return Objects.hash(id, name);
}
}
@@ -0,0 +1,4 @@
{
"name": "something-contains-me",
"version": 1
}
@@ -0,0 +1,5 @@
{
"name": "something-contains-me",
"other": "YeahNah",
"version": 1
}
@@ -1,6 +1,7 @@
{
"id": _,
"name": "something",
"other": null,
"whenModified": _,
"version": 1
}
@@ -1,11 +1,13 @@
[ {
"id": "*",
"name": "something",
"other": null,
"whenModified": "*",
"version": 1
}, {
"id": "*",
"name": "other",
"other": null,
"whenModified": "*",
"version": 1
} ]
@@ -0,0 +1,9 @@
{
"some": "val",
"someNull": "actualNotNull",
"matchNull": null,
"nullObject": {
"id": 42
}
}
@@ -0,0 +1,7 @@
{
"some": "val",
"someNull": null,
"extra": "notThere",
"matchNull": null,
"nullObject": null
}
@@ -0,0 +1,3 @@
{
"some": "val"
}
@@ -0,0 +1,3 @@
{
"some": 42
}
@@ -0,0 +1,26 @@
{
"someString1": "aaaa",
"someString2": "string2",
"someValue1": 99,
"someValue2": 2,
"someArray1": [
"a"
],
"someArray2": [
{
"value1": [],
"value2": {},
"array1": [
"1"
],
"object1": {
"val5": "a",
"val6": 1
},
"object2": null,
"objectNull": {
"val": []
}
}
]
}
@@ -0,0 +1,15 @@
{
"someString1": "string1",
"someValue1": 1,
"someArray2": [
{
"value1": 11,
"array1": [],
"object2": {
"v1": "v1",
"v4": []
},
"objectNull": null
}
]
}
@@ -0,0 +1,27 @@
{
"someString1": "string1",
"someString2": "string2",
"someValue1": 1,
"someValue2": 2,
"someArray1": [
1,
2,
3,
4
],
"someArray2": [
{
"value1": 11,
"value2": "22",
"array1": [],
"object1": {},
"object2": {
"v1": "v1",
"v2": 1,
"v3": {},
"v4": []
},
"objectNull": null
}
]
}
@@ -0,0 +1,10 @@
[
{
"id": 42,
"name": "foo"
},
{
"id": 55,
"name": "bar"
}
]
@@ -0,0 +1,4 @@
{
"id": 42,
"name": "foo"
}