diff --git a/README.md b/README.md index e8ad7e79..18486834 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ from the community. | [FileDownloadTrait](STEPS.md#filedownloadtrait) | Test file download functionality with content verification. | | [IframeTrait](STEPS.md#iframetrait) | Switch between iframes and the root document. | | [JavascriptTrait](STEPS.md#javascripttrait) | Automatically detect JavaScript errors during test execution. | +| [JsonTrait](STEPS.md#jsontrait) | Assert JSON responses with path and schema checks. | | [KeyboardTrait](STEPS.md#keyboardtrait) | Simulate keyboard interactions in Drupal browser testing. | | [LinkTrait](STEPS.md#linktrait) | Verify link elements with attribute and content assertions. | | [MetatagTrait](STEPS.md#metatagtrait) | Assert `` tags in page markup. | diff --git a/STEPS.md b/STEPS.md index dc6960ba..03143ed7 100644 --- a/STEPS.md +++ b/STEPS.md @@ -13,6 +13,7 @@ | [FileDownloadTrait](#filedownloadtrait) | Test file download functionality with content verification. | | [IframeTrait](#iframetrait) | Switch between iframes and the root document. | | [JavascriptTrait](#javascripttrait) | Automatically detect JavaScript errors during test execution. | +| [JsonTrait](#jsontrait) | Assert JSON responses with path and schema checks. | | [KeyboardTrait](#keyboardtrait) | Simulate keyboard interactions in Drupal browser testing. | | [LinkTrait](#linktrait) | Verify link elements with attribute and content assertions. | | [MetatagTrait](#metatagtrait) | Assert `` tags in page markup. | @@ -1383,6 +1384,301 @@ When I switch to the root document > ``` +## JsonTrait + +[Source](src/JsonTrait.php), [Example](tests/behat/features/json.feature) + +> Assert JSON responses with path and schema checks. +> - Assert response is valid JSON format. +> - Assert values at JSONPath expressions. +> - Assert JSONPath existence, type, and element counts. +> - Validate the response against a JSON Schema. +> +> JSONPath expressions use the standard `$.path.to[0].value` syntax. +>

+> The JSON Schema steps require the optional `justinrainbow/json-schema` +> package: `composer require --dev justinrainbow/json-schema`. + + +
+ @Given the response JSON from the file :filename + +
+Set the response JSON content from a fixture file +

+ +```gherkin +Given the response JSON from the file "json_valid.json" + +``` + +
+ +
+ @Given the response JSON content is the following: + +
+Set the response JSON content directly from a PyString +

+ +```gherkin +Given the response JSON content is the following: + """ + {"name": "John Doe", "roles": ["admin", "editor"]} + """ + +``` + +
+ +
+ @When I print last JSON response + +
+Print the last JSON response +

+ +```gherkin +When I print last JSON response + +``` + +
+ +
+ @Then the response should be in JSON format + +
+Assert that a response is valid JSON +

+ +```gherkin +Then the response should be in JSON format + +# Content set by a fixture step is validated instead of the page content. +Given the response JSON from the file "json_valid.json" +Then the response should be in JSON format + +``` + +
+ +
+ @Then the response should not be in JSON format + +
+Assert that a response is not valid JSON +

+ +```gherkin +Then the response should not be in JSON format + +``` + +
+ +
+ @Then the JSON path :path should exist + +
+Assert that a JSONPath expression matches at least one value +

+ +```gherkin +Then the JSON path "$.name" should exist +Then the JSON path "$.user.roles[0]" should exist + +``` + +
+ +
+ @Then the JSON path :path should not exist + +
+Assert that a JSONPath expression matches no values +

+ +```gherkin +Then the JSON path "$.nonexistent" should not exist + +``` + +
+ +
+ @Then the JSON path :path should be equal to :value + +
+Assert that the value at a JSONPath equals the expected value +

+ +```gherkin +Then the JSON path "$.name" should be equal to "John Doe" +Then the JSON path "$.age" should be equal to "42" + +``` + +
+ +
+ @Then the JSON path :path should not be equal to :value + +
+Assert that the value at a JSONPath does not equal the expected value +

+ +```gherkin +Then the JSON path "$.name" should not be equal to "Jane Doe" + +``` + +
+ +
+ @Then the JSON path :path should contain :value + +
+Assert that the value at a JSONPath contains the expected text +

+ +```gherkin +Then the JSON path "$.name" should contain "John" + +``` + +
+ +
+ @Then the JSON path :path should not contain :value + +
+Assert that the value at a JSONPath does not contain the expected text +

+ +```gherkin +Then the JSON path "$.name" should not contain "Jane" + +``` + +
+ +
+ @Then the JSON path :path should match :pattern + +
+Assert that the value at a JSONPath matches a regular expression +

+ +```gherkin +Then the JSON path "$.email" should match "/^[^@]+@example\.com$/" + +``` + +
+ +
+ @Then the JSON path :path should not match :pattern + +
+Assert that the value at a JSONPath does not match a regular expression +

+ +```gherkin +Then the JSON path "$.email" should not match "/^admin@/" + +``` + +
+ +
+ @Then the JSON path :path should be null + +
+Assert that the value at a JSONPath is null +

+ +```gherkin +Then the JSON path "$.deleted_at" should be null + +``` + +
+ +
+ @Then the JSON path :path should be true + +
+Assert that the value at a JSONPath is boolean true +

+ +```gherkin +Then the JSON path "$.active" should be true + +``` + +
+ +
+ @Then the JSON path :path should be false + +
+Assert that the value at a JSONPath is boolean false +

+ +```gherkin +Then the JSON path "$.disabled" should be false + +``` + +
+ +
+ @Then the JSON path :path should have :count element(s) + +
+Assert that the array or object at a JSONPath has a number of elements +

+ +```gherkin +Then the JSON path "$.items" should have "3" elements +Then the JSON path "$.user" should have "2" elements + +``` + +
+ +
+ @Then the response should match the following JSON schema: + +
+Assert that the response validates against an inline JSON Schema +

+ +```gherkin +Then the response should match the following JSON schema: + """ + {"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}} + """ + +``` + +
+ +
+ @Then the response should match the JSON schema in the file :filename + +
+Assert that the response validates against a JSON Schema from a file +

+ +```gherkin +Then the response should match the JSON schema in the file "json_schema.json" + +``` + +
+ ## KeyboardTrait [Source](src/KeyboardTrait.php), [Example](tests/behat/features/keyboard.feature) diff --git a/composer.json b/composer.json index ac54e456..19b241ff 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,8 @@ "behat/behat": "^3.14", "behat/mink": ">=1.11", "drupal/drupal-extension": "^6.0", - "lullabot/mink-selenium2-driver": "^1.7.4" + "lullabot/mink-selenium2-driver": "^1.7.4", + "softcreatr/jsonpath": "^0.10 || ^1.0" }, "require-dev": { "alexskrypnyk/phpunit-helpers": "^0.15.0", @@ -34,6 +35,7 @@ "drupal/coder": "^8.3.28", "dvdoug/behat-code-coverage": "^5.3.2.1", "ergebnis/composer-normalize": "^2.47", + "justinrainbow/json-schema": "^6.0", "mglaman/phpstan-drupal": "^2.0.0", "php-parallel-lint/php-parallel-lint": "^1.4", "phpcompatibility/php-compatibility": "^9.3.5", @@ -46,6 +48,9 @@ "conflict": { "drupal/drupal-extension": "<6" }, + "suggest": { + "justinrainbow/json-schema": "Required by JsonTrait for JSON Schema validation steps ('the response should match the JSON schema ...')." + }, "autoload": { "psr-4": { "DrevOps\\BehatSteps\\": "src/" diff --git a/src/JsonTrait.php b/src/JsonTrait.php new file mode 100644 index 00000000..03281d45 --- /dev/null +++ b/src/JsonTrait.php @@ -0,0 +1,624 @@ +|null + */ + protected ?array $jsonData = NULL; + + /** + * Hash of the currently decoded JSON content. + * + * Used to detect when page content changes and data needs re-decoding. + */ + protected ?string $jsonContentHash = NULL; + + /** + * JSON content set directly for testing without an HTTP request. + */ + protected ?string $jsonTestContent = NULL; + + /** + * Clear cached JSON state before each scenario. + */ + #[BeforeScenario] + public function jsonBeforeScenario(): void { + $this->jsonResetState(); + } + + /** + * Clear cached JSON state after each scenario. + */ + #[AfterScenario] + public function jsonAfterScenario(): void { + $this->jsonResetState(); + } + + /** + * Set the response JSON content from a fixture file. + * + * @code + * Given the response JSON from the file "json_valid.json" + * @endcode + */ + #[Given('the response JSON from the file :filename')] + public function jsonSetContentFromFile(string $filename): void { + $this->jsonTestContent = $this->jsonReadFile($filename); + $this->jsonData = NULL; + $this->jsonContentHash = NULL; + } + + /** + * Set the response JSON content directly from a PyString. + * + * @code + * Given the response JSON content is the following: + * """ + * {"name": "John Doe", "roles": ["admin", "editor"]} + * """ + * @endcode + */ + #[Given('the response JSON content is the following:')] + public function jsonSetContent(PyStringNode $content): void { + $this->jsonTestContent = $content->getRaw(); + $this->jsonData = NULL; + $this->jsonContentHash = NULL; + } + + /** + * Assert that a response is valid JSON. + * + * @code + * Then the response should be in JSON format + * + * # Content set by a fixture step is validated instead of the page content. + * Given the response JSON from the file "json_valid.json" + * Then the response should be in JSON format + * @endcode + */ + #[Then('the response should be in JSON format')] + public function jsonAssertResponseIsJson(): void { + $this->jsonDecodeLoose($this->jsonResolveContent()); + } + + /** + * Assert that a response is not valid JSON. + * + * @code + * Then the response should not be in JSON format + * @endcode + */ + #[Then('the response should not be in JSON format')] + public function jsonAssertResponseIsNotJson(): void { + $content = $this->jsonResolveContent(); + + json_decode((string) $content); + + if (json_last_error() === JSON_ERROR_NONE) { + throw new ExpectationException('The response is valid JSON, but it should not be.', $this->getSession()->getDriver()); + } + } + + /** + * Assert that a JSONPath expression matches at least one value. + * + * @code + * Then the JSON path "$.name" should exist + * Then the JSON path "$.user.roles[0]" should exist + * @endcode + */ + #[Then('the JSON path :path should exist')] + public function jsonAssertPathExists(string $path): void { + $matches = $this->jsonQuery($path); + + if (count($matches) === 0) { + throw new ExpectationException(sprintf('The JSON path "%s" was not found.', $path), $this->getSession()->getDriver()); + } + } + + /** + * Assert that a JSONPath expression matches no values. + * + * @code + * Then the JSON path "$.nonexistent" should not exist + * @endcode + */ + #[Then('the JSON path :path should not exist')] + public function jsonAssertPathNotExists(string $path): void { + $matches = $this->jsonQuery($path); + + if (count($matches) > 0) { + throw new ExpectationException(sprintf('The JSON path "%s" was found, but it should not exist.', $path), $this->getSession()->getDriver()); + } + } + + /** + * Assert that the value at a JSONPath equals the expected value. + * + * @code + * Then the JSON path "$.name" should be equal to "John Doe" + * Then the JSON path "$.age" should be equal to "42" + * @endcode + */ + #[Then('the JSON path :path should be equal to :value')] + public function jsonAssertPathEquals(string $path, string $value): void { + $actual = $this->jsonScalarToString($this->jsonResolveScalar($path)); + + if ($actual !== $value) { + throw new ExpectationException(sprintf('The JSON path "%s" is "%s", but expected "%s".', $path, $actual, $value), $this->getSession()->getDriver()); + } + } + + /** + * Assert that the value at a JSONPath does not equal the expected value. + * + * @code + * Then the JSON path "$.name" should not be equal to "Jane Doe" + * @endcode + */ + #[Then('the JSON path :path should not be equal to :value')] + public function jsonAssertPathNotEquals(string $path, string $value): void { + $actual = $this->jsonScalarToString($this->jsonResolveScalar($path)); + + if ($actual === $value) { + throw new ExpectationException(sprintf('The JSON path "%s" is "%s", but it should not be.', $path, $actual), $this->getSession()->getDriver()); + } + } + + /** + * Assert that the value at a JSONPath contains the expected text. + * + * @code + * Then the JSON path "$.name" should contain "John" + * @endcode + */ + #[Then('the JSON path :path should contain :value')] + public function jsonAssertPathContains(string $path, string $value): void { + $actual = $this->jsonScalarToString($this->jsonResolveScalar($path)); + + if (!str_contains((string) $actual, $value)) { + throw new ExpectationException(sprintf('The JSON path "%s" is "%s" and does not contain "%s".', $path, $actual, $value), $this->getSession()->getDriver()); + } + } + + /** + * Assert that the value at a JSONPath does not contain the expected text. + * + * @code + * Then the JSON path "$.name" should not contain "Jane" + * @endcode + */ + #[Then('the JSON path :path should not contain :value')] + public function jsonAssertPathNotContains(string $path, string $value): void { + $actual = $this->jsonScalarToString($this->jsonResolveScalar($path)); + + if (str_contains((string) $actual, $value)) { + throw new ExpectationException(sprintf('The JSON path "%s" is "%s" and contains "%s", but it should not.', $path, $actual, $value), $this->getSession()->getDriver()); + } + } + + /** + * Assert that the value at a JSONPath matches a regular expression. + * + * @code + * Then the JSON path "$.email" should match "/^[^@]+@example\.com$/" + * @endcode + */ + #[Then('the JSON path :path should match :pattern')] + public function jsonAssertPathMatches(string $path, string $pattern): void { + $actual = $this->jsonScalarToString($this->jsonResolveScalar($path)); + + $result = @preg_match($pattern, (string) $actual); + if ($result === FALSE) { + throw new ExpectationException(sprintf('The regular expression "%s" is invalid.', $pattern), $this->getSession()->getDriver()); + } + + if ($result === 0) { + throw new ExpectationException(sprintf('The JSON path "%s" is "%s" and does not match the pattern "%s".', $path, $actual, $pattern), $this->getSession()->getDriver()); + } + } + + /** + * Assert that the value at a JSONPath does not match a regular expression. + * + * @code + * Then the JSON path "$.email" should not match "/^admin@/" + * @endcode + */ + #[Then('the JSON path :path should not match :pattern')] + public function jsonAssertPathNotMatches(string $path, string $pattern): void { + $actual = $this->jsonScalarToString($this->jsonResolveScalar($path)); + + $result = @preg_match($pattern, (string) $actual); + if ($result === FALSE) { + throw new ExpectationException(sprintf('The regular expression "%s" is invalid.', $pattern), $this->getSession()->getDriver()); + } + + if ($result === 1) { + throw new ExpectationException(sprintf('The JSON path "%s" is "%s" and matches the pattern "%s", but it should not.', $path, $actual, $pattern), $this->getSession()->getDriver()); + } + } + + /** + * Assert that the value at a JSONPath is null. + * + * @code + * Then the JSON path "$.deleted_at" should be null + * @endcode + */ + #[Then('the JSON path :path should be null')] + public function jsonAssertPathNull(string $path): void { + $value = $this->jsonResolveSingle($path); + + if ($value !== NULL) { + throw new ExpectationException(sprintf('The JSON path "%s" is "%s", but expected null.', $path, $this->jsonScalarToString($value)), $this->getSession()->getDriver()); + } + } + + /** + * Assert that the value at a JSONPath is boolean true. + * + * @code + * Then the JSON path "$.active" should be true + * @endcode + */ + #[Then('the JSON path :path should be true')] + public function jsonAssertPathTrue(string $path): void { + $value = $this->jsonResolveSingle($path); + + if ($value !== TRUE) { + throw new ExpectationException(sprintf('The JSON path "%s" is not true.', $path), $this->getSession()->getDriver()); + } + } + + /** + * Assert that the value at a JSONPath is boolean false. + * + * @code + * Then the JSON path "$.disabled" should be false + * @endcode + */ + #[Then('the JSON path :path should be false')] + public function jsonAssertPathFalse(string $path): void { + $value = $this->jsonResolveSingle($path); + + if ($value !== FALSE) { + throw new ExpectationException(sprintf('The JSON path "%s" is not false.', $path), $this->getSession()->getDriver()); + } + } + + /** + * Assert that the array or object at a JSONPath has a number of elements. + * + * The path must resolve to a single array or object; its elements are then + * counted. Use a container path such as `$.items` rather than `$.items[*]`. + * + * @code + * Then the JSON path "$.items" should have "3" elements + * Then the JSON path "$.user" should have "2" elements + * @endcode + */ + #[Then('the JSON path :path should have :count element(s)')] + public function jsonAssertPathCount(string $path, string $count): void { + $value = $this->jsonResolveSingle($path); + + if (!is_array($value)) { + throw new ExpectationException(sprintf('The JSON path "%s" is not an array or object.', $path), $this->getSession()->getDriver()); + } + + if (!ctype_digit($count)) { + throw new ExpectationException(sprintf('The expected element count "%s" is not a valid non-negative integer.', $count), $this->getSession()->getDriver()); + } + + $actual = count($value); + $expected = (int) $count; + + if ($actual !== $expected) { + throw new ExpectationException(sprintf('The JSON path "%s" has %d element(s), but expected %d.', $path, $actual, $expected), $this->getSession()->getDriver()); + } + } + + /** + * Assert that the response validates against an inline JSON Schema. + * + * @code + * Then the response should match the following JSON schema: + * """ + * {"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}} + * """ + * @endcode + */ + #[Then('the response should match the following JSON schema:')] + public function jsonAssertMatchesSchema(PyStringNode $schema): void { + $this->jsonValidateSchema($schema->getRaw()); + } + + /** + * Assert that the response validates against a JSON Schema from a file. + * + * @code + * Then the response should match the JSON schema in the file "json_schema.json" + * @endcode + */ + #[Then('the response should match the JSON schema in the file :filename')] + public function jsonAssertMatchesSchemaFromFile(string $filename): void { + $this->jsonValidateSchema($this->jsonReadFile($filename)); + } + + /** + * Print the last JSON response. + * + * @code + * When I print last JSON response + * @endcode + */ + #[When('I print last JSON response')] + public function jsonPrintLastResponse(): void { + $data = $this->jsonDecodeLoose($this->jsonResolveContent()); + + print (string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + /** + * Reset all cached JSON state. + */ + protected function jsonResetState(): void { + $this->jsonData = NULL; + $this->jsonContentHash = NULL; + $this->jsonTestContent = NULL; + } + + /** + * Read a fixture file's contents. + * + * @param string $filename + * The fixture file name relative to the Mink files path. + * + * @return string + * The file contents. + */ + protected function jsonReadFile(string $filename): string { + $files_path = rtrim((string) $this->getMinkParameter('files_path'), '/'); + $file_path = $files_path . '/' . $filename; + + if (!file_exists($file_path)) { + throw new \RuntimeException(sprintf('The file "%s" does not exist.', $file_path)); + } + + $content = file_get_contents($file_path); + if ($content === FALSE) { + // @codeCoverageIgnoreStart + throw new \RuntimeException(sprintf('Failed to read the file "%s".', $file_path)); + // @codeCoverageIgnoreEnd + } + + return $content; + } + + /** + * Resolve the response content to assert against. + * + * @return string + * The content set by a fixture step, or the live page content. + */ + protected function jsonResolveContent(): string { + return $this->jsonTestContent ?? (string) $this->getSession()->getPage()->getContent(); + } + + /** + * Ensure the JSON response is decoded and cached. + * + * Re-decodes the document if the page content has changed since last decode. + */ + protected function jsonEnsureData(): void { + if ($this->jsonTestContent !== NULL) { + if ($this->jsonData === NULL) { + $this->jsonData = $this->jsonDecode($this->jsonTestContent); + } + return; + } + + $content = (string) $this->getSession()->getPage()->getContent(); + $content_hash = md5($content); + + if ($this->jsonData === NULL || $this->jsonContentHash !== $content_hash) { + $this->jsonData = $this->jsonDecode($content); + $this->jsonContentHash = $content_hash; + } + } + + /** + * Decode a JSON string into an array. + * + * @param string $content + * The JSON content to decode. + * + * @return array + * The decoded data. + */ + protected function jsonDecode(string $content): array { + $data = json_decode($content, TRUE); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \RuntimeException(sprintf('Failed to decode JSON: %s', json_last_error_msg())); + } + + if (!is_array($data)) { + throw new \RuntimeException(sprintf('The JSON response must decode to an array or object, but got %s.', gettype($data))); + } + + return $data; + } + + /** + * Decode JSON content as loosely-typed data. + * + * Preserves the decoded value's native type (object, array, or scalar) and + * reports a parse failure as an assertion error. + * + * @param string $content + * The JSON content to decode. + * + * @return mixed + * The decoded value. + */ + protected function jsonDecodeLoose(string $content): mixed { + $data = json_decode($content); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new ExpectationException(sprintf('The response is not valid JSON: %s', json_last_error_msg()), $this->getSession()->getDriver()); + } + + return $data; + } + + /** + * Run a JSONPath expression against the decoded response. + * + * @param string $path + * The JSONPath expression. + * + * @return array + * The list of matched values. + */ + protected function jsonQuery(string $path): array { + $this->jsonEnsureData(); + + try { + $result = (new JSONPath($this->jsonData))->find($path); + } + catch (\Exception $exception) { + throw new ExpectationException(sprintf('The JSON path "%s" is invalid: %s', $path, $exception->getMessage()), $this->getSession()->getDriver()); + } + + $data = $result->getData(); + + return is_array($data) ? array_values($data) : []; + } + + /** + * Resolve a JSONPath expression to a single matched value. + * + * @param string $path + * The JSONPath expression. + * + * @return mixed + * The single matched value. + */ + protected function jsonResolveSingle(string $path): mixed { + $matches = $this->jsonQuery($path); + + if (count($matches) === 0) { + throw new ExpectationException(sprintf('The JSON path "%s" was not found.', $path), $this->getSession()->getDriver()); + } + + if (count($matches) > 1) { + throw new ExpectationException(sprintf('The JSON path "%s" matched %d values, but a single value is required for this assertion.', $path, count($matches)), $this->getSession()->getDriver()); + } + + return $matches[0]; + } + + /** + * Resolve a JSONPath expression to a single scalar value. + * + * @param string $path + * The JSONPath expression. + * + * @return mixed + * The single scalar (or null) value. + */ + protected function jsonResolveScalar(string $path): mixed { + $value = $this->jsonResolveSingle($path); + + if (is_array($value)) { + throw new ExpectationException(sprintf('The JSON path "%s" resolves to an array or object, but a scalar value is required for this assertion.', $path), $this->getSession()->getDriver()); + } + + return $value; + } + + /** + * Convert a scalar JSON value to its string representation. + * + * @param mixed $value + * The scalar (or null) value. + * + * @return string + * The string representation: "true"/"false" for booleans, "null" for null. + */ + protected function jsonScalarToString(mixed $value): string { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_null($value)) { + return 'null'; + } + + return (string) $value; + } + + /** + * Validate the response body against a JSON schema. + * + * @param string $schema_json + * The JSON schema as a string. + */ + protected function jsonValidateSchema(string $schema_json): void { + if (!class_exists(Validator::class)) { + // @codeCoverageIgnoreStart + throw new \RuntimeException('JSON Schema validation requires the "justinrainbow/json-schema" package. Install it with "composer require --dev justinrainbow/json-schema".'); + // @codeCoverageIgnoreEnd + } + + $data = $this->jsonDecodeLoose($this->jsonResolveContent()); + + $schema = json_decode($schema_json); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new ExpectationException(sprintf('The provided JSON schema is not valid JSON: %s', json_last_error_msg()), $this->getSession()->getDriver()); + } + + $validator = new Validator(); + $validator->validate($data, $schema); + + if (!$validator->isValid()) { + $messages = []; + foreach ($validator->getErrors() as $error) { + $messages[] = sprintf('[%s] %s', $error['property'], $error['message']); + } + throw new ExpectationException(sprintf('The response does not match the JSON schema: %s', implode('; ', $messages)), $this->getSession()->getDriver()); + } + } + +} diff --git a/tests/behat/bootstrap/FeatureContext.php b/tests/behat/bootstrap/FeatureContext.php index aed41e51..bc6e45f9 100644 --- a/tests/behat/bootstrap/FeatureContext.php +++ b/tests/behat/bootstrap/FeatureContext.php @@ -41,6 +41,7 @@ use DrevOps\BehatSteps\HelperTrait; use DrevOps\BehatSteps\IframeTrait; use DrevOps\BehatSteps\JavascriptTrait; +use DrevOps\BehatSteps\JsonTrait; use DrevOps\BehatSteps\KeyboardTrait; use DrevOps\BehatSteps\LinkTrait; use DrevOps\BehatSteps\MetatagTrait; @@ -77,6 +78,7 @@ class FeatureContext extends DrupalContext { use IframeTrait; use FileTrait; use JavascriptTrait; + use JsonTrait; use KeyboardTrait; use LinkTrait; use MediaTrait; diff --git a/tests/behat/features/json.feature b/tests/behat/features/json.feature new file mode 100644 index 00000000..7275066f --- /dev/null +++ b/tests/behat/features/json.feature @@ -0,0 +1,558 @@ +Feature: Check that JsonTrait works + As Behat Steps library developer + I want to provide tools to assert JSON responses + So that users can test API endpoints returning JSON + + Scenario: Assert "Then the response should be in JSON format" works + When I go to "/sites/default/files/json_valid.json" + Then the response should be in JSON format + + Scenario: Assert "Then the response should be in JSON format" honours content set from a fixture file over the page content + When I go to "/sites/default/files/json_invalid.json" + And the response JSON from the file "json_valid.json" + Then the response should be in JSON format + + Scenario: Assert "Then the response should be in JSON format" honours content set from a PyString over the page content + When I go to "/sites/default/files/json_invalid.json" + And the response JSON content is the following: + """ + {"name": "Blue Widget", "price": 9.99} + """ + Then the response should be in JSON format + + @trait:JsonTrait + Scenario: Assert that negative assertion for "Then the response should be in JSON format" fails with an error + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_invalid.json" + Then the response should be in JSON format + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The response is not valid JSON + """ + + Scenario: Assert "Then the response should not be in JSON format" works + When I go to "/sites/default/files/json_invalid.json" + Then the response should not be in JSON format + + @trait:JsonTrait + Scenario: Assert that negative assertion for "Then the response should not be in JSON format" fails with an error + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the response should not be in JSON format + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The response is valid JSON, but it should not be. + """ + + Scenario: Assert "Given the response JSON from the file :filename" works + Given the response JSON from the file "json_valid.json" + Then the JSON path "$.name" should be equal to "John Doe" + + @trait:JsonTrait + Scenario: Assert that "Given the response JSON from the file :filename" fails with an exception for missing file + Given some behat configuration + And scenario steps: + """ + Given the response JSON from the file "nonexistent.json" + Then the JSON path "$.name" should exist + """ + When I run "behat --no-colors" + Then it should fail with an exception: + """ + does not exist + """ + + Scenario: Assert "Given the response JSON content is the following:" works with direct PyString content + Given the response JSON content is the following: + """ + {"name": "Blue Widget", "meta": {"sku": "p1"}, "tags": ["a", "b"]} + """ + Then the JSON path "$.name" should be equal to "Blue Widget" + And the JSON path "$.meta.sku" should be equal to "p1" + And the JSON path "$.tags" should have "2" elements + + @trait:JsonTrait + Scenario: Assert that path assertion fails with an exception for invalid JSON + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_invalid.json" + Then the JSON path "$.name" should exist + """ + When I run "behat --no-colors" + Then it should fail with an exception: + """ + Failed to decode JSON + """ + + @trait:JsonTrait + Scenario: Assert that path assertion fails with an exception for a non-object JSON root + Given some behat configuration + And scenario steps: + """ + Given the response JSON content is the following: + ''' + 42 + ''' + Then the JSON path "$.name" should exist + """ + When I run "behat --no-colors" + Then it should fail with an exception: + """ + The JSON response must decode to an array or object, but got integer. + """ + + @trait:JsonTrait + Scenario: Assert that path assertion fails with an error for an invalid JSONPath expression + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.items[?(@.x" should exist + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + is invalid + """ + + Scenario: Assert "Then the JSON path :path should exist" works + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.name" should exist + And the JSON path "$.user.roles[0]" should exist + + @trait:JsonTrait + Scenario: Assert that negative assertion for "Then the JSON path :path should exist" fails with an error + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.nonexistent" should exist + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The JSON path "$.nonexistent" was not found. + """ + + Scenario: Assert "Then the JSON path :path should not exist" works + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.nonexistent" should not exist + + @trait:JsonTrait + Scenario: Assert that negative assertion for "Then the JSON path :path should not exist" fails with an error + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.name" should not exist + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The JSON path "$.name" was found, but it should not exist. + """ + + Scenario: Assert "Then the JSON path :path should be equal to :value" works with different scalar types + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.name" should be equal to "John Doe" + And the JSON path "$.age" should be equal to "42" + And the JSON path "$.price" should be equal to "9.99" + And the JSON path "$.active" should be equal to "true" + + @trait:JsonTrait + Scenario: Assert that negative assertion for "Then the JSON path :path should be equal to :value" fails with an error for wrong value + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.name" should be equal to "Wrong Name" + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The JSON path "$.name" is "John Doe", but expected "Wrong Name". + """ + + @trait:JsonTrait + Scenario: Assert that "Then the JSON path :path should be equal to :value" fails with an error for a missing path + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.nonexistent" should be equal to "test" + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The JSON path "$.nonexistent" was not found. + """ + + @trait:JsonTrait + Scenario: Assert that "Then the JSON path :path should be equal to :value" fails with an error for multiple matches + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.books[*].id" should be equal to "123" + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The JSON path "$.books[*].id" matched 2 values, but a single value is required for this assertion. + """ + + @trait:JsonTrait + Scenario: Assert that "Then the JSON path :path should be equal to :value" fails with an error for a non-scalar value + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.user" should be equal to "test" + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The JSON path "$.user" resolves to an array or object, but a scalar value is required for this assertion. + """ + + Scenario: Assert "Then the JSON path :path should not be equal to :value" works + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.name" should not be equal to "Jane Roe" + And the JSON path "$.nickname" should not be equal to "John Doe" + + @trait:JsonTrait + Scenario: Assert that negative assertion for "Then the JSON path :path should not be equal to :value" fails with an error + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.name" should not be equal to "John Doe" + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The JSON path "$.name" is "John Doe", but it should not be. + """ + + Scenario: Assert "Then the JSON path :path should contain :value" works + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.name" should contain "John" + + @trait:JsonTrait + Scenario: Assert that negative assertion for "Then the JSON path :path should contain :value" fails with an error + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.name" should contain "Nonexistent" + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The JSON path "$.name" is "John Doe" and does not contain "Nonexistent". + """ + + Scenario: Assert "Then the JSON path :path should not contain :value" works + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.name" should not contain "Nonexistent" + + @trait:JsonTrait + Scenario: Assert that negative assertion for "Then the JSON path :path should not contain :value" fails with an error + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.name" should not contain "John" + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The JSON path "$.name" is "John Doe" and contains "John", but it should not. + """ + + Scenario: Assert "Then the JSON path :path should match :pattern" works + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.email" should match "/^[^@]+@example\.com$/" + + @trait:JsonTrait + Scenario: Assert that negative assertion for "Then the JSON path :path should match :pattern" fails with an error for no match + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.email" should match "/^admin@/" + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + does not match the pattern + """ + + @trait:JsonTrait + Scenario: Assert that "Then the JSON path :path should match :pattern" fails with an error for an invalid pattern + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.email" should match "not-a-valid-regex" + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The regular expression "not-a-valid-regex" is invalid. + """ + + Scenario: Assert "Then the JSON path :path should not match :pattern" works + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.email" should not match "/^admin@/" + + @trait:JsonTrait + Scenario: Assert that negative assertion for "Then the JSON path :path should not match :pattern" fails with an error when it matches + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.email" should not match "/@example\.com$/" + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + matches the pattern + """ + + @trait:JsonTrait + Scenario: Assert that "Then the JSON path :path should not match :pattern" fails with an error for an invalid pattern + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.email" should not match "not-a-valid-regex" + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The regular expression "not-a-valid-regex" is invalid. + """ + + Scenario: Assert "Then the JSON path :path should be null" works + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.nickname" should be null + + @trait:JsonTrait + Scenario: Assert that negative assertion for "Then the JSON path :path should be null" fails with an error + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.name" should be null + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The JSON path "$.name" is "John Doe", but expected null. + """ + + Scenario: Assert "Then the JSON path :path should be true" works + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.active" should be true + + @trait:JsonTrait + Scenario: Assert that negative assertion for "Then the JSON path :path should be true" fails with an error + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.disabled" should be true + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The JSON path "$.disabled" is not true. + """ + + Scenario: Assert "Then the JSON path :path should be false" works + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.disabled" should be false + + @trait:JsonTrait + Scenario: Assert that negative assertion for "Then the JSON path :path should be false" fails with an error + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.active" should be false + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The JSON path "$.active" is not false. + """ + + Scenario: Assert "Then the JSON path :path should have :count element(s)" works for arrays and objects + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.items" should have "3" elements + And the JSON path "$.user.roles" should have "2" elements + And the JSON path "$.user" should have "1" element + + @trait:JsonTrait + Scenario: Assert that negative assertion for "Then the JSON path :path should have :count element(s)" fails with an error for wrong count + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.items" should have "5" elements + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The JSON path "$.items" has 3 element(s), but expected 5. + """ + + @trait:JsonTrait + Scenario: Assert that "Then the JSON path :path should have :count element(s)" fails with an error for a scalar value + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.name" should have "1" element + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The JSON path "$.name" is not an array or object. + """ + + @trait:JsonTrait + Scenario: Assert that "Then the JSON path :path should have :count element(s)" fails with an error for a non-numeric count + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.items" should have "three" elements + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The expected element count "three" is not a valid non-negative integer. + """ + + Scenario: Assert "Then the response should match the following JSON schema:" works + When I go to "/sites/default/files/json_valid.json" + Then the response should match the following JSON schema: + """ + {"type": "object", "required": ["name", "age"], "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}} + """ + + @trait:JsonTrait + Scenario: Assert that negative assertion for "Then the response should match the following JSON schema:" fails with an error + Given some behat configuration + And scenario steps tagged with "@api": + """ + Given the response JSON content is the following: + ''' + {"age": 42} + ''' + Then the response should match the following JSON schema: + ''' + {"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}} + ''' + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The response does not match the JSON schema + """ + + @trait:JsonTrait + Scenario: Assert that "Then the response should match the following JSON schema:" fails with an error for an invalid schema + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the response should match the following JSON schema: + ''' + {invalid schema + ''' + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The provided JSON schema is not valid JSON + """ + + @trait:JsonTrait + Scenario: Assert that "Then the response should match the following JSON schema:" fails with an error for an invalid response body + Given some behat configuration + And scenario steps tagged with "@api": + """ + Given the response JSON content is the following: + ''' + {broken json + ''' + Then the response should match the following JSON schema: + ''' + {"type": "object"} + ''' + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The response is not valid JSON + """ + + Scenario: Assert "Then the response should match the JSON schema in the file :filename" works + When I go to "/sites/default/files/json_valid.json" + Then the response should match the JSON schema in the file "json_schema.json" + + @trait:JsonTrait + Scenario: Assert that "Then the response should match the JSON schema in the file :filename" fails with an exception for missing file + Given some behat configuration + And scenario steps: + """ + When I go to "/sites/default/files/json_valid.json" + Then the response should match the JSON schema in the file "nonexistent.json" + """ + When I run "behat --no-colors" + Then it should fail with an exception: + """ + does not exist + """ + + Scenario: Assert "When I print last JSON response" works + Given the response JSON from the file "json_valid.json" + When I print last JSON response + + @trait:JsonTrait + Scenario: Assert that "When I print last JSON response" fails with an error for invalid JSON + Given some behat configuration + And scenario steps tagged with "@api": + """ + Given the response JSON content is the following: + ''' + {broken json + ''' + When I print last JSON response + """ + When I run "behat --no-colors" + Then it should fail with an error: + """ + The response is not valid JSON + """ + + Scenario: Assert that JSON data is reloaded when navigating between different JSON files + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.name" should be equal to "John Doe" + When I go to "/sites/default/files/json_alt.json" + Then the JSON path "$.name" should be equal to "Jane Roe" + And the JSON path "$.count" should be equal to "5" + When I go to "/sites/default/files/json_valid.json" + Then the JSON path "$.name" should be equal to "John Doe" diff --git a/tests/behat/fixtures/json_alt.json b/tests/behat/fixtures/json_alt.json new file mode 100644 index 00000000..c873ee22 --- /dev/null +++ b/tests/behat/fixtures/json_alt.json @@ -0,0 +1,4 @@ +{ + "name": "Jane Roe", + "count": 5 +} diff --git a/tests/behat/fixtures/json_invalid.json b/tests/behat/fixtures/json_invalid.json new file mode 100644 index 00000000..bc46a383 --- /dev/null +++ b/tests/behat/fixtures/json_invalid.json @@ -0,0 +1 @@ +{"name": "John Doe" "age": 42} diff --git a/tests/behat/fixtures/json_schema.json b/tests/behat/fixtures/json_schema.json new file mode 100644 index 00000000..0a1b6ad5 --- /dev/null +++ b/tests/behat/fixtures/json_schema.json @@ -0,0 +1,30 @@ +{ + "type": "object", + "required": ["name", "age", "active"], + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "email": {"type": "string"}, + "active": {"type": "boolean"}, + "disabled": {"type": "boolean"}, + "price": {"type": "number"}, + "user": { + "type": "object", + "properties": { + "roles": {"type": "array", "items": {"type": "string"}} + } + }, + "items": {"type": "array", "items": {"type": "integer"}}, + "books": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "title"], + "properties": { + "id": {"type": "integer"}, + "title": {"type": "string"} + } + } + } + } +} diff --git a/tests/behat/fixtures/json_valid.json b/tests/behat/fixtures/json_valid.json new file mode 100644 index 00000000..09b3decc --- /dev/null +++ b/tests/behat/fixtures/json_valid.json @@ -0,0 +1,17 @@ +{ + "name": "John Doe", + "age": 42, + "email": "john@example.com", + "active": true, + "disabled": false, + "nickname": null, + "price": 9.99, + "user": { + "roles": ["admin", "editor"] + }, + "items": [1, 2, 3], + "books": [ + {"id": 123, "title": "The Great Adventure"}, + {"id": 456, "title": "Learning JSON"} + ] +}