From 90393a9471ced4a8806e7710408cf358a5d53c02 Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Wed, 29 Jun 2022 16:18:11 +0200 Subject: [PATCH] [feature] add `Json::assertHas()`/`assertMissing()`/`assertThat()`/`assertThatEach()` (#92) --- README.md | 12 ++- composer.json | 2 +- src/Browser/Json.php | 78 ++++++++++++++++++++ tests/JsonTest.php | 169 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 tests/JsonTest.php diff --git a/README.md b/README.md index 78ba206..26343f3 100644 --- a/README.md +++ b/README.md @@ -414,6 +414,8 @@ $json = $browser ; $json->assertMatches('foo.bar.baz', 1); +$json->assertHas('foo.bar.baz'); +$json->assertMissing('foo.bar.boo'); $json->search('foo.bar.baz'); // mixed (the found value at "JMESPath expression") $json->decoded(); // the decoded json (string) $json; // the json string pretty-printed @@ -422,7 +424,15 @@ $json->decoded(); // the decoded json $json = $browser ->get('/api/endpoint') ->use(function(\Zenstruck\Browser\Json $json) { - $json->assertMatches('foo.bar.baz', 1); + // Json acts like a proxy of zenstruck/assert Expectation class + $json->hasCount(5); + $json->contains('foo'); + // assert on children: the closure gets Json object contextualized on given selector + // {"foo": "bar"} + $json->assertThat('foo', fn(Json $json) => $json->equals('bar')) + // assert on each element of an array + // {"foo": [1, 2, 3]} + $json->assertThatEach('foo', fn(Json $json) => $json->isGreaterThan(0)); }) ; ``` diff --git a/composer.json b/composer.json index 5a04b5e..c711538 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ "symfony/dom-crawler": "^5.4|^6.0", "symfony/framework-bundle": "^5.4|^6.0", "symfony/polyfill-php80": "^1.20", - "zenstruck/assert": "^1.0", + "zenstruck/assert": "^1.1", "zenstruck/callback": "^1.4.2" }, "require-dev": { diff --git a/src/Browser/Json.php b/src/Browser/Json.php index 059758c..a7b690b 100644 --- a/src/Browser/Json.php +++ b/src/Browser/Json.php @@ -3,10 +3,12 @@ namespace Zenstruck\Browser; use Zenstruck\Assert; +use Zenstruck\Assert\Expectation; use function JmesPath\search; /** * @author Kevin Bond + * @mixin Expectation */ final class Json { @@ -17,6 +19,20 @@ public function __construct(string $source) $this->source = $source; } + /** + * @param array $arguments + */ + public function __call(string $methodName, array $arguments): self + { + if (!\method_exists(Expectation::class, $methodName)) { + throw new \BadMethodCallException("{$methodName} does not exist"); + } + + Assert::that($this->decoded())->{$methodName}(...$arguments); + + return $this; + } + public function __toString(): string { return \json_encode($this->decoded(), \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_THROW_ON_ERROR); @@ -33,6 +49,60 @@ public function assertMatches(string $expression, $expected): self return $this; } + /** + * @param string $selector JMESPath selector + */ + public function assertHas(string $selector): self + { + Assert::try(fn() => $this->search("length({$selector})")); + + return $this; + } + + /** + * @param string $selector JMESPath selector + */ + public function assertMissing(string $selector): self + { + try { + $this->search("length({$selector})"); + } catch (\RuntimeException $e) { + Assert::pass(); + + return $this; + } + + Assert::fail("Selector \"{$selector}\" does exists."); + } + + /** + * @param callable(Json):mixed $assert + */ + public function assertThat(string $selector, callable $assert): self + { + $assert(self::encode($this->search($selector))); + + return $this; + } + + /** + * @param callable(Json):mixed $assert + */ + public function assertThatEach(string $selector, callable $assert): void + { + $value = $this->search($selector); + + if (!\is_array($value)) { + Assert::fail("Value for selector \"{$selector}\" is not an array."); + } + + Assert::that($value)->isNotEmpty(); + + foreach ($value as $item) { + $assert(self::encode($item)); + } + } + /** * @return mixed */ @@ -56,4 +126,12 @@ public function decoded() return \json_decode($this->source, true, 512, \JSON_THROW_ON_ERROR); } + + /** + * @param mixed $data + */ + private static function encode($data): self + { + return new self(\json_encode($data, \JSON_THROW_ON_ERROR)); + } } diff --git a/tests/JsonTest.php b/tests/JsonTest.php new file mode 100644 index 0000000..8842178 --- /dev/null +++ b/tests/JsonTest.php @@ -0,0 +1,169 @@ +assertHas($selector); + } catch (AssertionFailedError $exception) { + Assert::fail("assertHas() did not assert that selector \"{$selector}\" exists."); + } + } + + /** + * @test + * @dataProvider selectorDoesNotExistProvider + */ + public function assert_has_fails_if_selector_does_not_exist(string $json, string $selector): void + { + try { + (new Json($json))->assertHas($selector); + } catch (AssertionFailedError $exception) { + Assert::pass(); + + return; + } + + Assert::fail("assertHas() asserted that selector \"{$selector}\" exists although it does not."); + } + + /** + * @test + * @dataProvider selectorDoesNotExistProvider + */ + public function assert_missing_passes_if_selector_does_not_exist(string $json, string $selector): void + { + try { + (new Json($json))->assertMissing($selector); + } catch (AssertionFailedError $exception) { + Assert::fail("assertMissing() asserted that selector \"{$selector}\" is missing although it does not."); + } + } + + /** + * @test + * @dataProvider selectorExistsProvider + */ + public function assert_missing_fails_if_selector_exists(string $json, string $selector): void + { + try { + (new Json($json))->assertMissing($selector); + } catch (AssertionFailedError $exception) { + Assert::pass(); + + return; + } + + Assert::fail("assertMissing() did not assert that \"{$selector}\" is missing."); + } + + public function selectorExistsProvider(): iterable + { + yield ['{"foo":"bar"}', 'foo']; + yield ['{"foo":{"bar": "baz"}}', 'foo.bar']; + yield ['[{"foo":"bar"}]', '[0].foo']; + yield ['{"foo":[{"bar": "baz"}]}', 'foo[0].bar']; + } + + public function selectorDoesNotExistProvider(): iterable + { + yield ['{}', 'foo']; + yield ['{"foo":"bar"}', 'bar']; + yield ['{"foo":{"bar": "baz"}}', 'foo.baz']; + } + + /** + * @test + * @dataProvider selectHasCountProvider + */ + public function can_assert_a_selector_has_count(string $json, int $expectedCount): void + { + try { + (new Json($json))->hasCount($expectedCount); + } catch (AssertionFailedError $exception) { + Assert::fail("assertCount() did not assert that json matches expected count \"{$expectedCount}\""); + } + } + + public function selectHasCountProvider(): iterable + { + yield ['[]', 0]; + yield ['[1,2,3]', 3]; + } + + /** + * @test + */ + public function can_perform_assertions_on_itself(): void + { + (new Json('["foo","bar"]'))->contains('bar')->doesNotContain('food'); + } + + /** + * @test + * @dataProvider scalarChildAssertionProvider + */ + public function can_perform_assertion_on_scalar_child(string $selector, callable $asserter): void + { + (new Json('{"foo":{"bar":"baz"}}'))->assertThat($selector, $asserter); + } + + public function scalarChildAssertionProvider(): iterable + { + yield ['noop', function(Json $json) {$json->isNull(); }]; + yield ['foo.bar', function(Json $json) {$json->isNotEmpty()->equals('baz'); }]; + } + + /** + * @test + * @dataProvider arrayChildAssertionProvider + */ + public function can_perform_assertion_on_array_child(string $json, string $selector, callable $asserter): void + { + (new Json($json))->assertThatEach($selector, $asserter); + } + + public function arrayChildAssertionProvider(): iterable + { + yield ['{"foo":[1, 2]}', 'foo', function(Json $json) {$json->isGreaterThan(0); }]; + yield ['{"foo":[{"bar": 1}, {"bar": 2}]}', 'foo[*].bar', function(Json $json) {$json->isGreaterThan(0); }]; + } + + /** + * @test + * @dataProvider invalidArrayChildAssertionProvider + */ + public function assert_that_each_throws_if_invalid_array_given(string $json, string $selector, callable $asserter): void + { + try { + (new Json($json))->assertThatEach($selector, $asserter); + } catch (AssertionFailedError $e) { + Assert::pass(); + + return; + } + + Assert::fail('Json::assertThatEach() should raise exception with invalid arrays.'); + } + + public function invalidArrayChildAssertionProvider(): iterable + { + yield ['{}', 'foo', static function(Json $json) {}]; + yield ['{"foo": "bar"}', 'foo', static function(Json $json) {}]; + yield ['{"foo": []}', 'foo', static function(Json $json) {}]; + } +}