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"}
+ ]
+}