Rest API Testing with Json Content Validator
The "classic" way
Common way to test Rest API results
Steps
- Make an API call with the proper parameters
- Get the response
- Assert the status code (200, 400 etc.)
- Assert the content (field/value)
Example
@Test
@Sql(INIT_SQL)
@DisplayName("Should get a quoting")
void testGetQuoting() throws IOException {
String product1Prefix = bodyPaths("products", findBy("product_code", "vcs_formule1"));
String product2Prefix = bodyPaths("products", findBy("product_code", "vcs_formule2"));
String fixedTaxPrefix = bodyPaths("prices.country_taxes", findBy("name", "COUNTRY_TAX_FIXED_EUR"));
String variableTaxPrefix = bodyPaths("prices.country_taxes", findBy("name", "COUNTRY_TAX_PERCENT"));
String medicalClassPrefix = bodyPaths("prices.guarantee_class_prices", findBy("code", "medical"));
String baggageClassPrefix = bodyPaths("prices.guarantee_class_prices", findBy("code", "baggage"));
String lawyerClassPrefix = bodyPaths("prices.guarantee_class_prices", findBy("code", "lawyer"));
String baggageGuaranteePrefix = bodyPaths("guarantees", findBy("label", "Baggages"));
String healthGuaranteePrefix = bodyPaths("guarantees", findBy("label", "Health"));
String cancellationGuaranteePrefix = bodyPaths("guarantees", findBy("label", "Cancellation"));
String lawyerGuaranteePrefix = bodyPaths("guarantees", findBy("label", "Lawyer"));
String customGuaranteePrefix = bodyPaths("guarantees", findBy("label", "Custom"));
LocalDate expirationDate = LocalDate.now().plusDays(10);
givenPartner(PARTNER_CLIENT_ID)
.header(callerJwtHeader())
.header("accept-language", Locale.FRANCE.toLanguageTag())
.contentType(ContentType.JSON)
.body(classResource(this, "get_quoting.json").getFile())
.post(path("/quotes_requests"))
.then()
.assertThat()
.statusCode(HttpStatus.OK.value())
.body("context.country", equalTo("FR"))
.body("context.currency", equalTo("EUR"))
.body("quote_expire_at", startsWith(expirationDate.toString()))
.body("products.product_code", hasItems("vcs_formule1", "vcs_formule2"))
.body("products[0].product_code", equalTo("vcs_formule1"))
.body("products[1].product_code", equalTo("vcs_formule2"))
// PRODUCT 1
.body(bodyPaths(product1Prefix, "quote_code"), not(emptyOrNullString()))
.body(bodyPaths(product1Prefix, "name"), equalTo("VCS Formule 1"))
.body(bodyPaths(product1Prefix, "promo_code"), equalTo("PROMO_1"))
.body(bodyPaths(product1Prefix, "prices.premium_after_discount_excl_tax"), equalTo(542.92f))
.body(bodyPaths(product1Prefix, "prices.total_discount"), equalTo(79.64f))
.body(bodyPaths(product1Prefix, "prices.country_taxes.name"), hasItems("COUNTRY_TAX_FIXED_EUR", "COUNTRY_TAX_PERCENT"))
.body(bodyPaths(product1Prefix, fixedTaxPrefix, "tax_amount"), equalTo(10f))
.body(bodyPaths(product1Prefix, variableTaxPrefix, "tax_amount"), equalTo(108.58f))
.body(bodyPaths(product1Prefix, "prices.total_taxes"), equalTo(183.73f))
.body(bodyPaths(product1Prefix, "prices.price_before_discount_incl_tax"), equalTo(806.29f))
.body(bodyPaths(product1Prefix, "prices.price_after_discount_incl_tax"), equalTo(726.65f))
.body(bodyPaths(product1Prefix, "prices.guarantee_class_prices.code"), hasItems("medical", "baggage"))
.body(bodyPaths(product1Prefix, medicalClassPrefix, "premium_after_discount_excl_tax"), equalTo(219.15f))
.body(bodyPaths(product1Prefix, medicalClassPrefix, "total_taxes"), equalTo(26.30f))
.body(bodyPaths(product1Prefix, baggageClassPrefix, "premium_after_discount_excl_tax"), equalTo(323.77f))
.body(bodyPaths(product1Prefix, baggageClassPrefix, "total_taxes"), equalTo(38.85f))
.body(bodyPaths(product1Prefix, baggageGuaranteePrefix, "limit"), equalTo("500"))
.body(bodyPaths(product1Prefix, baggageGuaranteePrefix, "excess"), emptyOrNullString())
.body(bodyPaths(product1Prefix, healthGuaranteePrefix, "limit"), equalTo("10000"))
.body(bodyPaths(product1Prefix, healthGuaranteePrefix, "excess"), equalTo("300"))
.body(bodyPaths(product1Prefix, cancellationGuaranteePrefix, "limit"), equalTo(2750))
.body(bodyPaths(product1Prefix, cancellationGuaranteePrefix, "excess"), emptyOrNullString())
.body(bodyPaths(product1Prefix, customGuaranteePrefix), is(notNullValue()))
.body(bodyPaths(product1Prefix, "attachments.name"), hasItems("Some docs"))
.body(bodyPaths(product1Prefix, "attachments.content_url"), hasItem(containsString("integration-tests-bucket/some_key")))
.body(bodyPaths(product1Prefix, "attachments.is_terms_and_conditions"), hasItems(true))
.body(bodyPaths(product1Prefix, "disclaimers.code"), hasItems("DISCLAIMER_1", "PRODUCT_DISCLAIMER_1"))
.body(bodyPaths(product1Prefix, "disclaimers.text"), hasItems("Some disclaimer text...", "Some product disclaimer text..."))
.body(bodyPaths(product1Prefix, "consents.code"), hasItems("DISCLAIMER_2"))
.body(bodyPaths(product1Prefix, "consents.text"), hasItems("Do not forget the commission of 29,13 € !"))
// PRODUCT 2
.body(bodyPaths(product2Prefix, "quote_code"), not(emptyOrNullString()))
.body(bodyPaths(product2Prefix, "name"), equalTo("VCS Formule 2"))
.body(bodyPaths(product2Prefix, "promo_code"), emptyOrNullString())
.body(bodyPaths(product2Prefix, "prices.premium_after_discount_excl_tax"), equalTo(40597.56f))
.body(bodyPaths(product2Prefix, "prices.total_discount"), equalTo(0))
.body(bodyPaths(product2Prefix, "prices.country_taxes.name"), hasItems("COUNTRY_TAX_FIXED_EUR", "COUNTRY_TAX_PERCENT"))
.body(bodyPaths(product2Prefix, fixedTaxPrefix, "tax_amount"), equalTo(10f))
.body(bodyPaths(product2Prefix, variableTaxPrefix, "tax_amount"), equalTo(8119.51f))
.body(bodyPaths(product2Prefix, "prices.total_taxes"), equalTo(13001.22f))
.body(bodyPaths(product2Prefix, "prices.price_before_discount_incl_tax"), emptyOrNullString())
.body(bodyPaths(product2Prefix, "prices.price_after_discount_incl_tax"), equalTo(53598.78f))
.body(bodyPaths(product2Prefix, "prices.guarantee_class_prices.code"), hasItems("medical", "baggage", "lawyer"))
.body(bodyPaths(product2Prefix, medicalClassPrefix, "premium_after_discount_excl_tax"), equalTo(18037.49f))
.body(bodyPaths(product2Prefix, medicalClassPrefix, "total_taxes"), equalTo(2164.50f))
.body(bodyPaths(product2Prefix, baggageClassPrefix, "premium_after_discount_excl_tax"), equalTo(6833.23f))
.body(bodyPaths(product2Prefix, baggageClassPrefix, "total_taxes"), equalTo(819.99f))
.body(bodyPaths(product2Prefix, lawyerClassPrefix, "premium_after_discount_excl_tax"), equalTo(15726.84f))
.body(bodyPaths(product2Prefix, lawyerClassPrefix, "total_taxes"), equalTo(1887.22f))
.body(bodyPaths(product2Prefix, baggageGuaranteePrefix, "limit"), equalTo("500"))
.body(bodyPaths(product2Prefix, baggageGuaranteePrefix, "excess"), emptyOrNullString())
.body(bodyPaths(product2Prefix, healthGuaranteePrefix, "limit"), equalTo("10000"))
.body(bodyPaths(product2Prefix, healthGuaranteePrefix, "excess"), equalTo(1375.0f))
.body(bodyPaths(product2Prefix, cancellationGuaranteePrefix, "limit"), equalTo("250"))
.body(bodyPaths(product2Prefix, cancellationGuaranteePrefix, "excess"), emptyOrNullString())
.body(bodyPaths(product2Prefix, lawyerGuaranteePrefix, "limit"), equalTo(2750))
.body(bodyPaths(product2Prefix, lawyerGuaranteePrefix, "excess"), equalTo("500"))
.body(bodyPaths(product2Prefix, "disclaimers.code"), hasItems("DISCLAIMER_1"))
.body(bodyPaths(product2Prefix, "disclaimers.text"), hasItems("Some disclaimer text..."))
.body(bodyPaths(product2Prefix, "consents.code"), hasItems("DISCLAIMER_2"))
.body(bodyPaths(product2Prefix, "consents.text"), hasItems("Do not forget the commission of 1 628,77 € !"))
.body("payment_modes.type", hasItems("CREDIT_CARD"))
.body("payment_modes.provider.id", hasItems("70b84f60-fbd7-4604-989b-1dd633f0fac3"))
.body("payment_modes.provider.label", hasItems("My provider"))
;
}
Body path "query"
// Query pattern to know
// ...
.body("products[0].product_code", equalTo("vcs_formule1"))
// Non-indexed array query pattern
protected static String bodyPaths(String... paths) {
return Stream.of(paths).collect(Collectors.joining("."));
}
protected static String findBy(String attributeName, String attributeValue) {
return "find { it." + attributeName + " == '" + attributeValue + "' }";
}
// ...
String product1Prefix = bodyPaths("products", findBy("product_code", "vcs_formule1"));
String medicalClassPrefix = bodyPaths("prices.guarantee_class_prices", findBy("code", "medical"));
// ...
.body(bodyPaths(product1Prefix, "promo_code"), equalTo("PROMO_1"))
.body(bodyPaths(product1Prefix, medicalClassPrefix, "total_taxes"), equalTo(26.30f))
It works but...
- Very hard to read/understand
- Lot of queries
- Inferred type errors (float vs. double)
- What about the fields we forgot?
Goals
How could it be better ?
I would like my test to
- Be easy to write
- Be easy to read/understand
- Be easy to customize/adapt
- Be strict (no missing fields)
Possible solutions
- Add JSON schema validation
- 👍 no more missing field (not true for every tool)
- 👎 does not help with field value assertions
- 👎 JSON schema not so easy to write/read
- Compare raw JSON results
- 👍 no more "query"/assertions
- 👍 no more missing field (not true for every tool)
- 👍 easy to read (almost documentation examples)
- 👎 hard to compare with un-ordered arrays
- 👎 hard to compare with unpredictable field values (generated id, dates, etc...)
The choice : custom solution based on JSONassert
- JSONassert
- Solution to compare raw JSON
- Configurable behaviour
- + Develop custom comparator
- Allow "validators" in the JSON body
Json Content Validator
A custom JSONassert comparator
- Compare raw JSON
- Forgive ordering (configuration)
- Forgive extending (configuration)
// Example 1
JSONObject data = getRESTData("/friends/367.json");
String expected = "{friends:[{id:123,name:\"Corby Page\"},{id:456,name:\"Carter Page\"}]}";
JSONAssert.assertEquals(expected, data, false);
// Example 2 - Error messages
String expected = "{id:1,name:\"Joe\",friends:[{id:2,name:\"Pat\",pets:[\"dog\"]},{id:3,name:\"Sue\",pets:[\"bird\",\"fish\"]}],pets:[]}";
String actual = "{id:1,name:\"Joe\",friends:[{id:2,name:\"Pat\",pets:[\"dog\"]},{id:3,name:\"Sue\",pets:[\"cat\",\"fish\"]}],pets:[]}"
JSONAssert.assertEquals(expected, actual, false);
// Result :
friends[id=3].pets[]: Expected bird, but not found ; friends[id=3].pets[]: Contains cat, but not expected
"Unpredictable" value problem
Common cases :
- Generated ID (entity uuid, etc.)
- Generated dates (created_date, etc.)
- Generated hypermedia urls (dynamic port, etc.)
"Validators" inside JSON
- Be able to mix values and validators in the raw JSON
- Be able to pass parameters to the validator
- Be able to get explicit errors
Example
{
"field_1": "some value",
"field_2": "3716a0cf-850e-46c3-bd97-ac1f34437c43",
"date": "2011-12-03T10:15:30Z",
"other_fields": [
{
"id": "2",
"link": "https://another.url.com/my-base-path/query?param1=true"
},
{
"id": "1",
"link": "https://some.url.com"
}
]
}
{
"field_1": "some value",
"field_2": "{#uuid#}",
"date": "{#date_time_format:iso_instant#}",
"other_fields": [
{
"id": "1",
"link": "{#url#}"
},
{
"id": "2",
"link": "{#url_ending:query?param1=true#}"
}
]
}
Actual JSON
Expected JSON
Json Content Validator
Soon on GitHub
What's in it? (WIP)
- Core module
- Custom JSONassert comparator
- Predefined validators collection
- Matcher modules
- for AssertJ
- for Hamcrest (and RestAssured)
- Command line tool
- Assert directly a curl result against your expected JSON
Demo (WIP)
The "new" way
A readable, small and strong test
Updated test
@Test
@Sql(INIT_SQL)
@DisplayName("Should get a quoting")
void testGetQuoting() throws IOException {
givenPartner(PARTNER_CLIENT_ID)
.header(callerJwtHeader())
.header("accept-language", Locale.FRANCE.toLanguageTag())
.contentType(ContentType.JSON)
.body(classResource(this, "get_quoting.json").getFile())
.post(path("/quotes_requests"))
.then()
.assertThat()
.statusCode(HttpStatus.OK.value())
.body(isValidAgainst(classResource(this, "get_quoting_expected.json")));
}
The expected JSON
{
"context": {
"country": "FR",
"currency": "EUR"
},
"quote_expire_at": "{#instant_format#}",
"products": [
{
"quote_code": "{#not_empty#}",
"name": "VCS Formule 1",
"is_default_product": true,
"promo_code": "PROMO_1",
"prices": {
"total_taxes": 183.73,
"country_taxes": [
{
"name": "COUNTRY_TAX_FIXED_EUR",
"tax_amount": 10.00
},
{
"name": "COUNTRY_TAX_PERCENT",
"tax_amount": 108.58
}
],
"price_after_discount_incl_tax": 726.65,
"premium_after_discount_excl_tax": 542.92,
"total_discount": 79.64,
"price_before_discount_incl_tax": 806.29,
"guarantee_class_prices": [
{
"code": "baggage",
"label": "Baggage",
"premium_after_discount_excl_tax": 323.77,
"total_taxes": 38.85
},
{
"code": "medical",
"label": "Medical",
"premium_after_discount_excl_tax": 219.15,
"total_taxes": 26.30
}
]
},
"attachments": [
{
"name": "Some docs",
"is_terms_and_conditions": true,
"content_url": "{#url:/integration-tests-bucket/some_key#}"
},
{
"name": "Some docs bis",
"content_url": "{#url:/integration-tests-bucket/some_key#}",
"is_terms_and_conditions": true
}
],
"consents": [
{
"code": "DISCLAIMER_2",
"is_mandatory": true,
"text": "Do not forget the commission of 29,13 € !",
"type": "PAYMENT"
}
],
"disclaimers": [
{
"code": "DISCLAIMER_1",
"text": "Some disclaimer text...",
"type": "PERSONNAL_DATA"
},
{
"code": "PRODUCT_DISCLAIMER_1",
"text": "Some product disclaimer text...",
"type": "PERSONNAL_DATA"
}
],
"guarantees": [
{
"label": "Baggages",
"description": "Baggages description",
"headline": "Baggages headline",
"limit": "500",
"sub_guarantees": [
]
},
{
"label": "Health",
"limit": "10000",
"excess": "300",
"sub_guarantees": [
]
},
{
"label": "Custom",
"sub_guarantees": [
]
}
],
"addon_codes": [
],
"product_code": "vcs_formule1",
"risks": [
{
"code": "risk_1",
"limit": 2750
},
{
"code": "risk_2",
"limit": "500"
}
]
},
{
"quote_code": "{#not_empty#}",
"name": "VCS Formule 2",
"is_default_product": false,
"prices": {
"total_taxes": 13001.22,
"country_taxes": [
{
"name": "COUNTRY_TAX_FIXED_EUR",
"tax_amount": 10.00
},
{
"name": "COUNTRY_TAX_PERCENT",
"tax_amount": 8119.51
}
],
"price_after_discount_incl_tax": 53598.78,
"premium_after_discount_excl_tax": 40597.56,
"total_discount": 0,
"guarantee_class_prices": [
{
"code": "baggage",
"label": "Baggage",
"premium_after_discount_excl_tax": 6833.23,
"total_taxes": 819.99
},
{
"code": "lawyer",
"label": "Lawyer",
"premium_after_discount_excl_tax": 15726.84,
"total_taxes": 1887.22
},
{
"code": "medical",
"label": "Medical",
"premium_after_discount_excl_tax": 18037.49,
"total_taxes": 2164.50
}
]
},
"attachments": [
],
"consents": [
{
"code": "DISCLAIMER_2",
"is_mandatory": true,
"text": "Do not forget the commission of 1 628,77 € !",
"type": "PAYMENT"
}
],
"disclaimers": [
{
"code": "DISCLAIMER_1",
"text": "Some disclaimer text...",
"type": "PERSONNAL_DATA"
}
],
"guarantees": [
{
"label": "Baggages",
"description": "Baggages description",
"headline": "Baggages headline",
"limit": "500",
"sub_guarantees": [
]
},
{
"label": "Health",
"limit": "10000",
"excess": 1375.00,
"sub_guarantees": [
]
},
{
"label": "Cancellation",
"limit": "250",
"sub_guarantees": [
]
},
{
"label": "Lawyer",
"limit": 2750,
"excess": "500",
"sub_guarantees": [
]
}
],
"addon_codes": [
],
"product_code": "vcs_formule2",
"risks": [
]
}
],
"addons": [
],
"payment_modes": [
{
"type": "CREDIT_CARD",
"provider": {
"id": "70b84f60-fbd7-4604-989b-1dd633f0fac3",
"label": "My provider"
}
}
],
"risks": [
{
"code": "risk_1",
"label": "Risk 1"
},
{
"code": "risk_2",
"label": "Risk 2"
},
{
"code": "risk_3",
"label": "Risk 3"
}
],
"policy": {
"pre_contractual_email_required": true
}
}
Thank you
Special thanks to
Marie Bonnissent and Nicolas Gunther
for their help and feedbacks
Json Content Validator
By Leo Millon
Json Content Validator
- 99