GraphQL-Resolver in Magento per Unit-Test ohne vollen Schema-Aufbau pruefen
AI generated
@test
assert
PHPUnit · Magento · GraphQL
GraphQL-Resolver ohne vollen Schema-Aufbau testen
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.

14 Min. Lesezeit GraphQL ResolverInterface Magento Mocking

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

11. FAQ: GraphQL-Resolver testen: Das Wichtigste auf einen Blick

1Muss ich fuer jeden Resolver-Test eine GraphQL-Query ausfuehren?
Nein, die resolve-Methode kann direkt mit gemockten Field-, Context- und ResolveInfo-Objekten sowie einem manuell erstellten args-Array aufgerufen werden, ohne jemals eine Query zu parsen.
2Wie teste ich fehlende Pflichtargumente in einem Resolver?
Man ruft resolve mit einem args-Array ohne das betreffende Feld auf und prueft, dass die passende GraphQlInputException mit der erwarteten Fehlermeldung geworfen wird.
3Wie unterscheide ich Gast- von Kundenzugriff in Tests?
Das Context-Objekt wird gemockt, wobei getUserId oder eine aehnliche Methode kontrolliert unterschiedliche Werte fuer Gast und eingeloggten Kunden liefert.
4Brauche ich fuer Resolver-Tests einen eigenen Data Provider?
Es ist empfehlenswert, die Datenbeschaffung in einen separaten Data Provider auszulagern, damit der Resolver-Test sich auf Argument-Mapping und Antwortformatierung konzentrieren kann.
5Wie pruefe ich, dass der Resolver die korrekte GraphQL-Antwortstruktur liefert?
Mit assertSame gegen das komplette erwartete Array, inklusive aller Feldnamen, statt nur grob auf Nicht-Leere zu pruefen.
6Wann brauche ich trotzdem einen echten Schema-Integrationstest?
Fuer die Absicherung, dass der Resolver korrekt im Schema registriert ist und schema.graphqls mit der tatsaechlichen Implementierung uebereinstimmt, reicht ein einzelner schlanker Integrationstest pro Resolver.
7Wie teste ich verschachtelte Filter- und Sortierargumente?
Mit einem Data Provider, der mehrere args-Varianten durchspielt und jeweils prueft, ob das erwartete interne Suchkriterium korrekt erzeugt wird.
8Was passiert, wenn ich eine generische Exception statt einer GraphQl-Exception werfe?
Das fuehrt in der Praxis zu einer 500er-Antwort statt einer sauberen GraphQL-Fehlermeldung im errors-Array, weshalb Tests explizit auf die korrekte GraphQl-Exception-Klasse pruefen sollten.
9Kann ich das ResolveInfo-Objekt in den meisten Tests einfach leer mocken?
Ja, fuer die meisten einfachen Resolver wird ResolveInfo inhaltlich nicht ausgewertet, sodass ein leerer Mock ohne konkrete Erwartungen ausreicht.
10Wie stelle ich sicher, dass Autorisierungslogik nicht versehentlich Daten zwischen Kunden preisgibt?
Mit gezielten Tests, die fuer verschiedene Kontext-Varianten, etwa Gast, eigener Kunde und fremder Kunde, exakt pruefen, welche Daten zurueckgegeben werden oder welche Exception geworfen wird.