Testing Magento GraphQL Resolvers with PHPUnit Without Building the Full Schema
AI generated
@test
assert
PHPUnit · Magento · GraphQL
Testing GraphQL Resolvers Without Building the Full Schema
Calling the resolver interface directly with mocked context objects

Magento GraphQL resolvers implement a simple, well-defined interface that can be tested directly with PHPUnit, without ever building the complete schema, once field resolution and data fetching are cleanly separated.

14 min read GraphQL ResolverInterface Magento Mocking

1. Why building the full schema is unnecessary for resolver tests

A Magento GraphQL resolver implements Magento\Framework\GraphQl\Query\ResolverInterface with a single central method, resolve(), which accepts the field, context, resolve info, and optionally value and args, and returns an array or value object. Anyone wanting to test resolvers might be tempted to send a real GraphQL query against the complete schema stack, running through query parsing, schema validation, and the entire resolver chain before finally checking the JSON response.

That makes sense for real integration tests, but is unnecessarily heavy for most test cases. Since resolve() has a clearly defined method signature, it can be called directly in PHPUnit without ever parsing a schema or executing a query. Field resolution itself thereby becomes an ordinary method call with mocked parameters.

2. Structuring a resolver so it stays testable in isolation

A test-friendly resolver delegates the actual data fetching to an injected service or data provider and limits itself to reading arguments from the args array, calling the service, and formatting the return value in the structure GraphQL expects. Just as with controllers and cron jobs, the rule is: thin resolver, extracted domain logic.

This structure allows the data provider to be tested independently, for instance with a classic repository mock, while the resolver itself only needs to verify that arguments are forwarded correctly and the result is translated correctly into the GraphQL response structure. Error handling, for instance for missing required arguments, also belongs in the resolver, since it is tightly coupled to the GraphQL interface itself.


<?php
declare(strict_types=1);

namespace Mironsoft\SeoSuite\Model\Resolver;

use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Exception\GraphQlInputException;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Mironsoft\SeoSuite\Model\RedirectRuleDataProvider;

/**
 * Returns a redirect rule for the requested path.
 */
class RedirectRuleResolver implements ResolverInterface
{
    public function __construct(
        private readonly RedirectRuleDataProvider $dataProvider
    ) {
    }

    /**
     * @param Field $field
     * @param mixed $context
     * @param ResolveInfo $info
     * @param array|null $value
     * @param array|null $args
     * @return array<string, mixed>
     * @throws GraphQlInputException
     */
    public function resolve(Field $field, $context, ResolveInfo $info, array $value = null, array $args = null): array
    {
        if (empty($args['path'])) {
            throw new GraphQlInputException(__('The "path" argument is required.'));
        }

        $rule = $this->dataProvider->getByPath($args['path']);

        return [
            'from_path' => $rule->getFromPath(),
            'to_path' => $rule->getToPath(),
            'redirect_type' => $rule->getRedirectType(),
        ];
    }
}

3. Calling the resolve method directly with mocked parameters

In the test, the resolver is instantiated directly and resolve() is called with mocked field, context, and resolve info objects plus a manually built args array. Since these three objects are not actually inspected for most simple resolvers, it is often enough to pass them as empty mocks without setting any concrete expectations on them.

The actual focus of the test lies on the args array and the return value: is the data provider called with the correct path, and is the returned object translated correctly into the array structure GraphQL expects with the matching field names. This test runs without schema parsing and without an HTTP layer, in milliseconds.


<?php
declare(strict_types=1);

namespace Mironsoft\SeoSuite\Test\Unit\Model\Resolver;

use Mironsoft\SeoSuite\Model\Resolver\RedirectRuleResolver;
use Mironsoft\SeoSuite\Model\RedirectRuleDataProvider;
use Mironsoft\SeoSuite\Api\Data\RedirectRuleInterface;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use PHPUnit\Framework\TestCase;

class RedirectRuleResolverTest extends TestCase
{
    public function testResolveReturnsFormattedRuleForGivenPath(): void
    {
        $rule = $this->createMock(RedirectRuleInterface::class);
        $rule->method('getFromPath')->willReturn('/old-path');
        $rule->method('getToPath')->willReturn('/new-path');
        $rule->method('getRedirectType')->willReturn(301);

        $dataProvider = $this->createMock(RedirectRuleDataProvider::class);
        $dataProvider->expects($this->once())
            ->method('getByPath')
            ->with('/old-path')
            ->willReturn($rule);

        $resolver = new RedirectRuleResolver($dataProvider);

        $result = $resolver->resolve(
            $this->createMock(Field::class),
            null,
            $this->createMock(ResolveInfo::class),
            null,
            ['path' => '/old-path']
        );

        $this->assertSame([
            'from_path' => '/old-path',
            'to_path' => '/new-path',
            'redirect_type' => 301,
        ], $result);
    }
}

4. Testing validation errors and GraphQl exceptions deliberately

GraphQL resolvers must throw specific exception classes from the Magento\Framework\GraphQl\Exception namespace on invalid input, for instance GraphQlInputException for malformed arguments or GraphQlNoSuchEntityException when the requested entity does not exist. These exceptions are automatically translated by GraphQL error handling into the correct errors array of the GraphQL response.

A test that passes missing or invalid arguments must therefore verify that exactly the right exception class is thrown with the expected error message. If a generic exception is accidentally thrown instead of a GraphQl-specific one, that leads in practice to a 500 response instead of a clean GraphQL error message, which tests reliably catch at exactly this point.


<?php
declare(strict_types=1);

public function testResolveThrowsGraphQlInputExceptionWhenPathIsMissing(): void
{
    $this->expectException(GraphQlInputException::class);
    $this->expectExceptionMessage('The "path" argument is required.');

    $resolver = new RedirectRuleResolver($this->createMock(RedirectRuleDataProvider::class));

    $resolver->resolve(
        $this->createMock(Field::class),
        null,
        $this->createMock(ResolveInfo::class),
        null,
        []
    );
}

public function testResolveThrowsNoSuchEntityExceptionWhenRuleNotFound(): void
{
    $this->expectException(GraphQlNoSuchEntityException::class);

    $dataProvider = $this->createMock(RedirectRuleDataProvider::class);
    $dataProvider->method('getByPath')->willThrowException(new NoSuchEntityException(__('not found')));

    $resolver = new RedirectRuleResolver($dataProvider);
    $resolver->resolve($this->createMock(Field::class), null, $this->createMock(ResolveInfo::class), null, ['path' => '/x']);
}

5. Testing customer context and authorization via a mocked ContextInterface

Many resolvers return different data depending on whether the requesting user is logged in or acting as a guest, with that information provided through the second parameter object, typically a ContextInterface carrying customer data. For the test, this object is mocked and the getUserId() or isCustomer() methods return controlled, different values.

This makes it possible to precisely verify in a data provider test that a logged-in customer sees their own order data, while a guest user receives a GraphQlAuthorizationException. This authorization logic ranks among the most critical test cases of all, because a bug here can directly lead to a data leak between customer accounts, which is why it should be covered especially carefully with multiple context variants.


<?php
declare(strict_types=1);

public function testResolveThrowsAuthorizationExceptionForGuestUser(): void
{
    $this->expectException(GraphQlAuthorizationException::class);

    $context = $this->createMock(ContextInterface::class);
    $context->method('getUserId')->willReturn(0);

    $resolver = new CustomerOrdersResolver($this->createMock(OrderDataProvider::class));
    $resolver->resolve($this->createMock(Field::class), $context, $this->createMock(ResolveInfo::class), null, []);
}

6. Running through complex argument structures with data providers

GraphQL queries allow nested filter and sort arguments that the resolver must translate into internal repository search criteria. This translation logic is a common source of bugs, for instance when a sort field in the GraphQL schema is named differently than the database column it should map to.

A PHPUnit data provider that runs through several args variants with expected search criteria results covers this mapping logic systematically. That ensures every supported filter and sort combination is translated correctly into the internal search logic before a customer stumbles over a broken sort order in the frontend.


<?php
declare(strict_types=1);

/**
 * @dataProvider sortArgsProvider
 */
public function testResolveMapsGraphQlSortFieldToRepositoryField(string $graphQlField, string $expectedRepositoryField): void
{
    $searchCriteriaBuilder = $this->createMock(SearchCriteriaBuilder::class);
    $searchCriteriaBuilder->expects($this->once())
        ->method('addSortOrder')
        ->with($expectedRepositoryField);

    $resolver = new ProductListResolver($searchCriteriaBuilder, $this->createMock(ProductRepositoryInterface::class));
    $resolver->resolve(
        $this->createMock(Field::class), null, $this->createMock(ResolveInfo::class), null,
        ['sort' => ['field' => $graphQlField, 'direction' => 'ASC']]
    );
}

public static function sortArgsProvider(): array
{
    return [
        'name maps to name column' => ['NAME', 'name'],
        'price maps to price column' => ['PRICE', 'price'],
    ];
}

7. When a real schema integration test still makes sense

Unit tests cover the resolver logic but do not check whether the resolver is actually registered correctly in the schema, whether the schema.graphqls declaration matches the actual return structure, or whether several resolvers work together correctly within one query. For these aspects, Magento provides GraphQlAbstract as a base class for integration tests that send a real query against the full schema stack.

In practice, it's enough to write a single lean integration test per resolver that fires a minimal query against the schema and only roughly checks the structure of the response. Fine-grained coverage of all edge and error cases is left to the fast unit tests, while the integration test merely serves as a safeguard against the schema and the resolver implementation drifting apart.


<?php
declare(strict_types=1);

namespace Mironsoft\SeoSuite\Test\Integration\GraphQl;

use Magento\TestFramework\TestCase\GraphQlAbstract;

class RedirectRuleResolverTest extends GraphQlAbstract
{
    public function testSchemaAndResolverAreWired(): void
    {
        $query = <<<QUERY
        {
            redirectRule(path: "/old-path") {
                from_path
                to_path
                redirect_type
            }
        }
        QUERY;

        $response = $this->graphQlQuery($query);
        $this->assertArrayHasKey('redirectRule', $response);
    }
}

8. Typical pitfalls when testing resolvers

A common pitfall is assuming args or value are never null, even though the interface declares both parameters as nullable. A test that explicitly passes null uncovers whether the resolver handles missing values robustly or whether an unexpected TypeError is thrown, which in production would only surface with a particular query variant.

A second pitfall is checking the resolver's return value only for rough non-emptiness instead of against the actual field names declared in the schema. A test that explicitly uses assertSame with the complete expected array structure immediately catches when a field name changes or a field is accidentally missing, while a lax assertNotEmpty easily overlooks such regressions.

9. A checklist for testable GraphQL resolvers

Anyone developing new resolvers in Magento should consistently extract data fetching into its own data provider, use precise GraphQl exceptions for error cases, and test the resolver exclusively through direct method calls with mocked parameters. A single lean schema integration test per resolver rounds off the coverage without overloading the test suite with slow schema builds.

The table below contrasts the test levels for GraphQL resolvers in Magento and shows which level covers which aspect.

Test level What is checked Requires schema build Typical execution time
Resolver unit test resolve logic, argument mapping, exceptions No Milliseconds
Data provider unit test Data fetching independent of the resolver No Milliseconds
Authorization test Customer context, guest versus customer access No Milliseconds
Schema integration test Registration, structure, interplay of multiple resolvers Yes Seconds

Mironsoft

Test automation, Magento quality assurance, and CI integration

Tests that catch real bugs instead of just turning green?

We review existing PHPUnit suites for implementation-detail tests, flaky tests, and missing coverage at critical points, then build a test strategy that provides real confidence with every Magento update.

Test Audit

Reviewing existing suites for mocking antipatterns and blind spots.

Test Strategy

Meaningfully combining unit, integration, and MFTF tests for Magento projects.

CI Integration

Setting up fast, reliable test runs in GitLab CI or GitHub Actions.

10. Summary

Testing GraphQL Resolvers: Key Takeaways

Direct call

resolve() is called directly in PHPUnit with mocked field, context, and resolve info objects

Thin resolver

Data fetching lives in the data provider, the resolver only formats the response

GraphQl exceptions

Test precise exception classes for input, authorization, and not-found cases

Lean integration

A single minimal schema test per resolver is enough to safeguard registration and structure

11. FAQ: Testing GraphQL Resolvers: Key Takeaways

1Do I need to execute a GraphQL query for every resolver test?
No, the resolve method can be called directly with mocked field, context, and resolve info objects plus a manually built args array, without ever parsing a query.
2How do I test missing required arguments in a resolver?
Call resolve with an args array missing the relevant field and verify that the matching GraphQlInputException is thrown with the expected error message.
3How do I distinguish guest from customer access in tests?
The context object is mocked, with getUserId or a similar method returning controlled, different values for guests and logged-in customers.
4Do I need a dedicated data provider for resolver tests?
It's advisable to extract data fetching into a separate data provider so the resolver test can focus on argument mapping and response formatting.
5How do I verify the resolver returns the correct GraphQL response structure?
With assertSame against the complete expected array, including all field names, instead of only roughly checking for non-emptiness.
6When do I still need a real schema integration test?
A single lean integration test per resolver is enough to safeguard that the resolver is registered correctly in the schema and that schema.graphqls matches the actual implementation.
7How do I test nested filter and sort arguments?
With a data provider that runs through several args variants and checks in each case whether the expected internal search criterion is generated correctly.
8What happens if I throw a generic exception instead of a GraphQl exception?
That leads in practice to a 500 response instead of a clean GraphQL error message in the errors array, which is why tests should explicitly check for the correct GraphQl exception class.
9Can I just mock the ResolveInfo object as empty in most tests?
Yes, for most simple resolvers ResolveInfo is not actually inspected, so an empty mock without concrete expectations is enough.
10How do I make sure authorization logic doesn't accidentally leak data between customers?
With targeted tests that, for various context variants such as guest, own customer, and another customer, precisely check which data is returned or which exception is thrown.