Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[8.x] Add Fluent JSON Assertions #36454

Merged
merged 4 commits into from
Mar 8, 2021
Merged

[8.x] Add Fluent JSON Assertions #36454

merged 4 commits into from
Mar 8, 2021

Conversation

claudiodekker
Copy link
Contributor

@claudiodekker claudiodekker commented Mar 3, 2021

This PR implements Inertia's Laravel Testing helpers as generic JSON helpers for Laravel.

While I initially wrote this code for Inertia specifically, one of the first things @reinink mentioned was that these would be amazing to have as first-party tooling to test JSON API's in Laravel, as (and we both share this opinion) the current JSON testing helpers can be difficult to use sometimes. It goes without saying that all Inertia-specific code has been stripped out.

Example

Here's an example of how this can be used, do note that while this shares the assertJson method, it is entirely backwards compatible:

use Illuminate\Testing\Fluent\Assert;

class PodcastsControllerTest extends TestCase
{
    public function test_can_view_podcast()
    {
        $this->get('/podcasts/41')
            ->assertJson(fn (Assert $json) => $json
                ->has('podcast', fn (Assert $json) => $json
                    ->where('id', $podcast->id)
                    ->where('subject', 'The Laravel Podcast')
                    ->where('description', 'The Laravel Podcast brings you Laravel & PHP development news.')
                    ->has('seasons', 4)
                    ->has('seasons.4.episodes', 21)
                    ->has('host', fn (Assert $json) => $json
                        ->where('id', 1)
                        ->where('name', 'Matt Stauffer')
                    )
                    ->has('subscribers', 7, fn (Assert $json) => $json
                        ->where('id', 2)
                        ->where('name', 'Claudio Dekker')
                        ->where('platform', 'Apple Podcasts')
                        ->etc()
                        ->missing('email')
                        ->missing('password')
                    )
                )
            );
    }
}

Basics

There's two ways to start using this, both of which rely on the 'Fluent Assert' class that this PR adds.
In the more-common scenario (testing a JSON API response), you would use it as already demonstrated above:

$response->assertJson(fn (Assert $json) => $json
    ->has('expected-property')
);   
 

Alternatively, you may instantiate the object manually, even on non-JSON arrays if you wish:

$assert = \Illuminate\Testing\Fluent\Assert::fromArray([/* ... */]);
$assert->has('expected-property');

Furthermore, while the scoping syntax above is by far the nicest to use with PHP 8's arrow functions, there's nothing preventing you from using standard functions.

Available Assertions

  • has
    • Count (Size / Length)
    • Scoping
  • where
  • etc
    • missing

Reducing verbosity (multiple assertions):

  • hasAll
  • whereAll
  • missingAll

Helpers:

  • Debugging (dump & dd)

Finally, the entire thing is Macroable and Tappable as well, so if someone really wants they can make use of those functionalities as well!

has

Basic Usage

To assert that your JSON response has a property, you may use the has method.
You can think of has similar to PHP's isset:

$response->assertJson(fn (Assert $json) => $json
    // Checking a root-level property
    ->has('podcast')

    // Checking that the podcast prop has a nested id property using "dot" notation
    ->has('podcast.id')
);

Count / Size / Length

To assert that your JSON response has a certain amount of items, you may provide the expected size as the second argument:

$response->assertJson(fn (Assert $json) => $json
    // Checking that the root-level podcasts property exists and has 7 items
    ->has('podcast', 7)

    // Checking that the podcast has 11 subscribers using "dot" notation
    ->has('podcast.subscribers', 11)
);

The above will first assert that the property exists, as well as that is the expected size.
This means that there is no need to manually ensure that the property exists using a separate has call.

Scoping

The deeper your assertions go, the more complex and verbose they can become:

$assert->where('message.comments.0.files.0.url', '/storage/attachments/example-attachment.pdf');
$assert->where('message.comments.0.files.0.name', 'example-attachment.pdf');

Fortunately, using scopes, we can remove this problem altogether through the has method:

$response->assertJson(fn (Assert $json) => $json
    // Creating a single-level property scope
    ->has('message', fn (Assert $json) => $json
        // We can now continue chaining methods
        ->has('subject')
        ->has('comments', 5)

        // And can even create a deeper scope using "dot" notation
        ->has('comments.0', fn (Assert $json) => $json
            ->has('body')
            ->has('files', 1)
            ->has('files.0', fn (Assert $json) => $json
                ->has('url')
            )
        )
    )
);

While this is already a significant improvement, that's not all: As you can see in the example above, you'll often run
into situations where you'll want to check that a property has a certain length, and then tap into one of the entries
to make sure that all the props there are as expected:

    ->has('comments', 5)
    ->has('comments.0', fn (Assert $json) => $json
        // ...

To simplify this, you can simply combine the two calls, providing the scope as the third argument:

$response->assertJson(fn (Assert $json) => $json
    // Assert that there are five comments, and automatically scope into the first comment.
    ->has('comments', 5, fn(Assert $json) => $json
        ->has('body')
        // ...
    )
);

where

To assert that an property has an expected value, you may use the where assertion:

$response->assertJson(fn (Assert $json) => $json
    ->has('message', fn (Assert $json) => $json
        // Assert that the subject prop matches the given message
        ->where('subject', 'This is an example message')

        // or, the exact same, but for deeply nested values
        ->where('comments.0.files.0.name', 'example-attachment.pdf')
    )
);

Under the hood, this first calls the has method to ensure that the property exists, and then uses an assertion to
make sure that the values match. This means that there is no need to manually call has and where on the same exact prop.

Automatic Eloquent Model / Arrayable casting

For convenience, the where method doesn't just assert using basic JSON values, but also has the ability to
test directly against Eloquent Models and other classes that implement the Arrayable interface.

For example:

$user = User::factory()->create(['name' => 'John Doe']);

// ... (Make your HTTP request etc.)

$response->assertJson(fn (Assert $json) => $json
    ->where('user', $user)
    ->where('deeply.nested.user', $user)
);

Using a Closure

Finally, it's also possible to assert against a callback / closure. To do so, simply provide a callback as the value,
and make sure that the response is true in order to make the assertion pass, or anything else to fail the assertion:

$response->assertJson(fn (Assert $json) => $json
    ->where('foo', fn ($value) => $value === 'bar')

    // or, as expected, for deeply nested values:
    ->where('deeply.nested.foo', function ($value) {
        return $value === 'bar';
    })
);

Because working with arrays directly isn't always a great experience, we'll automatically cast arrays to
Collections:

$response->assertJson(fn (Assert $json) => $json
    ->where('foo', function (Collection $value) {
        return $value->median() === 1.5;
    })
);

etc

This library will automatically fail your test when you haven't interacted with at least one of the props in a scope.
While this is generally useful, you might run into situations where you're working with unreliable data
(such as from a feed), or with data that you really don't want interact with, in order to keep your test simple.
For those situations, the etc method exists:

$response->assertJson(fn (Assert $json) => $json
    ->has('message', fn (Assert $json) => $json
        ->has('subject')
        ->has('comments')
        ->etc()
    )
);

IMPORTANT: This automatic property check DOES NOT APPLY TO TOP-LEVEL PROPS and only works in scopes.

NOTE: While etc reads fluently at the end of a query scope, placing it at the beginning or somewhere in the middle of your assertions does not change how it behaves: It will disable the automatic check that asserts that all properties in the current scope have been interacted with.

missing

Because missing isn't necessary by default, it provides a great solution when using etc.

In short, it does the exact opposite of the has method, ensuring that the property does not exist:

$response->assertJson(fn (Assert $json) => $json
    ->has('message', fn (Assert $json) => $json
        ->has('subject')
        ->missing('published_at')
        ->etc()
    )
);

Reducing verbosity

To reduce the amount of where, has or missing calls, there are a couple of convenience methods that allow you to
make these same assertions in a slightly less-verbose looking way. Do note that these methods do not make your assertions
any faster, and really only exist to help you reduce your test's visual complexity.

has

Instead of making multiple has calls, you may use the hasAll assertion instead. Depending on how you provide
arguments, this method will perform a series of slightly different but predictable assertion:

Basic has usage

$response->assertJson(fn (Assert $json) => $json
    // Before
    ->has('messages')
    ->has('subscribers')

    // After
    ->hasAll([
        'messages',
        'subscribers',
    ])

    // Alternative
    ->hasAll('messages', 'subscribers')
);

Count / Size / Length

$response->assertJson(fn (Assert $json) => $json
    // Before
    ->has('messages', 5)
    ->has('subscribers', 11)

    // After
    ->hasAll([
        'messages' => 5,
        'subscribers' => 11,
    ])
);

where

To reduce the amount of where calls, the whereAll method exists.

Since this method checks properties against values by design, there isn't a lot of flexibility like with some of these
other methods, meaning that only the array-syntax exists for it right now:

$response->assertJson(fn (Assert $json) => $json
    // Before
    ->where('subject', 'Hello World')
    ->has('user.name', 'Claudio')

    // After
    ->whereAll([
        'subject' => 'Hello World',
        'user.name' => fn ($value) => $value === 'Claudio',
    ])
);

missing

Instead of making multiple missing call, you may use missingAll instead.

Similar to basic hasAll usage, this assertion accepts both a single array or a list of arguments, at which point it
will assert that the given props do not exist:

$response->assertJson(fn (Assert $json) => $json
    // Before
    ->missing('subject')
    ->missing('user.name')

    // After
    ->missingAll([
        'subject',
        'user.name',
    ])

    // Alternative
    ->missingAll('subject', 'user.name')
);

Debugging

While writing your tests, you might find yourself wanting to inspect some of the page's props using Laravel's
dump or dd helpers. Luckily, this is really easy to do, and would work more or less how you'd expect it to:

$response->assertJson(fn (Assert $json) => $json
    // Dumping all props in the current scope
    // while still running all other assertions
    ->dump()
    ->where('user.name', 'Claudio')

    // Dump-and-die all props in the current scope, preventing
    // all other (perhaps failing) assertions from running
    ->dd()
    ->where('user.name', 'Jonathan')

    // Dumping / Dump-and-die a specific prop
    ->dump('user')
    ->dd('user.name')
);

@claudiodekker claudiodekker marked this pull request as ready for review March 4, 2021 17:12
@claudiodekker claudiodekker changed the title [8.x] Implement Fluent JSON Assertions [8.x] Add Fluent JSON Assertions Mar 4, 2021
@taylorotwell
Copy link
Member

Can you explain the etc method a bit more? I didn't quite understand the part about failing a test if you don't interact with at least one property.

@claudiodekker
Copy link
Contributor Author

claudiodekker commented Mar 6, 2021

Sure! Since the etc method ties in directly with the "interaction check", I'll explain both of these:

Explaining the interaction check

So, given the following JSON payload:

{
   "user": {
      "id": 5,
      "name": "Claudio Dekker",
      "email": "claudio@example.com",
      "created_at": "2020-01-01 00:00:00",
      "updated_at": "2020-01-01 00:00:00",
      "posts": [
         {
            "id": 12,
            "user_id": 5,
            "title": "This is a post title",
            "summary": "Once upon a time..",
            "created_at": "2020-01-01 01:00:00",
            "updated_at": "2020-01-01 00:00:00",
         }
      ]
   }
}

When you create a has scope, the internal logic starts tracking whether you interacted with each property in the scope.
For instance, in the following has-scope, I'm interacting with only the id and name:

$response->assertJson(fn (Assert $json) => $json
    // Start a new 'has' scope.
    ->has('user', fn (Assert $json) => $json
        ->where('id', 5)
        ->where('name', "Claudio Dekker")
    )
);

Because we haven't interacted with the other properties in the scope (email, created_at, updated_at, posts), this will automatically cause the tests to fail.

The reason this logic exists is pretty straight forward: If we didn't have this, and we were to somehow accidentally expose the password hash, the tests would still pass unless we were to create a separate assertion for this (for example: counting the array items, an array_keys-check, or directly comparing the entire "user" array)

So by automatically handling this internally instead, the user always gets this benefit and peace-of-mind of knowing they haven't missed anything without any additional effort.

Explaining the etc-method

Now, let's say we really only care about the 1-2 props that are relevant to our system, and not about the (perhaps) 100's of other props. In those cases, this 'feature' of automatically failing the test can quickly start to feel more like a limitation, as it suddenly requires us to somehow still 'interact' with all props in order to get the tests to pass.

This is where the etc method comes in: It allows you to disable the interaction check for all properties in that scope entirely, while still giving you the benefits of making your assertions in a scoped manner:

$response->assertJson(fn (Assert $json) => $json
    // Start a new 'has' scope.
    ->has('user', fn (Assert $json) => $json
        ->where('id', 5)
        ->where('name', "Claudio Dekker")
        ->etc()
    )
);

Problem solved.. right? Well..

Explaining the missing-method

While the tests (using the above) will now pass, the entire benefit I explained earlier regarding making sure that the password hash isn't included has gone out of the window as well. That's where the missing method comes in: It allows you to ensure that a certain property isn't present:

$response->assertJson(fn (Assert $json) => $json
    // Start a new 'has' scope.
    ->has('user', fn (Assert $json) => $json
        ->where('id', 5)
        ->where('name', "Claudio Dekker")
        ->missing('password')
        ->etc()
    )
);

If some of this still doesn't 100% make sense, perhaps it would make sense to look at the code instead. It all lives in a dedicated (~15 actual LOCs) Trait. Internally, whenever you create a has-scope, it first calls that callback (to run your assertions) and directly after before returning out of that method calls ->interacted() on it, so that's the entrypoint to this logic.

Finally, the last thing I'd like to mention, is that while this would be really easy to enable for top-level props as well (directly when you call $response->assertJson(fn (Assert $json) => $json ..), this is currently not the case, making it limited to the has-scopes only. I originally did have this behaviour enabled, but we quickly figured with Inertia that more often than not you actually don't really care about the top-level props, because those are usually manually set to the view/response anyway.

Hope this makes sense!

@taylorotwell taylorotwell merged commit c5eadc4 into laravel:8.x Mar 8, 2021
@claudiodekker claudiodekker deleted the assert-json-fluent branch March 8, 2021 18:07
@williamxsp
Copy link

@claudiodekker
Is it possible to assert all properties inside an array using fluent json?

$response
    ->assertJson(fn (AssertableJson $json) =>
        $json->has('meta')
             ->has('users', 3)
             ->has('users.0', fn ($json) =>
                $json->where('id', 1)
                     ->where('name', 'Victoria Faith')
                     ->missing('password')
                     ->etc()
             )
    );

Here it is asserting just the first
But what if I want to check if all users has an 'active' property = true

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants