May Sale! All accounts are 40% off this week.

madman81's avatar

Find extra elements with assertJsonStructure()

Is it possible to have assertJsonStructe() fail if there are elements/keys in the json that's being tested, but not in the given structure? See the example below. I would like to let this assert fail because there is a "price" element in the second book. A kind of strict validation perhaps?

Second question: Is it possible to have optional elements in the structure? If I would add "price" to the expected structure, it would fail on the first book. I would like to indicate that "price" is an optional element.

public function testJsonStructure()
    {
        $json = '
        {
            "books": [
                {
                    "isbn": "123456",
                    "title": "Nice book",
                    "author": "Awesome writer"
                },
                {
                    "isbn": "78912",
                    "title": "Better book",
                    "author": "Awesome writer",
                    "price": 20
                }
            ]
        }';

        $response = new TestResponse(new Response($json));
        $response->assertJsonStructure([
            'books' => [
                '*' => [
                    'isbn',
                    'title',
                    'author',
                ]
            ]
        ]);
    }
0 likes
8 replies
madman81's avatar

@Nakov Unfortunately assertExactJson() is not only validating the structure, but also the data. I'm looking for a way to validation the structure of a json string.

I've been playing around a bit with the Fluent Json testing, but I can't get it to work as I would have hoped. Although this is working, it's not very dynamic:

        $response = new TestResponse(new Response($json));
        $response->assertJson(fn (AssertableJson $json) =>
            $json->has('books.0', fn ($json) =>
                $json->has('isbn')
                    ->has('title')
                    ->has('author')
            )->has('books.1', fn ($json) =>
                $json->has('isbn')
                    ->has('title')
                    ->has('author')
            )
        );

How can I make it test all books without having to specify if for each book? let's say I want to validation the json of a complete library ;-)

Nakov's avatar

@madman81 I don’t get the point of dynamic testing I wouldn’t do that in unit tests. I might use data providers (https://tighten.com/blog/tidying-up-your-phpunit-tests-with-data-providers) to test different cases but validating that 100 books all match does not make the sense if the code works for two books it will work for 100 as long as the cases are not different, if you happen to find different case you just update the data provider and ensure that your class returns the correct result.

madman81's avatar

@Nakov One of the goals of the unit test is also to validate that no more data is published than necessary. And this is a simplified example of course. In this example I want to make sure that all books have at least "isbn", "title" and "author". Next, some attribute might be present for some books, but not for others, like "price". This should be considered valid data. But if someone changes the code and adds for example "publisher" to the dataset, the unit test should fail. Of course it's impossible to say what attributes might be added in the future, but all should make the unit test fail.

assertJsonStructure() is great for validating that a minimal set of attributes are present, but that's not enough in this case.

jbloomstrom's avatar

@madman81 What if you wrap the response json in a collection and iterate over each item?

collect($json)->each(function(array $book) {
    $this->assertCount(3, array_keys($book));
    $this->assertTrue(isset($book['isbn'])); 
    $this->assertTrue(isset($book['title']));
    $this->assertTrue(isset($book['author']));
});

edit just read your last reply. maybe something like this where you check for disallowed attributes

$allowed = collect(['isbn','title','author','price']);

collect($json)->each(function(array $book) use ($allowed) {
    $keys = collect(array_keys($book));
	$this->assertCount(0, $keys->diff($allowed));
});
madman81's avatar
madman81
OP
Best Answer
Level 1

Because it seemed there wasn't really something off-the-shelf for what I was looking for, I made my own implementation. Hopefully it helps others! Feedback is welcome of course!

class JsonValidator
{
    /**
     * Validates a json string against a given structure. The format of the structure is the same as assertJsonStructure() uses.
     * This function will return false is there are unexpected elements in the data
     * If used in strict mode, it will also return false if elements are missing in the data
     *
     * @see https://laravel.com/docs/8.x/http-tests#assert-json-structure

     * @param array $structure
     * @param string $json
     * @param bool $strict
     * @return bool
     */
    public static function validateJson(array $structure, string $json, bool $strict = false): bool
    {
        $json_as_array = json_decode($json, true);

        return self::validateArrayStructure($structure, $json_as_array, $strict);
    }

    /**
     * Recursively validates an array ($data) against a given structure.
     * This function will return false is there are unexpected elements in the data
     * If used in strict mode, it will also return false if elements are missing in the data
     *
     * @param array $structure
     * @param array $data
     * @param bool $strict
     * @return bool
     */
    public static function validateArrayStructure(array $structure, array $data, bool $strict = false): bool
    {
        //First check the array keys at the main level
        if (!self::arrayKeysExist($structure, $data, $strict)) {
            return false;
        }

        foreach ($data as $sub_key => $sub_data) {
            if (is_array($sub_data)) {
                //If we find an array, it could be a sub structure or an array of sub structures

                if (isset($structure['*'])) {
                    //If the structure indicates a '*', this is an array of sub structures.
                    //Because the data is an anonymous array, we use the '*' as key ($sub_key doesn't point to a valid key in the structure)
                    $sub_structure = $structure['*'];
                } elseif (!array_key_exists($sub_key, $structure)){
                    //This is a key we didn't expect
                    return false;
                } else {
                    //This is just a normal sub structure
                    $sub_structure = $structure[$sub_key];
                }

                if (!self::validateArrayStructure($sub_structure, $sub_data, $strict)) {
                    return false;
                }
            }
        }

        return true;
    }

    /**
     * Checks if keys of the data array match the structure. If extra keys are found in the data, it returns false
     * If used in strict mode, it will also return false if keys in de data are missing.
     *
     * @param array $structure
     * @param array $data
     * @param bool $strict
     * @return bool
     */
    public static function arrayKeysExist(array $structure, array $data, bool $strict = false): bool
    {
        //If the structure is an empty array ('key' => []) and we have a one dimensional array, this is considered the same
        if (count($structure) == 0 && (count($data) == count($data, COUNT_RECURSIVE))) {
            return true;
        }

        // If the structure indicates a '*' then the first element in the data array should also be an array. If not, it's considered missing
        if (isset($structure['*'])) {
            return (is_array($data[0]));
        }

        //If there is a sub structure, we need the key, else the value
        // [ 'a', 'b', 'c' => 'd'] then we need [ 'a', 'b' and 'c' ]
        // array_keys() will return [ 0, 1, 'c' ] so that doesn't work
        $fake_structure = [];
        foreach($structure as $key => $value) {
            if (is_array($value)) {
                $fake_structure[$key] = $key;
            } else {
                $fake_structure[$value] = $value;
            }
        }

        $data_collection = collect($data);
        $structure_collection = collect($fake_structure);

        $diff_for_more = $data_collection->diffKeys($structure_collection);
        $diff_for_less = $structure_collection->diffKeys($data_collection);

        return (count($diff_for_more) == 0 && (!$strict || count($diff_for_less) == 0));
    }
}
2 likes
madman81's avatar

A unit test to show how to use it:

    public function testLargeJson()
    {
        $json = '{
            "library_name": "My books",
            "count": 2,
            "books": [
                {
                    "isbn": 123456,
                    "title": "Nice book",
                    "chapters": [
                        "Intro",
                        "Chapter 1"
                    ],
                    "authors": [
                        {
                            "name": "Awesome writer",
                            "sex": "Male",
                            "age": 45
                        },
                        {
                            "name": "Another writer",
                            "age": 54
                        }
                    ]
                },
                {
                    "isbn": 789123,
                    "title": "Great book",
                    "authors": [
                        {
                            "name": "Awesome writer",
                            "sex": "Male",
                            "age": 45
                        }
                    ]
                }
            ]
        }';

        $structure = [
            'library_name',
            'count',
            'books' => [
                '*' => [
                    'isbn',
                    'title',
                    'chapters' => [],
                    'authors' => [
                        '*' => [
                            'name',
                            'sex',
                            'age'
                        ],
                    ],
                ],
            ],
        ];

        $this->assertTrue(JsonValidator::validateJson($structure, $json));
        $this->assertFalse(JsonValidator::validateJson($structure, $json, true));

        unset($structure['books']['*'][1]); //Remove "title"

        $this->assertFalse(JsonValidator::validateJson($structure, $json));
        $this->assertFalse(JsonValidator::validateJson($structure, $json, true));
    }

Please or to participate in this conversation.