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