13. Testing
Test your API over HTTP, the way clients use it. That exercises routing, middleware, authentication, parsing, validation, authorization and serialisation together. The example's 56 tests in tests/Feature/GraphQL are all written this way.
A base test case
// example/tests/Feature/GraphQL/GraphQLTestCase.php (abridged)
abstract class GraphQLTestCase extends TestCase
{
use RefreshDatabase;
protected function graphql(string $query, array $variables = [], ?User $as = null, string $endpoint = '/graphql'): TestResponse
{
return $this->postJson($endpoint, ['query' => $query, 'variables' => $variables], $this->headersFor($as));
}
protected function headersFor(?User $user): array
{
return $user === null ? [] : ['Authorization' => 'Bearer '.JWTAuth::fromUser($user)];
}
}A test then reads like the request it makes. $this->member, $this->admin and $this->acme (an organization) below aren't magic — GraphQLTestCase::setUp() (not shown above) creates them once per test, an admin and a member in the same organization, exactly like any other Laravel RefreshDatabase fixture setup:
public function test_members_see_their_own_email_but_not_other_peoples(): void
{
$this->graphql('query ($id: ID!) { user(id: $id) { email } }', ['id' => $this->member->id], as: $this->member)
->assertOk()
->assertJsonPath('data.user.email', $this->member->email);
}With session or Sanctum authentication, actingAs($user) (or Sanctum::actingAs($user)) replaces the header helper.
Token guards between requests
Laravel's test client reuses one application for every request in a test. Guards cache the user they resolved, and the JWT package also caches the token, so a second request without a token can still be authenticated as the first request's user. That hides authorization bugs. Reset the state before each request:
public function call($method, $uri, $parameters = [], $cookies = [], $files = [], $server = [], $content = null)
{
$this->app['auth']->forgetGuards();
JWTAuth::unsetToken(); // the JWTAuth facade's token…
$this->app['tymon.jwt']->unsetToken(); // …and the one the JWT guard reads
return parent::call($method, $uri, $parameters, $cookies, $files, $server, $content);
}(forgetGuards() alone is enough for Sanctum tokens.)
Shippable test helpers
The graphql() helper and the assertJsonPath('errors.0.extensions...') idioms below are so common that Laragraph ships them as a trait plus a handful of TestResponse macros — add Ayimdomnic\Laragraph\Testing\MakesGraphQLRequests to your own test base class instead of hand-rolling the helper shown above:
abstract class TestCase extends \Illuminate\Foundation\Testing\TestCase
{
use \Ayimdomnic\Laragraph\Testing\MakesGraphQLRequests;
// Laragraph doesn't know which auth package you use — override this once.
protected function graphqlAuthHeaders(mixed $as): array
{
return $as === null ? [] : ['Authorization' => 'Bearer ' . JWTAuth::fromUser($as)];
}
}$this->graphql('query ($id: ID!) { user(id: $id) { email } }', ['id' => $this->member->id], as: $this->member)
->assertNoGraphQLErrors()
->assertGraphQLData('user.email', $this->member->email);| Macro | Equivalent to |
|---|---|
assertGraphQLData($path, $value) | assertJsonPath("data.{$path}", $value) |
assertGraphQLErrors() / assertNoGraphQLErrors() | asserting errors is present-and-non-empty / absent-or-empty |
assertGraphQLErrorCategory($category) | assertJsonPath('errors.0.extensions.category', $category) |
assertGraphQLErrorCode($code) | assertJsonPath('errors.0.extensions.code', $code) |
assertGraphQLValidationError($field, $message = null) | asserting the validation category, that extensions.validation.{$field} exists, and optionally that it contains $message |
This is unrelated to tests/TestCase.php in Laragraph's own repository, which only exists to test the package itself and is never autoloaded into a consumer application — MakesGraphQLRequests is the one meant for your app.
What to assert
The examples below use assertJsonPath directly — reach for the macros above instead when the shape matches; they're the same assertions, just named for what they mean.
Data
$response->assertJsonPath('data.post.title', 'Hello')
->assertJsonMissingPath('errors');Remember that GraphQL errors come back with HTTP 200, so assertOk() alone proves nothing. Also check that errors is absent, or check its content.
Validation errors
$this->graphql('mutation { register(name: "A", email: "not-an-email", password: "x",
password_confirmation: "y", organizationSlug: "nope") { token } }')
->assertJsonPath('errors.0.extensions.category', 'validation')
->assertJsonStructure(['errors' => [['extensions' => ['validation' => ['email', 'password', 'organizationSlug']]]]]);Authorization
Test the denied paths at least as thoroughly as the allowed ones: guests, other users, other organizations and missing records.
$this->graphql('{ users { edges { node { id } } } }', as: $this->member)
->assertJsonPath('errors.0.extensions.category', 'authorization')
->assertJsonPath('data.users', null);N+1 regressions
Count queries at two data sizes and assert that the count doesn't grow:
// example/tests/Feature/GraphQL/BatchingNPlusOneTest.php
config(['laragraph.cache.response.enabled' => false]); // measure the database, not the cache
$this->addOrganizations(2);
$few = $this->countQueries();
$this->addOrganizations(10);
$many = $this->countQueries();
$this->assertSame($few, $many);Subscriptions
With QUEUE_CONNECTION=sync, broadcastLater() runs immediately. Fake the message event to capture what each subscriber receives:
Event::fake([SubscriptionMessage::class]);
$subscriberId = $this->graphql('subscription { postPublished(organizationId: 1) { title } }', as: $this->member)
->json('extensions.subscription.subscriberId');
$this->graphql('mutation { publishPost(id: 7) { id } }', as: $this->admin);
Event::assertDispatched(SubscriptionMessage::class, fn (SubscriptionMessage $m) =>
$m->subscriberId === $subscriberId
&& $m->payload['data']['postPublished']['title'] === 'Hello');To assert that the fan-out is queued, use Queue::fake() and Queue::assertPushed(BroadcastSubscriptionUpdates::class).
File uploads
Storage::fake('public');
$this->post('/graphql', [
'operations' => json_encode([
'query' => 'mutation ($file: Upload!) { uploadAvatar(file: $file) { avatarUrl } }',
'variables' => ['file' => null],
]),
'map' => json_encode(['0' => ['variables.file']]),
'0' => UploadedFile::fake()->image('avatar.png'),
], $this->headersFor($this->member) + ['Content-Type' => 'multipart/form-data'])
->assertJsonMissingPath('errors');Events and logs
Log::spy();
$this->postJson('/graphql', ['query' => 'query Dashboard { me { id } }', 'operationName' => 'Dashboard']);
Log::shouldHaveReceived('warning')->withArgs(fn ($message, $context) => $context['operation'] === 'Dashboard');Test configuration tips
- Response cache: every test starts with an empty cache (use the
arraystore), but within one test, repeating a query may return a cached response. Disable the cache in tests that change data behind the API's back, or count queries. - Production behaviour: GraphiQL and introspection follow
APP_DEBUG. Test the production defaults withconfig(['app.debug' => false]). The GraphiQL route is registered at boot, so test that by booting withAPP_DEBUG=false. - Rate limits use the cache. With the
arraystore they reset between tests. - Schema validity: run
php artisan laragraph:validatein CI. It fails on broken types before any test does.
Testing without HTTP
For unit-style tests of a single operation, call Laragraph::execute() directly:
$this->actingAs($user);
$result = Laragraph::execute('{ me { name } }');
$this->assertSame($user->name, $result['data']['me']['name']);This goes through validation, authorization, the cache and events, but skips routes and middleware.
Snapshotting the schema
Catch accidental breaking changes by committing the SDL and comparing it in CI:
public function test_the_schema_has_not_changed_unexpectedly(): void
{
Artisan::call('laragraph:schema:export');
$this->assertSame(trim(file_get_contents(base_path('schema.graphql'))), trim(Artisan::output()));
}Regenerate the snapshot on purpose with php artisan laragraph:schema:export --output=schema.graphql when the change is intended.
Static analysis
Laragraph ships a PHPStan rule that catches a typo'd type name in Laragraph::type('...') / app('laragraph')->type('...') — the kind of mistake that otherwise only surfaces at request time, on whichever field happens to reference it.
Install: if you have phpstan/extension-installer, nothing to do — it's discovered automatically. Otherwise add it manually to your phpstan.neon:
includes:
- vendor/ayimdomnic/laragraph/phpstan-extension.neonHow it works, and its one blind spot: the rule can't safely bootstrap your application inside PHPStan's process just to ask it which type names are registered, so it builds a best-effort set from static/textual sources instead — config('laragraph.types') (and every per-schema types array), plus every class under your discovery directory (app/GraphQL/Types by default), matched either by its class basename with a trailing Type stripped (UserType → User) or by a NAME constant. A type named only through its runtime ->name property — no NAME constant, no matching basename — is invisible to this and will be flagged as a false positive. The fix is the same either way: add a NAME constant to that type.
A dynamic argument (a variable, a concatenation) is always skipped silently — the rule only ever checks a literal string, so it never guesses.