Skip to content

Instantly share code, notes, and snippets.

@bwaidelich
Last active April 22, 2022 07:00
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bwaidelich/9bb76636cb5643fca1be30d34e33cee8 to your computer and use it in GitHub Desktop.
Save bwaidelich/9bb76636cb5643fca1be30d34e33cee8 to your computer and use it in GitHub Desktop.
GraphQL based Behat testing in Flow
# 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'
<?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;
}
}
<?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;
}
}
@bwaidelich
Copy link
Author

bwaidelich commented Nov 24, 2021

Install neos/behat via:

composer require neos/behat
./flow behat:setup

and update dependencies via:

cd Build/Behat
composer update

Example feature (located at /Tests/Behavior/Features/SomeFolder/SomeFeature.feature):

Feature: Some example feature
  lorem ipsum

  Scenario: Lorem ipsum
    Given I send the following GraphQL query:
      """
      mutation {
        login(
          username: "some-user"
          password: "password"
        )
      }
      """
    And I remember the GraphQL response at "login" as "jwt"
    And I remember the claim "sub" of JWT "<jwt>" as "userId"
    And I wait for account with id "<userId>" to exist
    When I send the following GraphQL query with authorization token "<jwt>":
      """
      {
        some_query(userId: "<userId>") {
          foo
        }
      }
      """
    Then I expect the following GraphQL response:
      """
      {
        "some_query": {
          "foo": "bar"
        }
      }
      """

To execute the tests:

cd Tests/Behavior
../../bin/behat

or ../../bin/behat Features/SomeFolder/SomeFeature.feature:4 to execute individual features (see https://docs.behat.org/en/v2.5/guides/6.cli.html)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment