From add249472cd192cabcb4f113ff7915f667394141 Mon Sep 17 00:00:00 2001 From: Claudio Dekker <1752195+claudiodekker@users.noreply.github.com> Date: Wed, 3 Mar 2021 20:07:50 +0100 Subject: [PATCH 1/4] Implement Fluent JSON Assertions --- src/Illuminate/Testing/Fluent/Assert.php | 76 ++ .../Testing/Fluent/Concerns/Debugging.php | 20 + .../Testing/Fluent/Concerns/Has.php | 100 +++ .../Testing/Fluent/Concerns/Interaction.php | 41 ++ .../Testing/Fluent/Concerns/Matching.php | 70 ++ src/Illuminate/Testing/TestResponse.php | 20 +- tests/Testing/Fluent/AssertTest.php | 688 ++++++++++++++++++ tests/Testing/Stubs/ArrayableStubObject.php | 25 + tests/Testing/TestResponseTest.php | 22 + 9 files changed, 1059 insertions(+), 3 deletions(-) create mode 100644 src/Illuminate/Testing/Fluent/Assert.php create mode 100644 src/Illuminate/Testing/Fluent/Concerns/Debugging.php create mode 100644 src/Illuminate/Testing/Fluent/Concerns/Has.php create mode 100644 src/Illuminate/Testing/Fluent/Concerns/Interaction.php create mode 100644 src/Illuminate/Testing/Fluent/Concerns/Matching.php create mode 100644 tests/Testing/Fluent/AssertTest.php create mode 100644 tests/Testing/Stubs/ArrayableStubObject.php diff --git a/src/Illuminate/Testing/Fluent/Assert.php b/src/Illuminate/Testing/Fluent/Assert.php new file mode 100644 index 000000000000..a5e89e83294c --- /dev/null +++ b/src/Illuminate/Testing/Fluent/Assert.php @@ -0,0 +1,76 @@ +path = $path; + $this->props = $props; + } + + protected function dotPath($key): string + { + if (is_null($this->path)) { + return $key; + } + + return implode('.', [$this->path, $key]); + } + + protected function prop(string $key = null) + { + return Arr::get($this->props, $key); + } + + protected function scope($key, Closure $callback): self + { + $props = $this->prop($key); + $path = $this->dotPath($key); + + PHPUnit::assertIsArray($props, sprintf('Property [%s] is not scopeable.', $path)); + + $scope = new self($props, $path); + $callback($scope); + $scope->interacted(); + + return $this; + } + + public static function fromArray(array $data): self + { + return new self($data); + } + + public static function fromAssertableJsonString(AssertableJsonString $json): self + { + return self::fromArray($json->json()); + } + + public function toArray() + { + return $this->props; + } +} diff --git a/src/Illuminate/Testing/Fluent/Concerns/Debugging.php b/src/Illuminate/Testing/Fluent/Concerns/Debugging.php new file mode 100644 index 000000000000..d604f0e0b21d --- /dev/null +++ b/src/Illuminate/Testing/Fluent/Concerns/Debugging.php @@ -0,0 +1,20 @@ +prop($prop)); + + return $this; + } + + public function dd(string $prop = null): void + { + dd($this->prop($prop)); + } + + abstract protected function prop(string $key = null); +} diff --git a/src/Illuminate/Testing/Fluent/Concerns/Has.php b/src/Illuminate/Testing/Fluent/Concerns/Has.php new file mode 100644 index 000000000000..28955a742493 --- /dev/null +++ b/src/Illuminate/Testing/Fluent/Concerns/Has.php @@ -0,0 +1,100 @@ +prop($key), + sprintf('Property [%s] does not have the expected size.', $this->dotPath($key)) + ); + + return $this; + } + + public function hasAll($key): self + { + $keys = is_array($key) ? $key : func_get_args(); + + foreach ($keys as $prop => $count) { + if (is_int($prop)) { + $this->has($count); + } else { + $this->has($prop, $count); + } + } + + return $this; + } + + public function has(string $key, $value = null, Closure $scope = null): self + { + $prop = $this->prop(); + + PHPUnit::assertTrue( + Arr::has($prop, $key), + sprintf('Property [%s] does not exist.', $this->dotPath($key)) + ); + + $this->interactsWith($key); + + // When all three arguments are provided, this indicates a short-hand + // expression that combines both a `count`-assertion, followed by + // directly creating a `scope` on the first element. + if (is_int($value) && ! is_null($scope)) { + $prop = $this->prop($key); + $path = $this->dotPath($key); + + PHPUnit::assertTrue($value > 0, sprintf('Cannot scope directly onto the first entry of property [%s] when asserting that it has a size of 0.', $path)); + PHPUnit::assertIsArray($prop, sprintf('Direct scoping is unsupported for non-array like properties such as [%s].', $path)); + + $this->count($key, $value); + + return $this->scope($key.'.'.array_keys($prop)[0], $scope); + } + + if (is_callable($value)) { + $this->scope($key, $value); + } elseif (! is_null($value)) { + $this->count($key, $value); + } + + return $this; + } + + public function missingAll($key): self + { + $keys = is_array($key) ? $key : func_get_args(); + + foreach ($keys as $prop) { + $this->missing($prop); + } + + return $this; + } + + public function missing(string $key): self + { + PHPUnit::assertNotTrue( + Arr::has($this->prop(), $key), + sprintf('Property [%s] was found while it was expected to be missing.', $this->dotPath($key)) + ); + + return $this; + } + + abstract protected function prop(string $key = null); + + abstract protected function dotPath($key): string; + + abstract protected function interactsWith(string $key): void; + + abstract protected function scope($key, Closure $callback); +} diff --git a/src/Illuminate/Testing/Fluent/Concerns/Interaction.php b/src/Illuminate/Testing/Fluent/Concerns/Interaction.php new file mode 100644 index 000000000000..d938438a618c --- /dev/null +++ b/src/Illuminate/Testing/Fluent/Concerns/Interaction.php @@ -0,0 +1,41 @@ +interacted, true)) { + $this->interacted[] = $prop; + } + } + + public function interacted(): void + { + PHPUnit::assertSame( + [], + array_diff(array_keys($this->prop()), $this->interacted), + $this->path + ? sprintf('Unexpected properties were found in scope [%s].', $this->path) + : 'Unexpected properties were found on the root level.' + ); + } + + public function etc(): self + { + $this->interacted = array_keys($this->prop()); + + return $this; + } + + abstract protected function prop(string $key = null); +} diff --git a/src/Illuminate/Testing/Fluent/Concerns/Matching.php b/src/Illuminate/Testing/Fluent/Concerns/Matching.php new file mode 100644 index 000000000000..3edce127d362 --- /dev/null +++ b/src/Illuminate/Testing/Fluent/Concerns/Matching.php @@ -0,0 +1,70 @@ + $value) { + $this->where($key, $value); + } + + return $this; + } + + public function where($key, $expected): self + { + $this->has($key); + + $actual = $this->prop($key); + + if ($expected instanceof Closure) { + PHPUnit::assertTrue( + $expected(is_array($actual) ? Collection::make($actual) : $actual), + sprintf('Property [%s] was marked as invalid using a closure.', $this->dotPath($key)) + ); + + return $this; + } + + if ($expected instanceof Arrayable) { + $expected = $expected->toArray(); + } + + $this->ensureSorted($expected); + $this->ensureSorted($actual); + + PHPUnit::assertSame( + $expected, + $actual, + sprintf('Property [%s] does not match the expected value.', $this->dotPath($key)) + ); + + return $this; + } + + protected function ensureSorted(&$value): void + { + if (! is_array($value)) { + return; + } + + foreach ($value as &$arg) { + $this->ensureSorted($arg); + } + + ksort($value); + } + + abstract protected function dotPath($key): string; + + abstract protected function prop(string $key = null); + + abstract public function has(string $key, $value = null, Closure $scope = null); +} diff --git a/src/Illuminate/Testing/TestResponse.php b/src/Illuminate/Testing/TestResponse.php index 1bfc75285518..b900418caf79 100644 --- a/src/Illuminate/Testing/TestResponse.php +++ b/src/Illuminate/Testing/TestResponse.php @@ -14,6 +14,7 @@ use Illuminate\Support\Traits\Tappable; use Illuminate\Testing\Assert as PHPUnit; use Illuminate\Testing\Constraints\SeeInOrder; +use Illuminate\Testing\Fluent\Assert as FluentAssert; use LogicException; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -507,13 +508,26 @@ public function assertDontSeeText($value, $escape = true) /** * Assert that the response is a superset of the given JSON. * - * @param array $data + * @param array|callable $value * @param bool $strict * @return $this */ - public function assertJson(array $data, $strict = false) + public function assertJson($value, $strict = false) { - $this->decodeResponseJson()->assertSubset($data, $strict); + $json = $this->decodeResponseJson(); + + if (is_array($value)) { + $json->assertSubset($value, $strict); + } else { + $assert = FluentAssert::fromAssertableJsonString($json); + + $value($assert); + + if ($strict) { + $assert->interacted(); + } + } + return $this; } diff --git a/tests/Testing/Fluent/AssertTest.php b/tests/Testing/Fluent/AssertTest.php new file mode 100644 index 000000000000..f02e366f40a0 --- /dev/null +++ b/tests/Testing/Fluent/AssertTest.php @@ -0,0 +1,688 @@ + 'value', + ]); + + $assert->has('prop'); + } + + public function testAssertHasFailsWhenPropMissing() + { + $assert = Assert::fromArray([ + 'bar' => 'value', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [prop] does not exist.'); + + $assert->has('prop'); + } + + public function testAssertHasNestedProp() + { + $assert = Assert::fromArray([ + 'example' => [ + 'nested' => 'nested-value', + ], + ]); + + $assert->has('example.nested'); + } + + public function testAssertHasFailsWhenNestedPropMissing() + { + $assert = Assert::fromArray([ + 'example' => [ + 'nested' => 'nested-value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [example.another] does not exist.'); + + $assert->has('example.another'); + } + + public function testAssertCountItemsInProp() + { + $assert = Assert::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $assert->has('bar', 2); + } + + public function testAssertCountFailsWhenAmountOfItemsDoesNotMatch() + { + $assert = Assert::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] does not have the expected size.'); + + $assert->has('bar', 1); + } + + public function testAssertCountFailsWhenPropMissing() + { + $assert = Assert::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] does not exist.'); + + $assert->has('baz', 1); + } + + public function testAssertHasFailsWhenSecondArgumentUnsupportedType() + { + $assert = Assert::fromArray([ + 'bar' => 'baz', + ]); + + $this->expectException(TypeError::class); + + $assert->has('bar', 'invalid'); + } + + public function testAssertMissing() + { + $assert = Assert::fromArray([ + 'foo' => [ + 'bar' => true, + ], + ]); + + $assert->missing('foo.baz'); + } + + public function testAssertMissingFailsWhenPropExists() + { + $assert = Assert::fromArray([ + 'prop' => 'value', + 'foo' => [ + 'bar' => true, + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo.bar] was found while it was expected to be missing.'); + + $assert->missing('foo.bar'); + } + + public function testAssertMissingAll() + { + $assert = Assert::fromArray([ + 'baz' => 'foo', + ]); + + $assert->missingAll([ + 'foo', + 'bar', + ]); + } + + public function testAssertMissingAllFailsWhenAtLeastOnePropExists() + { + $assert = Assert::fromArray([ + 'baz' => 'foo', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] was found while it was expected to be missing.'); + + $assert->missingAll([ + 'bar', + 'baz', + ]); + } + + public function testAssertMissingAllAcceptsMultipleArgumentsInsteadOfArray() + { + $assert = Assert::fromArray([ + 'baz' => 'foo', + ]); + + $assert->missingAll('foo', 'bar'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] was found while it was expected to be missing.'); + + $assert->missingAll('bar', 'baz'); + } + + public function testAssertWhereMatchesValue() + { + $assert = Assert::fromArray([ + 'bar' => 'value', + ]); + + $assert->where('bar', 'value'); + } + + public function testAssertWhereFailsWhenDoesNotMatchValue() + { + $assert = Assert::fromArray([ + 'bar' => 'value', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] does not match the expected value.'); + + $assert->where('bar', 'invalid'); + } + + public function testAssertWhereFailsWhenMissing() + { + $assert = Assert::fromArray([ + 'bar' => 'value', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] does not exist.'); + + $assert->where('baz', 'invalid'); + } + + public function testAssertWhereFailsWhenMachingLoosely() + { + $assert = Assert::fromArray([ + 'bar' => 1, + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] does not match the expected value.'); + + $assert->where('bar', true); + } + + public function testAssertWhereUsingClosure() + { + $assert = Assert::fromArray([ + 'bar' => 'baz', + ]); + + $assert->where('bar', function ($value) { + return $value === 'baz'; + }); + } + + public function testAssertWhereFailsWhenDoesNotMatchValueUsingClosure() + { + $assert = Assert::fromArray([ + 'bar' => 'baz', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] was marked as invalid using a closure.'); + + $assert->where('bar', function ($value) { + return $value === 'invalid'; + }); + } + + public function testAssertWhereClosureArrayValuesAreAutomaticallyCastedToCollections() + { + $assert = Assert::fromArray([ + 'bar' => [ + 'baz' => 'foo', + 'example' => 'value', + ], + ]); + + $assert->where('bar', function ($value) { + $this->assertInstanceOf(Collection::class, $value); + + return $value->count() === 2; + }); + } + + public function testAssertWhereMatchesValueUsingArrayable() + { + $stub = ArrayableStubObject::make(['foo' => 'bar']); + + $assert = Assert::fromArray([ + 'bar' => $stub->toArray(), + ]); + + $assert->where('bar', $stub); + } + + public function testAssertWhereMatchesValueUsingArrayableWhenSortedDifferently() + { + $assert = Assert::fromArray([ + 'bar' => [ + 'baz' => 'foo', + 'example' => 'value', + ], + ]); + + $assert->where('bar', function ($value) { + $this->assertInstanceOf(Collection::class, $value); + + return $value->count() === 2; + }); + } + + public function testAssertWhereFailsWhenDoesNotMatchValueUsingArrayable() + { + $assert = Assert::fromArray([ + 'bar' => ['id' => 1, 'name' => 'Example'], + 'baz' => [ + 'id' => 1, + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + 'email_verified_at' => '2021-01-22T10:34:42.000000Z', + 'created_at' => '2021-01-22T10:34:42.000000Z', + 'updated_at' => '2021-01-22T10:34:42.000000Z', + ], + ]); + + $assert + ->where('bar', ArrayableStubObject::make(['name' => 'Example', 'id' => 1])) + ->where('baz', [ + 'name' => 'Taylor Otwell', + 'email' => 'taylor@laravel.com', + 'id' => 1, + 'email_verified_at' => '2021-01-22T10:34:42.000000Z', + 'updated_at' => '2021-01-22T10:34:42.000000Z', + 'created_at' => '2021-01-22T10:34:42.000000Z', + ]); + } + + public function testAssertNestedWhereMatchesValue() + { + $assert = Assert::fromArray([ + 'example' => [ + 'nested' => 'nested-value', + ], + ]); + + $assert->where('example.nested', 'nested-value'); + } + + public function testAssertNestedWhereFailsWhenDoesNotMatchValue() + { + $assert = Assert::fromArray([ + 'example' => [ + 'nested' => 'nested-value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [example.nested] does not match the expected value.'); + + $assert->where('example.nested', 'another-value'); + } + + public function testScope() + { + $assert = Assert::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $called = false; + $assert->has('bar', function (Assert $assert) use (&$called) { + $called = true; + $assert + ->where('baz', 'example') + ->where('prop', 'value'); + }); + + $this->assertTrue($called, 'The scoped query was never actually called.'); + } + + public function testScopeFailsWhenPropMissing() + { + $assert = Assert::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] does not exist.'); + + $assert->has('baz', function (Assert $item) { + $item->where('baz', 'example'); + }); + } + + public function testScopeFailsWhenPropSingleValue() + { + $assert = Assert::fromArray([ + 'bar' => 'value', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] is not scopeable.'); + + $assert->has('bar', function (Assert $item) { + // + }); + } + + public function testScopeShorthand() + { + $assert = Assert::fromArray([ + 'bar' => [ + ['key' => 'first'], + ['key' => 'second'], + ], + ]); + + $called = false; + $assert->has('bar', 2, function (Assert $item) use (&$called) { + $item->where('key', 'first'); + $called = true; + }); + + $this->assertTrue($called, 'The scoped query was never actually called.'); + } + + public function testScopeShorthandFailsWhenAssertingZeroItems() + { + $assert = Assert::fromArray([ + 'bar' => [ + ['key' => 'first'], + ['key' => 'second'], + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Cannot scope directly onto the first entry of property [bar] when asserting that it has a size of 0.'); + + $assert->has('bar', 0, function (Assert $item) { + $item->where('key', 'first'); + }); + } + + public function testScopeShorthandFailsWhenAmountOfItemsDoesNotMatch() + { + $assert = Assert::fromArray([ + 'bar' => [ + ['key' => 'first'], + ['key' => 'second'], + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [bar] does not have the expected size.'); + + $assert->has('bar', 1, function (Assert $item) { + $item->where('key', 'first'); + }); + } + + public function testFailsWhenNotInteractingWithAllPropsInScope() + { + $assert = Assert::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected properties were found in scope [bar].'); + + $assert->has('bar', function (Assert $item) { + $item->where('baz', 'example'); + }); + } + + public function testDisableInteractionCheckForCurrentScope() + { + $assert = Assert::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $assert->has('bar', function (Assert $item) { + $item->etc(); + }); + } + + public function testCannotDisableInteractionCheckForDifferentScopes() + { + $assert = Assert::fromArray([ + 'bar' => [ + 'baz' => [ + 'foo' => 'bar', + 'example' => 'value', + ], + 'prop' => 'value', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected properties were found in scope [bar.baz].'); + + $assert->has('bar', function (Assert $item) { + $item + ->etc() + ->has('baz', function (Assert $item) { + // + }); + }); + } + + public function testTopLevelPropInteractionDisabledByDefault() + { + $assert = Assert::fromArray([ + 'foo' => 'bar', + 'bar' => 'baz', + ]); + + $assert->has('foo'); + } + + public function testTopLevelInteractionEnabledWhenInteractedFlagSet() + { + $assert = Assert::fromArray([ + 'foo' => 'bar', + 'bar' => 'baz', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected properties were found on the root level.'); + + $assert + ->has('foo') + ->interacted(); + } + + public function testAssertWhereAllMatchesValues() + { + $assert = Assert::fromArray([ + 'foo' => [ + 'bar' => 'value', + 'example' => ['hello' => 'world'], + ], + 'baz' => 'another', + ]); + + $assert->whereAll([ + 'foo.bar' => 'value', + 'foo.example' => ArrayableStubObject::make(['hello' => 'world']), + 'baz' => function ($value) { + return $value === 'another'; + }, + ]); + } + + public function testAssertWhereAllFailsWhenAtLeastOnePropDoesNotMatchValue() + { + $assert = Assert::fromArray([ + 'foo' => 'bar', + 'baz' => 'example', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] was marked as invalid using a closure.'); + + $assert->whereAll([ + 'foo' => 'bar', + 'baz' => function ($value) { + return $value === 'foo'; + }, + ]); + } + + public function testAssertHasAll() + { + $assert = Assert::fromArray([ + 'foo' => [ + 'bar' => 'value', + 'example' => ['hello' => 'world'], + ], + 'baz' => 'another', + ]); + + $assert->hasAll([ + 'foo.bar', + 'foo.example', + 'baz', + ]); + } + + public function testAssertHasAllFailsWhenAtLeastOnePropMissing() + { + $assert = Assert::fromArray([ + 'foo' => [ + 'bar' => 'value', + 'example' => ['hello' => 'world'], + ], + 'baz' => 'another', + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo.baz] does not exist.'); + + $assert->hasAll([ + 'foo.bar', + 'foo.baz', + 'baz', + ]); + } + + public function testAssertHasAllAcceptsMultipleArgumentsInsteadOfArray() + { + $assert = Assert::fromArray([ + 'foo' => [ + 'bar' => 'value', + 'example' => ['hello' => 'world'], + ], + 'baz' => 'another', + ]); + + $assert->hasAll('foo.bar', 'foo.example', 'baz'); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [foo.baz] does not exist.'); + + $assert->hasAll('foo.bar', 'foo.baz', 'baz'); + } + + public function testAssertCountMultipleProps() + { + $assert = Assert::fromArray([ + 'bar' => [ + 'key' => 'value', + 'prop' => 'example', + ], + 'baz' => [ + 'another' => 'value', + ], + ]); + + $assert->hasAll([ + 'bar' => 2, + 'baz' => 1, + ]); + } + + public function testAssertCountMultiplePropsFailsWhenPropMissing() + { + $assert = Assert::fromArray([ + 'bar' => [ + 'key' => 'value', + 'prop' => 'example', + ], + ]); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Property [baz] does not exist.'); + + $assert->hasAll([ + 'bar' => 2, + 'baz' => 1, + ]); + } + + public function testMacroable() + { + Assert::macro('myCustomMacro', function () { + throw new RuntimeException('My Custom Macro was called!'); + }); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('My Custom Macro was called!'); + + $assert = Assert::fromArray(['foo' => 'bar']); + $assert->myCustomMacro(); + } + + public function testTappable() + { + $assert = Assert::fromArray([ + 'bar' => [ + 'baz' => 'example', + 'prop' => 'value', + ], + ]); + + $called = false; + $assert->has('bar', function (Assert $assert) use (&$called) { + $assert->etc(); + $assert->tap(function (Assert $assert) use (&$called) { + $called = true; + }); + }); + + $this->assertTrue($called, 'The scoped query was never actually called.'); + } +} diff --git a/tests/Testing/Stubs/ArrayableStubObject.php b/tests/Testing/Stubs/ArrayableStubObject.php new file mode 100644 index 000000000000..021440e0b287 --- /dev/null +++ b/tests/Testing/Stubs/ArrayableStubObject.php @@ -0,0 +1,25 @@ +data = $data; + } + + public static function make($data = []) + { + return new self($data); + } + + public function toArray() + { + return $this->data; + } +} diff --git a/tests/Testing/TestResponseTest.php b/tests/Testing/TestResponseTest.php index 42d16e72d3fa..055519925cb9 100644 --- a/tests/Testing/TestResponseTest.php +++ b/tests/Testing/TestResponseTest.php @@ -9,6 +9,7 @@ use Illuminate\Encryption\Encrypter; use Illuminate\Filesystem\Filesystem; use Illuminate\Http\Response; +use Illuminate\Testing\Fluent\Assert; use Illuminate\Testing\TestResponse; use JsonSerializable; use Mockery as m; @@ -577,6 +578,27 @@ public function testAssertJsonWithNull() $response->assertJson($resource->jsonSerialize()); } + public function testAssertJsonWithFluent() + { + $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableSingleResourceStub)); + + $response->assertJson(function (Assert $json) { + $json->where('0.foo', 'foo 0'); + }); + } + + public function testAssertJsonWithFluentStrict() + { + $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableSingleResourceStub)); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected properties were found on the root level.'); + + $response->assertJson(function (Assert $json) { + $json->where('0.foo', 'foo 0'); + }, true); + } + public function testAssertSimilarJsonWithMixed() { $response = TestResponse::fromBaseResponse(new Response(new JsonSerializableMixedResourcesStub)); From f31557f76ac5a790d66b717e074537031c48085e Mon Sep 17 00:00:00 2001 From: Claudio Dekker <1752195+claudiodekker@users.noreply.github.com> Date: Wed, 3 Mar 2021 20:20:34 +0100 Subject: [PATCH 2/4] Apply fixes from StyleCI --- src/Illuminate/Testing/TestResponse.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Illuminate/Testing/TestResponse.php b/src/Illuminate/Testing/TestResponse.php index b900418caf79..eb983f464a6d 100644 --- a/src/Illuminate/Testing/TestResponse.php +++ b/src/Illuminate/Testing/TestResponse.php @@ -528,7 +528,6 @@ public function assertJson($value, $strict = false) } } - return $this; } From 91bd93f92c26cc58c25849f1a1ac6d7ee6d55411 Mon Sep 17 00:00:00 2001 From: Claudio Dekker <1752195+claudiodekker@users.noreply.github.com> Date: Thu, 4 Mar 2021 17:29:43 +0100 Subject: [PATCH 3/4] Add docblocks & minor cleanup --- src/Illuminate/Testing/Fluent/Assert.php | 58 +++++++++- .../Testing/Fluent/Concerns/Debugging.php | 18 ++++ .../Testing/Fluent/Concerns/Has.php | 100 ++++++++++++++---- .../Testing/Fluent/Concerns/Interaction.php | 28 ++++- .../Testing/Fluent/Concerns/Matching.php | 65 +++++++++--- 5 files changed, 230 insertions(+), 39 deletions(-) diff --git a/src/Illuminate/Testing/Fluent/Assert.php b/src/Illuminate/Testing/Fluent/Assert.php index a5e89e83294c..926ef09656ac 100644 --- a/src/Illuminate/Testing/Fluent/Assert.php +++ b/src/Illuminate/Testing/Fluent/Assert.php @@ -19,19 +19,39 @@ class Assert implements Arrayable Macroable, Tappable; - /** @var array */ + /** + * The properties in the current scope. + * + * @var array + */ private $props; - /** @var string */ + /** + * The "dot" path to the current scope. + * + * @var string|null + */ private $path; + /** + * Create a new Assert instance. + * + * @param array $props + * @param string|null $path + */ protected function __construct(array $props, string $path = null) { $this->path = $path; $this->props = $props; } - protected function dotPath($key): string + /** + * Compose the absolute "dot" path to the given key. + * + * @param string $key + * @return string + */ + protected function dotPath(string $key): string { if (is_null($this->path)) { return $key; @@ -40,12 +60,25 @@ protected function dotPath($key): string return implode('.', [$this->path, $key]); } + /** + * Retrieve a prop within the current scope using "dot" notation. + * + * @param string|null $key + * @return mixed + */ protected function prop(string $key = null) { return Arr::get($this->props, $key); } - protected function scope($key, Closure $callback): self + /** + * Instantiate a new "scope" at the path of the given key. + * + * @param string $key + * @param Closure $callback + * @return $this + */ + protected function scope(string $key, Closure $callback): self { $props = $this->prop($key); $path = $this->dotPath($key); @@ -59,16 +92,33 @@ protected function scope($key, Closure $callback): self return $this; } + /** + * Create a new instance from an array. + * + * @param array $data + * @return static + */ public static function fromArray(array $data): self { return new self($data); } + /** + * Create a new instance from a AssertableJsonString. + * + * @param AssertableJsonString $json + * @return static + */ public static function fromAssertableJsonString(AssertableJsonString $json): self { return self::fromArray($json->json()); } + /** + * Get the instance as an array. + * + * @return array + */ public function toArray() { return $this->props; diff --git a/src/Illuminate/Testing/Fluent/Concerns/Debugging.php b/src/Illuminate/Testing/Fluent/Concerns/Debugging.php index d604f0e0b21d..f51d119074ae 100644 --- a/src/Illuminate/Testing/Fluent/Concerns/Debugging.php +++ b/src/Illuminate/Testing/Fluent/Concerns/Debugging.php @@ -4,6 +4,12 @@ trait Debugging { + /** + * Dumps the given props. + * + * @param string|null $prop + * @return $this + */ public function dump(string $prop = null): self { dump($this->prop($prop)); @@ -11,10 +17,22 @@ public function dump(string $prop = null): self return $this; } + /** + * Dumps the given props and exits. + * + * @param string|null $prop + * @return void + */ public function dd(string $prop = null): void { dd($this->prop($prop)); } + /** + * Retrieve a prop within the current scope using "dot" notation. + * + * @param string|null $key + * @return mixed + */ abstract protected function prop(string $key = null); } diff --git a/src/Illuminate/Testing/Fluent/Concerns/Has.php b/src/Illuminate/Testing/Fluent/Concerns/Has.php index 28955a742493..746fa49b60f1 100644 --- a/src/Illuminate/Testing/Fluent/Concerns/Has.php +++ b/src/Illuminate/Testing/Fluent/Concerns/Has.php @@ -8,7 +8,14 @@ trait Has { - protected function count(string $key, $length): self + /** + * Assert that the prop is of the expected size. + * + * @param string $key + * @param int $length + * @return $this + */ + protected function count(string $key, int $length): self { PHPUnit::assertCount( $length, @@ -19,21 +26,14 @@ protected function count(string $key, $length): self return $this; } - public function hasAll($key): self - { - $keys = is_array($key) ? $key : func_get_args(); - - foreach ($keys as $prop => $count) { - if (is_int($prop)) { - $this->has($count); - } else { - $this->has($prop, $count); - } - } - - return $this; - } - + /** + * Ensure that the given prop exists. + * + * @param string $key + * @param null $value + * @param Closure|null $scope + * @return $this + */ public function has(string $key, $value = null, Closure $scope = null): self { $prop = $this->prop(); @@ -69,6 +69,33 @@ public function has(string $key, $value = null, Closure $scope = null): self return $this; } + /** + * Assert that all of the given props exist. + * + * @param array|string $key + * @return $this + */ + public function hasAll($key): self + { + $keys = is_array($key) ? $key : func_get_args(); + + foreach ($keys as $prop => $count) { + if (is_int($prop)) { + $this->has($count); + } else { + $this->has($prop, $count); + } + } + + return $this; + } + + /** + * Assert that none of the given props exist. + * + * @param array|string $key + * @return $this + */ public function missingAll($key): self { $keys = is_array($key) ? $key : func_get_args(); @@ -80,6 +107,12 @@ public function missingAll($key): self return $this; } + /** + * Assert that the given prop does not exist. + * + * @param string $key + * @return $this + */ public function missing(string $key): self { PHPUnit::assertNotTrue( @@ -90,11 +123,36 @@ public function missing(string $key): self return $this; } - abstract protected function prop(string $key = null); - - abstract protected function dotPath($key): string; - + /** + * Compose the absolute "dot" path to the given key. + * + * @param string $key + * @return string + */ + abstract protected function dotPath(string $key): string; + + /** + * Marks the property as interacted. + * + * @param string $key + * @return void + */ abstract protected function interactsWith(string $key): void; - abstract protected function scope($key, Closure $callback); + /** + * Retrieve a prop within the current scope using "dot" notation. + * + * @param string|null $key + * @return mixed + */ + abstract protected function prop(string $key = null); + + /** + * Instantiate a new "scope" at the path of the given key. + * + * @param string $key + * @param Closure $callback + * @return $this + */ + abstract protected function scope(string $key, Closure $callback); } diff --git a/src/Illuminate/Testing/Fluent/Concerns/Interaction.php b/src/Illuminate/Testing/Fluent/Concerns/Interaction.php index d938438a618c..15e7e9508f55 100644 --- a/src/Illuminate/Testing/Fluent/Concerns/Interaction.php +++ b/src/Illuminate/Testing/Fluent/Concerns/Interaction.php @@ -7,9 +7,19 @@ trait Interaction { - /** @var array */ + /** + * The list of interacted properties. + * + * @var array + */ protected $interacted = []; + /** + * Marks the property as interacted. + * + * @param string $key + * @return void + */ protected function interactsWith(string $key): void { $prop = Str::before($key, '.'); @@ -19,6 +29,11 @@ protected function interactsWith(string $key): void } } + /** + * Asserts that all properties have been interacted with. + * + * @return void + */ public function interacted(): void { PHPUnit::assertSame( @@ -30,6 +45,11 @@ public function interacted(): void ); } + /** + * Disables the interaction check. + * + * @return $this + */ public function etc(): self { $this->interacted = array_keys($this->prop()); @@ -37,5 +57,11 @@ public function etc(): self return $this; } + /** + * Retrieve a prop within the current scope using "dot" notation. + * + * @param string|null $key + * @return mixed + */ abstract protected function prop(string $key = null); } diff --git a/src/Illuminate/Testing/Fluent/Concerns/Matching.php b/src/Illuminate/Testing/Fluent/Concerns/Matching.php index 3edce127d362..052b2d49ab2f 100644 --- a/src/Illuminate/Testing/Fluent/Concerns/Matching.php +++ b/src/Illuminate/Testing/Fluent/Concerns/Matching.php @@ -9,16 +9,14 @@ trait Matching { - public function whereAll(array $bindings): self - { - foreach ($bindings as $key => $value) { - $this->where($key, $value); - } - - return $this; - } - - public function where($key, $expected): self + /** + * Asserts that the property matches the expected value. + * + * @param string $key + * @param mixed|callable $expected + * @return $this + */ + public function where(string $key, $expected): self { $this->has($key); @@ -49,6 +47,27 @@ public function where($key, $expected): self return $this; } + /** + * Asserts that all properties match their expected values. + * + * @param array $bindings + * @return $this + */ + public function whereAll(array $bindings): self + { + foreach ($bindings as $key => $value) { + $this->where($key, $value); + } + + return $this; + } + + /** + * Ensures that all properties are sorted the same way, recursively. + * + * @param mixed $value + * @return void + */ protected function ensureSorted(&$value): void { if (! is_array($value)) { @@ -62,9 +81,29 @@ protected function ensureSorted(&$value): void ksort($value); } - abstract protected function dotPath($key): string; + /** + * Compose the absolute "dot" path to the given key. + * + * @param string $key + * @return string + */ + abstract protected function dotPath(string $key): string; + + /** + * Ensure that the given prop exists. + * + * @param string $key + * @param null $value + * @param Closure|null $scope + * @return $this + */ + abstract public function has(string $key, $value = null, Closure $scope = null); + /** + * Retrieve a prop within the current scope using "dot" notation. + * + * @param string|null $key + * @return mixed + */ abstract protected function prop(string $key = null); - - abstract public function has(string $key, $value = null, Closure $scope = null); } From c5eadc4d9de5a2597dc0c4b64f0d009e287c25a6 Mon Sep 17 00:00:00 2001 From: Claudio Dekker <1752195+claudiodekker@users.noreply.github.com> Date: Thu, 4 Mar 2021 18:11:49 +0100 Subject: [PATCH 4/4] Use FQN in DocBlocks --- src/Illuminate/Testing/Fluent/Assert.php | 4 ++-- src/Illuminate/Testing/Fluent/Concerns/Has.php | 4 ++-- src/Illuminate/Testing/Fluent/Concerns/Matching.php | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Illuminate/Testing/Fluent/Assert.php b/src/Illuminate/Testing/Fluent/Assert.php index 926ef09656ac..bde937a0bf23 100644 --- a/src/Illuminate/Testing/Fluent/Assert.php +++ b/src/Illuminate/Testing/Fluent/Assert.php @@ -75,7 +75,7 @@ protected function prop(string $key = null) * Instantiate a new "scope" at the path of the given key. * * @param string $key - * @param Closure $callback + * @param \Closure $callback * @return $this */ protected function scope(string $key, Closure $callback): self @@ -106,7 +106,7 @@ public static function fromArray(array $data): self /** * Create a new instance from a AssertableJsonString. * - * @param AssertableJsonString $json + * @param \Illuminate\Testing\AssertableJsonString $json * @return static */ public static function fromAssertableJsonString(AssertableJsonString $json): self diff --git a/src/Illuminate/Testing/Fluent/Concerns/Has.php b/src/Illuminate/Testing/Fluent/Concerns/Has.php index 746fa49b60f1..19b9ad9915ca 100644 --- a/src/Illuminate/Testing/Fluent/Concerns/Has.php +++ b/src/Illuminate/Testing/Fluent/Concerns/Has.php @@ -31,7 +31,7 @@ protected function count(string $key, int $length): self * * @param string $key * @param null $value - * @param Closure|null $scope + * @param \Closure|null $scope * @return $this */ public function has(string $key, $value = null, Closure $scope = null): self @@ -151,7 +151,7 @@ abstract protected function prop(string $key = null); * Instantiate a new "scope" at the path of the given key. * * @param string $key - * @param Closure $callback + * @param \Closure $callback * @return $this */ abstract protected function scope(string $key, Closure $callback); diff --git a/src/Illuminate/Testing/Fluent/Concerns/Matching.php b/src/Illuminate/Testing/Fluent/Concerns/Matching.php index 052b2d49ab2f..3cf1f82c471c 100644 --- a/src/Illuminate/Testing/Fluent/Concerns/Matching.php +++ b/src/Illuminate/Testing/Fluent/Concerns/Matching.php @@ -13,7 +13,7 @@ trait Matching * Asserts that the property matches the expected value. * * @param string $key - * @param mixed|callable $expected + * @param mixed|\Closure $expected * @return $this */ public function where(string $key, $expected): self @@ -94,7 +94,7 @@ abstract protected function dotPath(string $key): string; * * @param string $key * @param null $value - * @param Closure|null $scope + * @param \Closure|null $scope * @return $this */ abstract public function has(string $key, $value = null, Closure $scope = null);