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.
Table of Contents
- 1. Why building the full schema is unnecessary for resolver tests
- 2. Structuring a resolver so it stays testable in isolation
- 3. Calling the resolve method directly with mocked parameters
- 4. Testing validation errors and GraphQl exceptions deliberately
- 5. Testing customer context and authorization via a mocked ContextInterface
- 6. Running through complex argument structures with data providers
- 7. When a real schema integration test still makes sense
- 8. Typical pitfalls when testing resolvers
- 9. A checklist for testable GraphQL resolvers
- 10. Summary
- 11. FAQ
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