diff --git a/.gitattributes b/.gitattributes index d36f031709..3987fa6b17 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,13 +9,11 @@ /.gitattributes export-ignore /.gitignore export-ignore /.styleci.yml export-ignore -/_ide_helper.php export-ignore /CHANGELOG.md export-ignore /CONTRIBUTING.md export-ignore /docker-compose.yml export-ignore /Dockerfile export-ignore /logo.png export-ignore -/logo.png export-ignore /Makefile export-ignore /netlify.toml export-ignore /phpbench.json export-ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 9394aebfdd..d234af3ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ You can find and compare releases at the [GitHub release page](https://github.co - Add flag `--json` to `print-schema` to output JSON instead of GraphQL SDL https://github.com/nuwave/lighthouse/pull/1268 - Add TTL option for subscriptions storage https://github.com/nuwave/lighthouse/pull/1284 +- Provide assertion helpers through `TestResponseMixin` https://github.com/nuwave/lighthouse/pull/1308 ### Fixed diff --git a/_ide_helper.php b/_ide_helper.php index 718cf6e59e..2a6ec0884f 100644 --- a/_ide_helper.php +++ b/_ide_helper.php @@ -6,10 +6,43 @@ class TestResponse /** * Asserts that the response contains an error from a given category. * - * @param string $category + * @param string $category The name of the expected error category. * @return $this */ - public function assertErrorCategory(string $category): self + public function assertGraphQLErrorCategory(string $category): self + { + return $this; + } + + /** + * Assert that the returned result contains exactly the given validation keys. + * + * @param array $keys The validation keys the result should have. + * @return $this + */ + public function assertGraphQLValidationKeys(array $keys): self + { + return $this; + } + + /** + * Assert that a given validation error is present in the response. + * + * @param string $key The validation key that should be present. + * @param string $message The expected validation message. + * @return $this + */ + public function assertGraphQLValidationError(string $key, string $message): self + { + return $this; + } + + /** + * Assert that no validation errors are present in the response. + * + * @return $this + */ + public function assertGraphQLValidationPasses(): self { return $this; } @@ -20,7 +53,8 @@ public function assertErrorCategory(string $category): self * @param string|null $key * @return mixed */ - public function jsonGet(string $key = null) { + public function jsonGet(string $key = null) + { return; } } @@ -32,10 +66,43 @@ class TestResponse /** * Asserts that the response contains an error from a given category. * - * @param string $category + * @param string $category The name of the expected error category. * @return $this */ - public function assertErrorCategory(string $category): self + public function assertGraphQLErrorCategory(string $category): self + { + return $this; + } + + /** + * Assert that the returned result contains exactly the given validation keys. + * + * @param array $keys The validation keys the result should have. + * @return $this + */ + public function assertGraphQLValidationKeys(array $keys): self + { + return $this; + } + + /** + * Assert that a given validation error is present in the response. + * + * @param string $key The validation key that should be present. + * @param string $message The expected validation message. + * @return $this + */ + public function assertGraphQLValidationError(string $key, string $message): self + { + return $this; + } + + /** + * Assert that no validation errors are present in the response. + * + * @return $this + */ + public function assertGraphQLValidationPasses(): self { return $this; } @@ -46,7 +113,8 @@ public function assertErrorCategory(string $category): self * @param string|null $key * @return mixed */ - public function jsonGet(string $key = null) { + public function jsonGet(string $key = null) + { return; } } @@ -56,6 +124,8 @@ public function jsonGet(string $key = null) { class ResolveInfo { /** + * We monkey patch this onto here to pass it down the resolver chain. + * * @var \Nuwave\Lighthouse\Execution\Arguments\ArgumentSet */ public $argumentSet; diff --git a/docs/master/testing/phpunit.md b/docs/master/testing/phpunit.md index 500adb27fc..3e3550056e 100644 --- a/docs/master/testing/phpunit.md +++ b/docs/master/testing/phpunit.md @@ -124,6 +124,29 @@ public function testOrdersUsersByName(): void } ``` +### TestResponse Assertion Mixins + +Lighthouse conveniently provides additional assertions as mixins to the `TestResponse` class. +Make sure to publish the latest [IDE-helper file](/_ide_helper.php) to get proper autocompletion: + +```bash +php artisan vendor:publish --provider="Nuwave\Lighthouse\LighthouseServiceProvider" --tag=ide-helper +``` + +The provided assertions are prefixed with `assertGraphQL` for easy discovery. +They offer useful shortcuts to common testing tasks. +For example, you might want to ensure that validation works properly: + +```php +$this + ->graphQL(/** @lang GraphQL */ ' + mutation { + createUser(email: "invalid email") + } + ') + ->assertGraphQLValidationKeys(['email']); +``` + ## Simulating File Uploads Lighthouse allows you to [upload files](../digging-deeper/file-uploads.md) through GraphQL. diff --git a/src/Exceptions/ValidationException.php b/src/Exceptions/ValidationException.php index 21754bc22e..a45e5e24d8 100644 --- a/src/Exceptions/ValidationException.php +++ b/src/Exceptions/ValidationException.php @@ -4,6 +4,8 @@ class ValidationException extends \Illuminate\Validation\ValidationException implements RendersErrorsExtensions { + const CATEGORY = 'validation'; + /** * Returns true when exception message is safe to be displayed to a client. * @@ -21,7 +23,7 @@ public function isClientSafe() */ public function getCategory() { - return 'validation'; + return self::CATEGORY; } /** @@ -30,6 +32,6 @@ public function getCategory() */ public function extensionsContent(): array { - return ['validation' => $this->errors()]; + return [self::CATEGORY => $this->errors()]; } } diff --git a/src/LighthouseServiceProvider.php b/src/LighthouseServiceProvider.php index 52d6fce6c5..bb229d95b8 100644 --- a/src/LighthouseServiceProvider.php +++ b/src/LighthouseServiceProvider.php @@ -68,6 +68,10 @@ public function boot(ValidationFactory $validationFactory, ConfigRepository $con __DIR__.'/../assets/default-schema.graphql' => $configRepository->get('lighthouse.schema.register'), ], 'schema'); + $this->publishes([ + __DIR__.'/../_ide_helper.php' => $this->app->make('path.base').'/_lighthouse_ide_helper.php', + ], 'ide-helper'); + $this->loadRoutesFrom(__DIR__.'/Support/Http/routes.php'); $validationFactory->resolver( @@ -112,7 +116,6 @@ public function register(): void $this->app->singleton(CanStreamResponse::class, ResponseStream::class); $this->app->bind(CreatesResponse::class, SingleResponse::class); - $this->app->bind(GlobalIdContract::class, GlobalId::class); $this->app->singleton(GraphQLRequest::class, function (Container $app): GraphQLRequest { diff --git a/src/Testing/MakesGraphQLRequests.php b/src/Testing/MakesGraphQLRequests.php index 7790d5ab5e..90112edb01 100644 --- a/src/Testing/MakesGraphQLRequests.php +++ b/src/Testing/MakesGraphQLRequests.php @@ -10,7 +10,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse; /** - * Useful helpers for PHPUnit testing. + * Testing helpers for making requests to the GraphQL endpoint. * * @mixin \Illuminate\Foundation\Testing\Concerns\MakesHttpRequests */ diff --git a/src/Testing/TestResponseMixin.php b/src/Testing/TestResponseMixin.php new file mode 100644 index 0000000000..2dec68f3dd --- /dev/null +++ b/src/Testing/TestResponseMixin.php @@ -0,0 +1,86 @@ +assertJson([ + 'errors' => [ + [ + 'extensions' => [ + 'validation' => [ + $key => [ + $message, + ], + ], + ], + ], + ], + ]); + + return $this; + }; + } + + public function assertGraphQLValidationKeys(): Closure + { + return function (array $keys) { + $validation = TestResponseUtils::extractValidationErrors($this); + + Assert::assertSame( + $keys, + array_keys($validation['extensions']['validation']), + 'Expected the query to return validation errors for specific fields.' + ); + + return $this; + }; + } + + public function assertGraphQLValidationPasses(): Closure + { + return function () { + $validation = TestResponseUtils::extractValidationErrors($this); + + Assert::assertNull( + $validation, + 'Expected the query to have no validation errors.' + ); + + return $this; + }; + } + + public function assertGraphQLErrorCategory(): Closure + { + return function (string $category) { + $this->assertJson([ + 'errors' => [ + [ + 'extensions' => [ + 'category' => $category, + ], + ], + ], + ]); + + return $this; + }; + } + + public function jsonGet(): Closure + { + return function (string $key = null) { + return data_get($this->decodeResponseJson(), $key); + }; + } +} diff --git a/src/Testing/TestResponseUtils.php b/src/Testing/TestResponseUtils.php new file mode 100644 index 0000000000..43bd136934 --- /dev/null +++ b/src/Testing/TestResponseUtils.php @@ -0,0 +1,29 @@ +json('errors') ?? []; + + return Arr::first( + $errors, + function (array $error): bool { + return Arr::get($error, 'extensions.category') === ValidationException::CATEGORY; + } + ); + } +} diff --git a/src/Testing/TestingServiceProvider.php b/src/Testing/TestingServiceProvider.php index 19ae13f259..97f3ff844e 100644 --- a/src/Testing/TestingServiceProvider.php +++ b/src/Testing/TestingServiceProvider.php @@ -24,5 +24,11 @@ static function (): string { public function register(): void { $this->app->singleton(MockDirective::class); + + if (class_exists('Illuminate\Testing\TestResponse')) { + \Illuminate\Testing\TestResponse::mixin(new TestResponseMixin()); + } elseif (class_exists('Illuminate\Foundation\Testing\TestResponse')) { + \Illuminate\Foundation\Testing\TestResponse::mixin(new TestResponseMixin()); + } } } diff --git a/tests/Integration/Schema/Directives/CanDirectiveDBTest.php b/tests/Integration/Schema/Directives/CanDirectiveDBTest.php index 74039c9df9..67b65e6a4a 100644 --- a/tests/Integration/Schema/Directives/CanDirectiveDBTest.php +++ b/tests/Integration/Schema/Directives/CanDirectiveDBTest.php @@ -213,7 +213,7 @@ public function testThrowsIfNotAuthorized(): void title } } - ")->assertErrorCategory(AuthorizationException::CATEGORY); + ")->assertGraphQLErrorCategory(AuthorizationException::CATEGORY); } public function testCanHandleMultipleModels(): void diff --git a/tests/Integration/Schema/Directives/HasManyDirectiveTest.php b/tests/Integration/Schema/Directives/HasManyDirectiveTest.php index 35febc20aa..b502dcadf3 100644 --- a/tests/Integration/Schema/Directives/HasManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/HasManyDirectiveTest.php @@ -276,7 +276,7 @@ public function testHandlesPaginationWithCountZero(): void 'tasks' => null, ], ], - ])->assertErrorCategory(Error::CATEGORY_GRAPHQL); + ])->assertGraphQLErrorCategory(Error::CATEGORY_GRAPHQL); } public function testRelayTypeIsLimitedByMaxCountFromDirective(): void diff --git a/tests/Integration/Schema/Directives/MorphManyDirectiveTest.php b/tests/Integration/Schema/Directives/MorphManyDirectiveTest.php index 9f323e0393..7af7ceb5d9 100644 --- a/tests/Integration/Schema/Directives/MorphManyDirectiveTest.php +++ b/tests/Integration/Schema/Directives/MorphManyDirectiveTest.php @@ -354,7 +354,7 @@ public function testHandlesPaginationWithCountZero(): void 'images' => null, ], ], - ])->assertErrorCategory(Error::CATEGORY_GRAPHQL); + ])->assertGraphQLErrorCategory(Error::CATEGORY_GRAPHQL); } public function testCanQueryMorphManyPaginatorWithADefaultCount(): void diff --git a/tests/Integration/Subscriptions/SubscriptionTest.php b/tests/Integration/Subscriptions/SubscriptionTest.php index 47a886cedb..a6ed010e45 100644 --- a/tests/Integration/Subscriptions/SubscriptionTest.php +++ b/tests/Integration/Subscriptions/SubscriptionTest.php @@ -110,13 +110,15 @@ public function testCanBroadcastSubscriptions(): void public function testThrowsWithMissingOperationName(): void { - $this->graphQL(' - subscription { - onPostCreated { - body + $this + ->graphQL(/** @lang GraphQL */ ' + subscription { + onPostCreated { + body + } } - } - ')->assertErrorCategory('subscription') + ') + ->assertGraphQLErrorCategory('subscription') ->assertJson([ 'data' => [ 'onPostCreated' => null, diff --git a/tests/TestCase.php b/tests/TestCase.php index 80d7839437..59be18c9a1 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -127,12 +127,6 @@ protected function getEnvironmentSetUp($app) ); $config->set('app.debug', true); - - if (class_exists('Illuminate\Testing\TestResponse')) { - \Illuminate\Testing\TestResponse::mixin(new TestResponseMixin()); - } elseif (class_exists('Illuminate\Foundation\Testing\TestResponse')) { - \Illuminate\Foundation\Testing\TestResponse::mixin(new TestResponseMixin()); - } } /** diff --git a/tests/TestResponseMixin.php b/tests/TestResponseMixin.php deleted file mode 100644 index b350bbe72a..0000000000 --- a/tests/TestResponseMixin.php +++ /dev/null @@ -1,35 +0,0 @@ -decodeResponseJson(), $key); - }; - } - - public function assertErrorCategory(): Closure - { - return function (string $category) { - $this->assertJson([ - 'errors' => [ - [ - 'extensions' => [ - 'category' => $category, - ], - ], - ], - ]); - - return $this; - }; - } -} diff --git a/tests/Unit/Execution/Arguments/TypedArgsTest.php b/tests/Unit/Execution/Arguments/TypedArgsTest.php index 8fa500d1b2..4830b0197b 100644 --- a/tests/Unit/Execution/Arguments/TypedArgsTest.php +++ b/tests/Unit/Execution/Arguments/TypedArgsTest.php @@ -13,7 +13,7 @@ class TypedArgsTest extends TestCase { public function testSimpleField(): void { - $this->schema = ' + $this->schema = /** @lang GraphQL */ ' type Query { foo(bar: Int): Int } @@ -32,7 +32,7 @@ public function testSimpleField(): void public function testNullableList(): void { - $this->schema = ' + $this->schema = /** @lang GraphQL */ ' type Query { foo(bar: [Int!]): Int } @@ -51,11 +51,11 @@ public function testNullableList(): void public function testNullableInputObject(): void { - $this->schema = ' + $this->schema = /** @lang GraphQL */ ' type Query { foo(bar: Bar): Int } - + input Bar { baz: ID } diff --git a/tests/Unit/Schema/AST/ASTHelperTest.php b/tests/Unit/Schema/AST/ASTHelperTest.php index ddcafa71bd..f0a28bb4ed 100644 --- a/tests/Unit/Schema/AST/ASTHelperTest.php +++ b/tests/Unit/Schema/AST/ASTHelperTest.php @@ -11,13 +11,13 @@ class ASTHelperTest extends TestCase { public function testThrowsWhenMergingUniqueNodeListWithCollision(): void { - $objectType1 = PartialParser::objectTypeDefinition(' + $objectType1 = PartialParser::objectTypeDefinition(/** @lang GraphQL */ ' type User { email: String } '); - $objectType2 = PartialParser::objectTypeDefinition(' + $objectType2 = PartialParser::objectTypeDefinition(/** @lang GraphQL */ ' type User { email(bar: String): Int } @@ -33,14 +33,14 @@ public function testThrowsWhenMergingUniqueNodeListWithCollision(): void public function testMergesUniqueNodeListsWithOverwrite(): void { - $objectType1 = PartialParser::objectTypeDefinition(' + $objectType1 = PartialParser::objectTypeDefinition(/** @lang GraphQL */ ' type User { first_name: String email: String } '); - $objectType2 = PartialParser::objectTypeDefinition(' + $objectType2 = PartialParser::objectTypeDefinition(/** @lang GraphQL */ ' type User { first_name: String @foo last_name: String @@ -62,7 +62,7 @@ public function testMergesUniqueNodeListsWithOverwrite(): void public function testCanExtractStringArguments(): void { - $directive = PartialParser::directive('@foo(bar: "baz")'); + $directive = PartialParser::directive(/** @lang GraphQL */ '@foo(bar: "baz")'); $this->assertSame( 'baz', ASTHelper::directiveArgValue($directive, 'bar') @@ -71,7 +71,7 @@ public function testCanExtractStringArguments(): void public function testCanExtractBooleanArguments(): void { - $directive = PartialParser::directive('@foo(bar: true)'); + $directive = PartialParser::directive(/** @lang GraphQL */ '@foo(bar: true)'); $this->assertTrue( ASTHelper::directiveArgValue($directive, 'bar') ); @@ -79,7 +79,7 @@ public function testCanExtractBooleanArguments(): void public function testCanExtractArrayArguments(): void { - $directive = PartialParser::directive('@foo(bar: ["one", "two"])'); + $directive = PartialParser::directive(/** @lang GraphQL */ '@foo(bar: ["one", "two"])'); $this->assertSame( ['one', 'two'], ASTHelper::directiveArgValue($directive, 'bar') @@ -88,7 +88,7 @@ public function testCanExtractArrayArguments(): void public function testCanExtractObjectArguments(): void { - $directive = PartialParser::directive('@foo(bar: { baz: "foobar" })'); + $directive = PartialParser::directive(/** @lang GraphQL */ '@foo(bar: { baz: "foobar" })'); $this->assertSame( ['baz' => 'foobar'], ASTHelper::directiveArgValue($directive, 'bar') @@ -97,7 +97,7 @@ public function testCanExtractObjectArguments(): void public function testReturnsNullForNonExistingArgumentOnDirective(): void { - $directive = PartialParser::directive('@foo'); + $directive = PartialParser::directive(/** @lang GraphQL */ '@foo'); $this->assertNull( ASTHelper::directiveArgValue($directive, 'bar') ); @@ -105,10 +105,10 @@ public function testReturnsNullForNonExistingArgumentOnDirective(): void public function testChecksWhetherTypeImplementsInterface(): void { - $type = PartialParser::objectTypeDefinition(' - type Foo implements Bar { - baz: String - } + $type = PartialParser::objectTypeDefinition(/** @lang GraphQL */ ' + type Foo implements Bar { + baz: String + } '); $this->assertTrue(ASTHelper::typeImplementsInterface($type, 'Bar')); $this->assertFalse(ASTHelper::typeImplementsInterface($type, 'FakeInterface')); diff --git a/tests/Unit/Schema/Directives/CanDirectiveTest.php b/tests/Unit/Schema/Directives/CanDirectiveTest.php index c88733e5f4..6ba7e1fda5 100644 --- a/tests/Unit/Schema/Directives/CanDirectiveTest.php +++ b/tests/Unit/Schema/Directives/CanDirectiveTest.php @@ -32,7 +32,7 @@ public function testThrowsIfNotAuthorized(): void name } } - ')->assertErrorCategory(AuthorizationException::CATEGORY); + ')->assertGraphQLErrorCategory(AuthorizationException::CATEGORY); } public function testPassesAuthIfAuthorized(): void @@ -157,7 +157,7 @@ public function testProcessesTheArgsArgument(): void name } } - ')->assertErrorCategory(AuthorizationException::CATEGORY); + ')->assertGraphQLErrorCategory(AuthorizationException::CATEGORY); } public function testInjectArgsPassesClientArgumentToPolicy(): void