Last active
April 22, 2022 07:00
-
-
Save bwaidelich/9bb76636cb5643fca1be30d34e33cee8 to your computer and use it in GitHub Desktop.
GraphQL based Behat testing in Flow
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Behat distribution configuration | |
# | |
# Override with behat.yml for local configuration. | |
# Alternatively use environment variables to override parameters, for example: | |
# BEHAT_PARAMS='{"suites": {"behat": {"contexts": [{"FeatureContext": {"graphQLEndpointUrl": "http://localhost:8082/graphql", "testingDBSuffix": "_testing"}}]}}}' | |
# | |
default: | |
autoload: | |
'': '%paths.base%/Bootstrap' | |
suites: | |
behat: | |
paths: | |
- '%paths.base%/Features' | |
contexts: | |
- FeatureContext: | |
# URL at which this instance is running (in Testing/Behat context) | |
graphQLEndpointUrl: 'http://localhost:8082/graphql' | |
# This suffix is checked against when resetting the database for each Behat scenario | |
# in order to prevent data loss in case no individual db is configured for Testing/Behat context | |
testingDBSuffix: '_testing' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
use Behat\Behat\Context\Context; | |
use Behat\Gherkin\Node\PyStringNode; | |
use Behat\Gherkin\Node\TableNode; | |
use Doctrine\DBAL\Connection; | |
use Doctrine\ORM\EntityManagerInterface; | |
use Firebase\JWT\JWT; | |
use GuzzleHttp\Client; | |
use GuzzleHttp\Psr7\ServerRequest; | |
use GuzzleHttp\Psr7\Uri; | |
use Neos\Behat\Tests\Behat\FlowContextTrait; | |
use Neos\Flow\ObjectManagement\ObjectManagerInterface; | |
use Neos\Flow\Security\Context as SecurityContext; | |
use Neos\Utility\ObjectAccess; | |
use PHPUnit\Framework\Assert; | |
require_once(\dirname(__DIR__, 3) . '/Packages/Application/Neos.Behat/Tests/Behat/FlowContextTrait.php'); | |
require_once(__DIR__ . '/GraphQLResponse.php'); | |
class FeatureContext implements Context | |
{ | |
use FlowContextTrait; | |
/** | |
* @var ObjectManagerInterface | |
*/ | |
protected $objectManager; | |
private string $graphQLEndpointUrl; | |
private string $testingDBSuffix; | |
private Connection $dbal; | |
private FakeEmailService $fakeEmailService; | |
private SecurityContext $securityContext; | |
private static array $variables = []; | |
private ?GraphQLResponse $lastGraphQLResponse = null; | |
private bool $handledLastGraphQLError = false; | |
public function __construct(string $graphQLEndpointUrl = null, string $testingDBSuffix = null) | |
{ | |
if (self::$bootstrap === null) { | |
self::$bootstrap = $this->initializeFlow(); | |
} | |
$this->objectManager = self::$bootstrap->getObjectManager(); | |
$this->graphQLEndpointUrl = $graphQLEndpointUrl ?? 'http://localhost'; | |
$this->testingDBSuffix = $testingDBSuffix ?? '_testing'; | |
/** @var EntityManagerInterface $entityManager */ | |
$entityManager = $this->objectManager->get(EntityManagerInterface::class); | |
$this->dbal = $entityManager->getConnection(); | |
/** @noinspection PhpFieldAssignmentTypeMismatchInspection */ | |
$this->fakeEmailService = $this->objectManager->get(FakeEmailService::class); | |
/** @noinspection PhpFieldAssignmentTypeMismatchInspection */ | |
$this->securityContext = $this->objectManager->get(SecurityContext::class); | |
} | |
/** | |
* @BeforeScenario | |
*/ | |
public function resetData(): void | |
{ | |
if (!str_ends_with($this->dbal->getDatabase(), $this->testingDBSuffix)) { | |
throw new \RuntimeException(sprintf('Testing database name has to be suffixed with "%s" to prevent data loss. But the current database name is "%s".', $this->testingDBSuffix, $this->dbal->getDatabase()), 1630596717); | |
} | |
// TODO reset your event stores & write/read model states | |
} | |
/** | |
* @AfterScenario | |
*/ | |
public function throwGraphQLException(): void | |
{ | |
$this->failIfLastGraphQLResponseHasErrors(); | |
} | |
/** | |
* @When I send the following GraphQL query with authorization token :authorizationToken: | |
*/ | |
public function iSendTheFollowingGraphQLQueryWithAuthorizationToken(string $authorizationToken, PyStringNode $query): void | |
{ | |
$this->sendGraphQLQuery($query->getRaw(), $this->replaceVariables($authorizationToken)); | |
} | |
/** | |
* @When I send the following GraphQL query: | |
*/ | |
public function iSendTheFollowingGraphQLQuery(PyStringNode $query): void | |
{ | |
$this->sendGraphQLQuery($query->getRaw()); | |
} | |
/** | |
* @When I remember the GraphQL response at :responsePath as :variableName | |
*/ | |
public function iRememberTheGraphQLResponseAtAs(string $responsePath, string $variableName): void | |
{ | |
$this->failIfLastGraphQLResponseHasErrors(); | |
self::$variables[$variableName] = ObjectAccess::getPropertyPath($this->lastGraphQLResponse->toDataArray(), $responsePath); | |
} | |
/** | |
* @When I expect the GraphQL response to contain an error with code :expectedErrorCode | |
*/ | |
public function iExpectTheGraphQLResponseToContainAnErrorWithCode(int $expectedErrorCode): void | |
{ | |
Assert::assertContains($expectedErrorCode, $this->lastGraphQLResponse->errorCodes(), sprintf('Expected last GraphQL response to contain error with code "%s", but it didn\'t: %s', $expectedErrorCode, $this->lastGraphQLResponse)); | |
$this->handledLastGraphQLError = true; | |
} | |
/** | |
* @When I remember the claim :claim of JWT :jwt as :variableName | |
*/ | |
public function iRememberTheClaimOfJwtAs(string $claim, string $jwt, string $variableName): void | |
{ | |
$tks = \explode('.', $this->replaceVariables($jwt)); | |
if (\count($tks) !== 3) { | |
throw new \RuntimeException(sprintf('Failed to parse JWT "%s": Invalid number of segments', $jwt), 1630513785); | |
} | |
$payload = (array)JWT::jsonDecode(JWT::urlsafeB64Decode($tks[1])); | |
if (!array_key_exists($claim, $payload)) { | |
throw new \RuntimeException(sprintf('JWT "%s" does not contain a claim "%s"', $jwt, $claim), 1630513938); | |
} | |
self::$variables[$variableName] = $payload[$claim]; | |
} | |
/** | |
* @When I wait for account with id :accountId to exist | |
*/ | |
public function iWaitForAccountWithIdToExist(string $accountId): void | |
{ | |
$accountId = $this->replaceVariables($accountId); | |
$this->retry(function (bool &$cancel) use ($accountId) { | |
$result = $this->dbal->fetchOne('SELECT persistence_object_identifier FROM neos_flow_security_account WHERE persistence_object_identifier = :accountId', ['accountId' => $accountId]); | |
if ($result !== false) { | |
$cancel = true; | |
} | |
return $result; | |
}, 50, 100); | |
} | |
/** | |
* @Then I expect the following GraphQL response: | |
*/ | |
public function iExpectTheFollowingGraphQLResponse(PyStringNode $string) | |
{ | |
try { | |
$expectedResponse = json_decode($string->getRaw(), true, 512, JSON_THROW_ON_ERROR); | |
} catch (\JsonException $e) { | |
throw new \InvalidArgumentException(sprintf('Failed to JSON decode expected GraphQL response: %s. %s', $e->getMessage(), $string->getRaw()), 1631036320, $e); | |
} | |
$expectedResponse = $this->replaceVariablesInArray($expectedResponse); | |
Assert::assertSame($expectedResponse, $this->lastGraphQLResponse->toDataArray()); | |
} | |
/** | |
* @Then I expect an array GraphQL response at :propertyPath that contains: | |
*/ | |
public function iExpectAGraphQLResponseAtThatContains($propertyPath, PyStringNode $string) | |
{ | |
$array = ObjectAccess::getPropertyPath($this->lastGraphQLResponse->toDataArray(), $propertyPath); | |
try { | |
$expectedResponse = json_decode($string->getRaw(), true, 512, JSON_THROW_ON_ERROR); | |
} catch (\JsonException $e) { | |
throw new \InvalidArgumentException(sprintf('Failed to JSON decode expected GraphQL response: %s. %s', $e->getMessage(), $string->getRaw()), 1631117527, $e); | |
} | |
$expectedResponse = $this->replaceVariablesInArray($expectedResponse); | |
Assert::assertContains($expectedResponse, $array, "Item should be in result\n" . $this->lastGraphQLResponse); | |
} | |
// --------------------------------- | |
private function retry(\Closure $callback, int $maxAttempts, int $retryIntervalInMilliseconds) | |
{ | |
$attempts = 1; | |
$cancel = false; | |
do { | |
$result = $callback($cancel); | |
if ($cancel) { | |
$this->printDebug(sprintf('Success after %d attempt(s)', $attempts)); | |
return $result; | |
} | |
usleep($retryIntervalInMilliseconds * 1000); | |
} while (++$attempts <= $maxAttempts); | |
throw new \RuntimeException(sprintf('Failed after %d attempts', $maxAttempts), 1630747953); | |
} | |
private function sendGraphQLQuery(string $query, ?string $authorizationToken = null): void | |
{ | |
if ($this->lastGraphQLResponse !== null && $this->lastGraphQLResponse->hasErrors()) { | |
Assert::fail(sprintf('Previous GraphQL Response contained unhandled errors: %s', $this->lastGraphQLResponse)); | |
} | |
$this->handledLastGraphQLError = false; | |
$data = ['query' => $this->replaceVariables($query), 'variables' => []]; | |
try { | |
$body = json_encode($data, JSON_THROW_ON_ERROR); | |
} catch (\JsonException $e) { | |
throw new \InvalidArgumentException(sprintf('Failed to JSON encode GraphQL body: %s', $e->getMessage()), 1630511632, $e); | |
} | |
$headers = [ | |
'Content-Type' => 'application/json' | |
]; | |
if ($authorizationToken !== null) { | |
$headers['Authorization'] = 'Bearer ' . $authorizationToken; | |
} | |
$request = new ServerRequest('POST', new Uri($this->graphQLEndpointUrl), $headers, $body); | |
$client = new Client(); | |
$response = $client->send($request, ['http_errors' => false]); | |
$this->lastGraphQLResponse = GraphQLResponse::fromResponseBody($response->getBody()->getContents()); | |
} | |
private function failIfLastGraphQLResponseHasErrors(): void | |
{ | |
if (!$this->handledLastGraphQLError && $this->lastGraphQLResponse !== null && $this->lastGraphQLResponse->hasErrors()) { | |
Assert::fail(sprintf('Last GraphQL query produced an error "%s" (code: %s)', $this->lastGraphQLResponse->firstErrorMessage(), $this->lastGraphQLResponse->firstErrorCode())); | |
} | |
} | |
/** | |
* @param TableNode $table | |
* @return array | |
*/ | |
private function parseJsonTable(TableNode $table): array | |
{ | |
return array_map(static function (array $row) { | |
return array_map(static function (string $jsonValue) { | |
if (strncmp($jsonValue, 'date:', 5) === 0) { | |
try { | |
$decodedValue = new DateTime(substr($jsonValue, 5)); | |
} catch (Exception $e) { | |
throw new RuntimeException(sprintf('Failed to decode json value "%s" to DateTime instance: %s', substr($jsonValue, 5), $e->getMessage()), 1636021305, $e); | |
} | |
} else { | |
try { | |
$decodedValue = json_decode($jsonValue, true, 512, JSON_THROW_ON_ERROR); | |
} catch (JsonException $e) { | |
throw new \InvalidArgumentException(sprintf('Failed to decode JSON value %s: %s', $jsonValue, $e->getMessage()), 1636021310, $e); | |
} | |
} | |
return $decodedValue; | |
}, $row); | |
}, $table->getHash()); | |
} | |
/** | |
* @param string $string | |
* @return string the original $string with <variables> replaced | |
*/ | |
private function replaceVariables(string $string): string | |
{ | |
return preg_replace_callback('/<([\w\.]+)>/', static function ($matches) { | |
$variableName = $matches[1]; | |
$result = ObjectAccess::getPropertyPath(self::$variables, $variableName); | |
if ($result === null) { | |
throw new \InvalidArgumentException(sprintf('Variable "%s" is not defined!', $variableName), 1630508048); | |
} | |
return $result; | |
}, $string); | |
} | |
private function replaceVariablesInArray(array $array): array | |
{ | |
foreach ($array as &$value) { | |
if (is_array($value)) { | |
$value = $this->replaceVariablesInArray($value); | |
} elseif (is_string($value)) { | |
$value = $this->replaceVariables($value); | |
} | |
} | |
return $array; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
declare(strict_types=1); | |
final class GraphQLResponse | |
{ | |
private string $responseBody; | |
private array $parsedBody; | |
private function __construct(string $responseBody) | |
{ | |
$this->responseBody = $responseBody; | |
$this->parsedBody = json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR); | |
} | |
public static function fromResponseBody(string $responseBody): self | |
{ | |
return new self($responseBody); | |
} | |
public function toDataArray(): array | |
{ | |
return $this->parsedBody['data'] ?? []; | |
} | |
public function toErrorArray(): array | |
{ | |
return $this->parsedBody['errors'] ?? []; | |
} | |
public function hasErrors(): bool | |
{ | |
return isset($this->parsedBody['errors']); | |
} | |
public function firstErrorMessage(): ?string | |
{ | |
return $this->parsedBody['errors'][0]['message'] ?? null; | |
} | |
public function firstErrorCode(): ?int | |
{ | |
return $this->parsedBody['errors'][0]['extensions']['code'] ?? null; | |
} | |
public function errorCodes(): array | |
{ | |
return array_filter(array_map(static fn(array $error) => $error['extensions']['code'] ?? null, $this->toErrorArray())); | |
} | |
public function __toString(): string | |
{ | |
return $this->responseBody; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Install
neos/behat
via:and update dependencies via:
Example feature (located at
/Tests/Behavior/Features/SomeFolder/SomeFeature.feature
):To execute the tests:
or
../../bin/behat Features/SomeFolder/SomeFeature.feature:4
to execute individual features (see https://docs.behat.org/en/v2.5/guides/6.cli.html)