Test JSON schema with AJV and Jest

March 20, 2019

Photo by Lacie Slezak on Unsplash

While I worked on a project I was involved (Coding-Coach if you must know 😉) I faced with an interesting challenge.

The project users “database” is actually a big JSON file which contains all the entries. (Why? this is for another post)

This file has some restrictions to make sure the data will be consistency. For instance, “country” field which its value can be only from a list.

Each user can add himself to that JSON file with PR, and travis-ci is running the test which should verify the schema (among other things).

So how can we sure the new member added himself with the right schema without review it in each PR?

AJV is “The fastest JSON Schema Validator”. I didn’t check how fast it is but I can tell that its API is nice, flexible, well documented (except 1 case. We will talk about it later) and I liked to work with it.

So, how we do this?

TLD’R include the validation call in a it spec and fail the test if you get an error from the validation method.

Let’s take their example as a base:

1var Ajv = require('ajv');
2var ajv = new Ajv();
3var validate = ajv.compile(schema);
4var valid = validate(data);
5if (!valid) console.log(validate.errors);

Let’s say, our schema should looks like:

1{
2 "fullname": "your full name"
3}

So the user will have a fullname field and let’s say has minimum of 2 characters, the validation code will be:

1import users from './users.json';
2
3const shema = {
4 "type": "array",
5 "items": {
6 "type": "object",
7 "properties": {
8 "fullname": {
9 "type": "string",
10 "minLength": 2
11 },
12 },
13 },
14};
15
16ajv.validate(shema, users);

users.json

1[
2 {
3 "fullname": "john doe"
4 }
5]

ajv object has errors property — Array with the errors so if the JSON will not meet the schema, the errors will be:

1[
2 {
3 keyword: 'minLength',
4 dataPath: '[0].fullname',
5 schemaPath: '#/items/properties/fullname/minLength',
6 params: { limit: 2 },
7 message: 'should NOT be shorter than 2 characters'
8 }
9]

So far so good but it will not break our test.. Where is the test actually?

It’s not so hard. We just “wrap” the validation call with it.

1it.only(`should user's schema be valid`, () => {
2 const schema = {
3 "type": "array",
4 "items": {
5 "type": "object",
6 "properties": {
7 "fullname": {
8 "type": "string",
9 "minLength": 2
10 },
11 },
12 },
13 };
14
15 const valid = ajv.validate(shema, [
16 {
17 fullname: 'a'
18 }
19 ]);
20 expect(valid).toBeTruthy();
21});

In this case, the test will fail with the message:

1● should user's schema be valid
2
3expect(received).toBeTruthy()
4
5Received: false

As you can see, I don’t know what’s wrong, I just know that the data’s schema is invalid, it’s not good enough. It will be better to show more meaningful message.

As we saw earlier, we get the errors from ajv ( ajv.errors ) remember? So we can display it when the test is failing.

In Jest (unlike other libraries), you can’t specify the error message. So, in order to display a meaningful error message, we will have to create a custom matcher to show our own message. We also, collect the field name and the index so we could know which one is the problematic and what field.

1expect.extend({
2 toBeValid(isValid, errorMessage) {
3 return {
4 message: () => isValid ? '' : errorMessage,
5 pass: isValid
6 }
7 }
8});
9
10it(`should user's schema be valid`, () => {
11 const shema = {
12 "type": "array",
13 "items": {
14 "type": "object",
15 "properties": {
16 "fullname": {
17 "type": "string",
18 "minLength": 2
19 },
20 },
21 },
22 };
23
24 const valid = ajv.validate(shema, [
25 {
26 fullname: 'a'
27 }
28 ]);
29 try {
30 const [, index, fieldName] = /\[(.*)\].(.*)/.exec(error.dataPath);
31 return `error with item #${index}'s field "${fieldName}". The error is: ${error.message}`;
32 } catch (error) {
33 return error.message;
34 }
35 }).join('\n');
36 expect(valid).toBeValid(errorMessage);
37});

This one will show the error message we wanted:

1● should user's schema be valid
2
3error with item #0's field "fullname". The error is: should NOT be shorter than 2 characters

The next field I wanted to validate is an avatar URL. It shouldn’t be a problem because with AJV we get a URL validation out of the box (Hint: by using format: ‘uri').

The problem is, our website is running on https schema so we wanted that all the resources will come from https either (to avoid Mixed Content issue). URL with http is valid so how can we verify the the URL schema?

AJV let us to define “Custom Keywords” — a custom function validation of top of the original function based on the type of the field.

Here is how:

1const validateSecuredUrl = function (schema, uri) {
2 return uri.indexOf('https://') === 0;
3};
4
5ajv.addKeyword('securedUrl', {
6 validate: validateSecuredUrl,
7 errors: true
8});
9
10it(`should user's schema be valid`, () => {
11 const shema = {
12 "type": "array",
13 "items": {
14 "type": "object",
15 "properties": {
16 "fullname": {
17 "type": "string",
18 "minLength": 2
19 },
20 "avatar": {
21 "type": "string",
22 "format": "uri",
23 "securedUrl": true,
24 },
25 },
26 },
27 };
28
29 const valid = ajv.validate(shema, [
30 {
31 fullname: 'ab',
32 avatar: "http://mywebsite.com/path/to/avatar"
33 }
34 ]);
35 const errorMessage = (ajv.errors || []).map(error => {
36 try {
37 const [, index, fieldName] = /\[(.*)\].(.*)/.exec(error.dataPath);
38 return `error with item #${index}'s field "${fieldName}". The error is: ${error.message}`;
39 } catch (error) {
40 return error.message;
41 }
42 }).join('\n');
43 expect(valid).toBeValid(errorMessage);
44});

And the output will be

1● should user's schema be valid
2
3error with item #0's field "avatar". The error is: should pass "securedUrl" keyword validation

The only thing that left is to display what’s exactly secureUrl validation is.

The docs does mention how to return a custom error message

The function should return validation result as boolean. It can return an array of validation errors via .errors property of itself (otherwise a standard error will be used).

But I wasn’t sure how exactly to implement it (whoever thinks that it straightforward, well done 👏)


well documented (except 1 case. We will talk about it later)

Remember?

So after diving into the repo in Github, I found the answer in an issue who asks exactly that.

So the full code will be

1const validateSecuredUrl = function (schema, uri) {
2 validateSecuredUrl.errors = [{keyword: 'secured', message: 'avatar url must be "https" schema', params: {keyword: 'secured'}}];
3 return uri.indexOf('https://') === 0;
4};
5
6ajv.addKeyword('securedUrl', {
7 validate: validateSecuredUrl,
8 errors: true
9});
10
11it(`should user's schema be valid`, () => {
12 const shema = {
13 "type": "array",
14 "items": {
15 "type": "object",
16 "properties": {
17 "fullname": {
18 "type": "string",
19 "minLength": 2
20 },
21 "avatar": {
22 "type": "string",
23 "format": "uri",
24 "securedUrl": true,
25 },
26 },
27 },
28 };
29
30 const valid = ajv.validate(shema, [
31 {
32 fullname: 'ab',
33 avatar: "http://mywebsite.com/path/to/avatar"
34 }
35 ]);
36 const errorMessage = (ajv.errors || []).map(error => {
37 try {
38 const [, index, fieldName] = /\[(.*)\].(.*)/.exec(error.dataPath);
39 return `error with item #${index}'s field "${fieldName}". The error is: ${error.message}`;
40 } catch (error) {
41 return error.message;
42 }
43 }).join('\n');
44 expect(valid).toBeValid(errorMessage);
45});

And the output will be

1● should user's schema be valid
2
3error with item #0's field "avatar". The error is: avatar url must be "https" schema

And now the user could understand exactly what he / she needs to fix.

Original Post
© 2022 - Moshe Feuchtwander