Das Resolver-Interface direkt mit gemockten Context-Objekten aufrufen
Magento-GraphQL-Resolver implementieren ein einfaches, gut definiertes Interface, das sich direkt und ohne den Aufbau des kompletten Schemas per PHPUnit testen laesst, sobald man Feldaufloesung von Datenbeschaffung sauber trennt.
Inhaltsverzeichnis
- 1. Warum der volle Schema-Aufbau fuer Resolver-Tests unnoetig ist
- 2. Einen Resolver so strukturieren, dass er isoliert testbar bleibt
- 3. Die resolve-Methode direkt mit gemockten Parametern aufrufen
- 4. Validierungsfehler und GraphQl-Exceptions gezielt testen
- 5. Kundenkontext und Autorisierung ueber ein gemocktes ContextInterface pruefen
- 6. Komplexe Argument-Strukturen mit Data Providern durchspielen
- 7. Wann ein echter Schema-Integrationstest trotzdem sinnvoll ist
- 8. Typische Fallstricke beim Testen von Resolvern
- 9. Checkliste fuer testbare GraphQL-Resolver
- 10. Zusammenfassung
- 11. FAQ
1. Warum der volle Schema-Aufbau fuer Resolver-Tests unnoetig ist
Ein Magento-GraphQL-Resolver implementiert Magento\Framework\GraphQl\Query\ResolverInterface mit einer einzigen zentralen Methode resolve(), die Feld, Context, ResolveInfo sowie optional value und args entgegennimmt und ein Array oder Value-Objekt zurueckgibt. Wer Resolver testen moechte, koennte versucht sein, eine echte GraphQL-Query gegen den kompletten Schema-Stack zu senden, das Query-Parsing, die Schema-Validierung und die gesamte Resolver-Kette zu durchlaufen und am Ende die JSON-Antwort zu pruefen. Bei einem groesseren Modul mit mehreren Dutzend Resolvern summiert sich dieser Aufwand schnell zu einer spuerbar langsamen Test-Suite.
Das ist fuer echte Integrationstests durchaus sinnvoll, aber fuer die meisten Testfaelle unnoetig aufwendig. Da resolve() eine klar definierte Methodensignatur besitzt, kann sie in PHPUnit direkt aufgerufen werden, ohne dass jemals ein Schema geparst oder eine Query ausgefuehrt werden muss. Die Feldaufloesung selbst wird dadurch zu einem gewoehnlichen Methodenaufruf mit gemockten Parametern, was den Test genauso schnell und stabil macht wie den Test einer beliebigen anderen Service-Klasse im Projekt.
2. Einen Resolver so strukturieren, dass er isoliert testbar bleibt
Ein testfreundlicher Resolver delegiert die eigentliche Datenbeschaffung an einen injizierten Service oder Data Provider und beschraenkt sich selbst auf das Auslesen der Argumente aus dem args-Array, das Aufrufen des Service und das Formatieren des Rueckgabewerts in der von GraphQL erwarteten Struktur. Genau wie bei Controllern und Cronjobs gilt: duenner Resolver, ausgelagerte Fachlogik.
Diese Struktur erlaubt es, den Data Provider unabhaengig zu testen, etwa mit einem klassischen Repository-Mock, waehrend der Resolver selbst nur noch pruefen muss, ob die Argumente korrekt weitergereicht und das Ergebnis korrekt in die GraphQL-Antwortstruktur uebersetzt wird. Fehlerbehandlung, etwa bei fehlenden Pflichtargumenten, gehoert ebenfalls in den Resolver, da sie eng mit der GraphQL-Schnittstelle selbst verknuepft ist.
<?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;
/**
* Liefert eine Weiterleitungsregel anhand des angefragten Pfads.
*/
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(__('Das Argument "path" ist erforderlich.'));
}
$rule = $this->dataProvider->getByPath($args['path']);
return [
'from_path' => $rule->getFromPath(),
'to_path' => $rule->getToPath(),
'redirect_type' => $rule->getRedirectType(),
];
}
}
3. Die resolve-Methode direkt mit gemockten Parametern aufrufen
Im Test wird der Resolver direkt instanziiert und resolve() mit gemockten Field-, Context- und ResolveInfo-Objekten sowie einem manuell erstellten args-Array aufgerufen. Da diese drei Objekte fuer die meisten einfachen Resolver gar nicht inhaltlich ausgewertet werden, reicht es oft, sie als leere Mocks zu uebergeben, ohne konkrete Erwartungen an sie zu stellen.
Der eigentliche Fokus des Tests liegt auf dem args-Array und dem Rueckgabewert: Wird mit dem korrekten Pfad der Data Provider aufgerufen, und wird das zurueckgegebene Objekt korrekt in die von GraphQL erwartete Array-Struktur mit den passenden Feldnamen uebersetzt. Dieser Test laeuft ohne Schema-Parsing und ohne HTTP-Layer in Millisekunden.
<?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. Validierungsfehler und GraphQl-Exceptions gezielt testen
GraphQL-Resolver muessen bei ungueltigen Eingaben spezifische Exception-Klassen aus dem Namespace Magento\Framework\GraphQl\Exception werfen, etwa GraphQlInputException fuer fehlerhafte Argumente oder GraphQlNoSuchEntityException, wenn die angefragte Entitaet nicht existiert. Diese Exceptions werden von der GraphQL-Fehlerbehandlung automatisch in das korrekte errors-Array der GraphQL-Antwort uebersetzt.
Ein Test, der fehlende oder ungueltige Argumente uebergibt, muss also pruefen, dass exakt die richtige Exception-Klasse mit der erwarteten Fehlermeldung geworfen wird. Wird versehentlich eine generische Exception statt einer GraphQl-spezifischen geworfen, fuehrt das in der Praxis zu einer 500er-Antwort statt einer sauberen GraphQL-Fehlermeldung, was Tests genau an dieser Stelle zuverlaessig aufdecken.
<?php
declare(strict_types=1);
public function testResolveThrowsGraphQlInputExceptionWhenPathIsMissing(): void
{
$this->expectException(GraphQlInputException::class);
$this->expectExceptionMessage('Das Argument "path" ist erforderlich.');
$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. Kundenkontext und Autorisierung ueber ein gemocktes ContextInterface pruefen
Viele Resolver liefern unterschiedliche Daten je nachdem, ob der anfragende Nutzer eingeloggt ist oder als Gast agiert, wobei diese Information ueber das zweite Parameterobjekt, typischerweise ein \Magento\GraphQlResolverCache\Model\Resolver\Result\Value oder ein ContextInterface mit Kundendaten, bereitgestellt wird. Fuer den Test wird dieses Objekt gemockt und die getUserId()- oder isCustomer()-Methoden liefern kontrolliert unterschiedliche Werte.
So laesst sich in einem Data-Provider-Test genau abbilden, dass ein eingeloggter Kunde seine eigenen Bestelldaten sieht, waehrend ein Gastnutzer eine GraphQlAuthorizationException erhaelt. Diese Autorisierungslogik gehoert zu den kritischsten Testfaellen ueberhaupt, weil ein Fehler hier direkt zu einem Datenleck zwischen Kundenkonten fuehren kann, weshalb sie besonders sorgfaeltig mit mehreren Kontext-Varianten abgedeckt werden sollte.
<?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. Komplexe Argument-Strukturen mit Data Providern durchspielen
GraphQL-Queries erlauben verschachtelte Filter- und Sortierargumente, die der Resolver in interne Repository-Suchkriterien uebersetzen muss. Diese Uebersetzungslogik ist eine haeufige Fehlerquelle, etwa wenn ein Sortierfeld im GraphQL-Schema anders benannt ist als die Datenbankspalte, auf die es abgebildet werden soll.
Ein PHPUnit-Data-Provider, der mehrere args-Varianten mit erwarteten SearchCriteria-Ergebnissen durchspielt, deckt diese Mapping-Logik systematisch ab. So wird sichergestellt, dass jede unterstuetzte Filter- und Sortierkombination korrekt in die interne Suchlogik uebersetzt wird, bevor ein Kunde ueber eine fehlerhafte Sortierung im Frontend stolpert.
<?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. Wann ein echter Schema-Integrationstest trotzdem sinnvoll ist
Unit-Tests decken die Resolver-Logik ab, pruefen aber nicht, ob der Resolver ueberhaupt korrekt im Schema registriert ist, ob die schema.graphqls-Deklaration mit der tatsaechlichen Rueckgabestruktur uebereinstimmt oder ob mehrere Resolver in einer Query korrekt zusammenspielen. Fuer diese Aspekte bietet Magento GraphQlAbstract als Basisklasse fuer Integrationstests, die eine echte Query gegen den vollen Schema-Stack senden.
In der Praxis reicht es, pro Resolver einen einzigen schlanken Integrationstest zu schreiben, der eine minimale Query gegen das Schema absetzt und nur die Struktur der Antwort grob prueft. Die Feindifferenzierung aller Rand- und Fehlerfaelle bleibt den schnellen Unit-Tests vorbehalten, waehrend der Integrationstest lediglich als Absicherung dient, dass Schema und Resolver-Implementierung nicht auseinanderdriften.
<?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. Typische Fallstricke beim Testen von Resolvern
Eine haeufige Falle ist, args oder value als null anzunehmen, obwohl das Interface beide Parameter als nullable deklariert. Ein Test, der explizit null uebergibt, deckt auf, ob der Resolver mit fehlenden Werten robust umgeht oder ob eine unerwartete TypeError-Exception geworfen wird, die im Produktivbetrieb erst bei einer bestimmten Query-Variante auftaucht, etwa wenn ein Feld ueber ein optionales Elternfeld angefragt wird, das selbst null zurueckgeben kann.
Eine zweite Falle ist, den Rueckgabewert des Resolvers nicht gegen die tatsaechlich im Schema deklarierten Feldnamen zu pruefen, sondern nur grob auf Nicht-Leere. Ein Test, der explizit assertSame mit der kompletten erwarteten Array-Struktur verwendet, faellt sofort auf, wenn sich ein Feldname aendert oder ein Feld versehentlich fehlt, waehrend ein laxer assertNotEmpty solche Regressionen leicht uebersieht. Eine dritte Falle betrifft das Caching: Wird ein Resolver ueber den GraphQL-Response-Cache abgesichert, sollte ein Test zusaetzlich pruefen, dass die Cache-Tags korrekt aus den zurueckgegebenen Entitaeten abgeleitet werden, da fehlerhafte Tags dazu fuehren koennen, dass veraltete Daten laenger als beabsichtigt ausgeliefert werden.
9. Checkliste fuer testbare GraphQL-Resolver
Wer neue Resolver in Magento entwickelt, sollte die Datenbeschaffung konsequent in einen eigenen Data Provider auslagern, praezise GraphQl-Exceptions fuer Fehlerfaelle verwenden und den Resolver ausschliesslich per direktem Methodenaufruf mit gemockten Parametern testen. Ein einzelner schlanker Schema-Integrationstest pro Resolver rundet die Absicherung ab, ohne die Test-Suite mit langsamen Schema-Aufbauten zu ueberladen.
Die folgende Tabelle stellt die Testebenen fuer GraphQL-Resolver in Magento gegenueber und zeigt, welche Ebene welchen Aspekt abdeckt.
| Testebene | Was geprueft wird | Benoetigt Schema-Aufbau | Typische Ausfuehrungszeit |
|---|---|---|---|
| Resolver-Unit-Test | resolve-Logik, Argument-Mapping, Exceptions | Nein | Millisekunden |
| Data-Provider-Unit-Test | Datenbeschaffung unabhaengig vom Resolver | Nein | Millisekunden |
| Autorisierungstest | Kundenkontext, Gast- versus Kunden-Zugriff | Nein | Millisekunden |
| Schema-Integrationstest | Registrierung, Struktur, Zusammenspiel mehrerer Resolver | Ja | Sekunden |
Mironsoft
Testautomatisierung, Magento-Qualitätssicherung und CI-Integration
Tests, die echte Fehler finden statt nur grün zu leuchten?
Wir prüfen bestehende PHPUnit-Suiten auf Implementierungsdetail-Tests, flaky Tests und fehlende Coverage an kritischen Stellen und bauen daraus eine Teststrategie, die bei jedem Magento-Update wirklich Sicherheit gibt.
Test-Audit
Bestehende Suiten auf Mocking-Antipatterns und blinde Flecken prüfen.
Teststrategie
Unit-, Integrations- und MFTF-Tests sinnvoll für Magento-Projekte kombinieren.
CI-Integration
Schnelle, zuverlässige Testläufe in GitLab CI oder GitHub Actions einrichten.
10. Zusammenfassung
GraphQL-Resolver testen: Das Wichtigste auf einen Blick
Direkter Aufruf
resolve() wird in PHPUnit direkt mit gemockten Field-, Context- und ResolveInfo-Objekten aufgerufen
Duenner Resolver
Datenbeschaffung liegt im Data Provider, der Resolver formatiert nur die Antwort
GraphQl-Exceptions
Praezise Exception-Klassen fuer Input-, Autorisierungs- und NotFound-Faelle testen
Schlanke Integration
Ein minimaler Schema-Test pro Resolver reicht, um Registrierung und Struktur abzusichern