@test
assert
PHPUnit · GraphQL · REST · Magento 2 · Integration Tests
GraphQL und REST Endpoints
in Magento 2 mit PHPUnit testen

Magento 2 exponiert komplexe Geschäftslogik über REST und GraphQL APIs. Wer Custom-Endpoints, Schema-Erweiterungen oder API-Policies implementiert, braucht verlässliche Integrationstests – kein manuelles Postman-Testen, kein "läuft auf Staging, hoffe auf das Beste".

18 Min. Lesezeit GraphQL · REST · Fixtures · Auth · Response-Validierung Magento 2.4.8 · PHPUnit 10 · PHP 8.4

1. API-Tests in Magento 2: Wo sie in der Testpyramide stehen

API-Tests für Magento-REST- und GraphQL-Endpoints sind Integrationstests – sie laufen gegen eine vollständig initialisierte Magento-Instanz mit echter Datenbank. Das macht sie langsamer als Unit-Tests, aber sie prüfen etwas qualitativ anderes: das Zusammenspiel von Routing, ACL, Repository, Business-Logik und Serialisierung in einer einzigen Anfrage. Ein Unit-Test für den Resolver allein reicht nicht aus, wenn der Fehler im Routing-Framework oder der ACL-Konfiguration liegt.

Magento 2 liefert für beide API-Stile eigene Test-Basisklassen mit. Für REST: Magento\TestFramework\TestCase\AbstractController, das HTTP-Requests gegen den internen Router schickt ohne echten Netzwerk-Overhead. Für GraphQL: Magento\TestFramework\TestCase\GraphQlAbstract, das GraphQL-Queries intern ausführt und die Response als Array zurückgibt. Beide Basisklassen integrieren das Fixture-System, Transaktions-Rollbacks und die Magento-Konfigurationsumgebung automatisch.

Die wichtigste Entscheidung vor dem Aufbau von API-Tests ist die Abgrenzung: Was soll getestet werden – die eigene Business-Logik, das korrekte Schema, das Berechtigungsmodell oder alle drei? Je nach Antwort werden unterschiedliche Tests geschrieben. Wer versucht, alles in einem einzigen Test abzudecken, erzeugt überladene Tests, die bei der ersten Änderung aus verschiedenen Gründen fehlschlagen und schwer zu debuggen sind.

2. REST-API-Tests mit AbstractController

Der Magento-AbstractController stellt die Methode $this->getRequest() und $this->dispatch() bereit. Mit dispatch('/rest/V1/products/TEST-001') schickt der Test einen GET-Request an den internen Magento-Router, ohne TCP-Verbindung aufzubauen. Die Response ist über $this->getResponse() zugänglich, der HTTP-Status über getStatusCode(), der Body als JSON-String über getBody().

Für POST-, PUT- und DELETE-Requests setzt man den Request-Body explizit: $this->getRequest()->setMethod('POST')->setContent(json_encode($payload)). Das Content-Type-Header wird für JSON-APIs auf application/json gesetzt. Der Authorization-Header trägt das Bearer-Token des Test-Benutzers. Diese drei Zeilen sind das vollständige Setup für jeden REST-API-Test – der Rest ist fachliche Assertion über die Response.


<?php
declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Integration\Api;

use Magento\TestFramework\Fixture\DataFixture;
use Magento\Catalog\Test\Fixture\Product as ProductFixture;
use Magento\TestFramework\Fixture\DataFixtureStorageManager;
use PHPUnit\Framework\Attributes\Test;
use Magento\TestFramework\Helper\Bootstrap;

/**
 * REST API integration tests for the custom product enrichment endpoint.
 * Tests the full stack: routing, ACL, service layer, serialization.
 */
#[DataFixture(ProductFixture::class, ['sku' => 'REST-TEST-001', 'price' => 100.0, 'status' => 1], 'product')]
final class ProductEnrichmentRestTest extends \Magento\TestFramework\TestCase\AbstractController
{
    private string $adminToken = '';

    protected function setUp(): void
    {
        parent::setUp();
        $this->adminToken = $this->getAdminToken();
    }

    #[Test]
    public function returnsEnrichedProductDataForValidSku(): void
    {
        $product = DataFixtureStorageManager::getStorage()->get('product');

        $this->getRequest()
             ->setMethod('GET')
             ->setHeader('Authorization', 'Bearer ' . $this->adminToken)
             ->setHeader('Content-Type', 'application/json');

        $this->dispatch('/rest/V1/mironsoft/products/' . $product->getSku() . '/enriched');

        $this->assertSame(200, $this->getResponse()->getStatusCode());

        $body = json_decode($this->getResponse()->getBody(), true, 512, JSON_THROW_ON_ERROR);

        $this->assertArrayHasKey('sku', $body);
        $this->assertArrayHasKey('gross_price', $body);
        $this->assertArrayHasKey('tax_rate', $body);
        $this->assertSame('REST-TEST-001', $body['sku']);
        $this->assertIsFloat($body['gross_price']);
    }

    #[Test]
    public function returns404ForNonExistentSku(): void
    {
        $this->getRequest()
             ->setMethod('GET')
             ->setHeader('Authorization', 'Bearer ' . $this->adminToken)
             ->setHeader('Content-Type', 'application/json');

        $this->dispatch('/rest/V1/mironsoft/products/DOES-NOT-EXIST/enriched');

        $this->assertSame(404, $this->getResponse()->getStatusCode());
    }

    private function getAdminToken(): string
    {
        /** @var \Magento\Integration\Api\AdminTokenServiceInterface $tokenService */
        $tokenService = Bootstrap::getObjectManager()->get(\Magento\Integration\Api\AdminTokenServiceInterface::class);
        return $tokenService->createAdminAccessToken('admin', \Magento\TestFramework\Bootstrap::ADMIN_PASSWORD);
    }
}

3. Authentifizierung in REST-Tests: Admin und Customer Token

REST-API-Tests in Magento 2 müssen die Authentifizierung korrekt simulieren. Für Admin-geschützte Endpoints verwendet man den Admin-Token-Service, der einen JWT für den Test-Admin-Benutzer generiert. Für Customer-geschützte Endpoints benötigt man einen Customer-Account in der Testdatenbank – entweder als Fixture oder als Bootstrap-Benutzer – und den entsprechenden Customer-Token.

Ein häufiger Fehler: Der Test sendet keinen Authorization-Header und wundert sich über ein 401-Response. Das ist aber kein Fehler des API-Endpoints, sondern fehlendes Test-Setup. Ein zweiter häufiger Fehler: Der Test verwendet den Admin-Token für Customer-Endpoints oder umgekehrt. Das führt zu 403-Responses, die zu einem Fehlschluss über die Funktionalität des Endpoints verleiten. Klare Trennung: Admin-Tests verwenden Admin-Token, Customer-Tests verwenden Customer-Token, öffentliche Endpoints werden ohne Token getestet.


<?php
declare(strict_types=1);

namespace Mironsoft\Customer\Test\Integration\Api;

use Magento\Customer\Test\Fixture\Customer as CustomerFixture;
use Magento\TestFramework\Fixture\DataFixture;
use PHPUnit\Framework\Attributes\Test;
use Magento\TestFramework\Helper\Bootstrap;

/**
 * REST API tests for customer-scoped endpoints.
 * Uses customer authentication token, not admin token.
 */
#[DataFixture(CustomerFixture::class, ['email' => 'test-api@example.com', 'password' => 'Test@12345!'], 'customer')]
final class CustomerOrderHistoryRestTest extends \Magento\TestFramework\TestCase\AbstractController
{
    private string $customerToken = '';

    protected function setUp(): void
    {
        parent::setUp();
        $this->customerToken = $this->getCustomerToken('test-api@example.com', 'Test@12345!');
    }

    #[Test]
    public function returnsOrderHistoryForAuthenticatedCustomer(): void
    {
        $this->getRequest()
             ->setMethod('GET')
             ->setHeader('Authorization', 'Bearer ' . $this->customerToken)
             ->setHeader('Content-Type', 'application/json');

        $this->dispatch('/rest/V1/orders/mine');

        // New customer has no orders — expect empty array, not 401/403
        $this->assertSame(200, $this->getResponse()->getStatusCode());
        $body = json_decode($this->getResponse()->getBody(), true, 512, JSON_THROW_ON_ERROR);
        $this->assertArrayHasKey('items', $body);
        $this->assertIsArray($body['items']);
    }

    #[Test]
    public function returns401WithoutToken(): void
    {
        $this->getRequest()->setMethod('GET');
        $this->dispatch('/rest/V1/orders/mine');
        $this->assertSame(401, $this->getResponse()->getStatusCode());
    }

    private function getCustomerToken(string $email, string $password): string
    {
        /** @var \Magento\Integration\Api\CustomerTokenServiceInterface $service */
        $service = Bootstrap::getObjectManager()->get(\Magento\Integration\Api\CustomerTokenServiceInterface::class);
        return $service->createCustomerAccessToken($email, $password);
    }
}

4. GraphQL-Tests: GraphQlAbstract und Query-Struktur

Magento-GraphQL-Tests erben von Magento\TestFramework\TestCase\GraphQlAbstract. Die zentrale Methode ist $this->graphQlQuery(string $query, array $variables = [], string $operationName = '', array $headers = []). Sie führt die GraphQL-Query intern aus und gibt die Response als PHP-Array zurück – ohne Netzwerk-Roundtrip. Fehler werden als Exception geworfen, sodass man für Fehlerfälle expectException verwenden kann.

Ein GraphQL-Test besteht aus drei Teilen: dem Query-String, optional Variablen und der Assertion über die Response. Der Query-String sollte in einer Konstante oder einer privaten Methode der Testklasse definiert sein, nicht inline im Test. Das verbessert die Lesbarkeit und erlaubt die Wiederverwendung desselben Queries in mehreren Tests mit verschiedenen Fixtures oder Variablen.


<?php
declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Integration\GraphQl;

use Magento\TestFramework\Fixture\DataFixture;
use Magento\Catalog\Test\Fixture\Product as ProductFixture;
use Magento\TestFramework\Fixture\DataFixtureStorageManager;
use PHPUnit\Framework\Attributes\Test;

/**
 * GraphQL integration tests for product queries with custom field extensions.
 * Tests Mironsoft schema extension: product.miron_enriched_data { gross_price, tax_rate }
 */
#[DataFixture(ProductFixture::class, [
    'sku'    => 'GQL-TEST-001',
    'name'   => 'GraphQL Test Product',
    'price'  => 100.0,
    'status' => 1,
], 'product')]
final class ProductEnrichmentGraphQlTest extends \Magento\TestFramework\TestCase\GraphQlAbstract
{
    private const PRODUCT_QUERY = <<<'GQL'
    query GetEnrichedProduct($sku: String!) {
        products(filter: { sku: { eq: $sku } }) {
            items {
                sku
                name
                price_range {
                    minimum_price {
                        regular_price { value currency }
                    }
                }
                miron_enriched_data {
                    gross_price
                    tax_rate
                    availability_status
                }
            }
        }
    }
    GQL;

    #[Test]
    public function returnsEnrichedDataForExistingProduct(): void
    {
        $product  = DataFixtureStorageManager::getStorage()->get('product');
        $response = $this->graphQlQuery(self::PRODUCT_QUERY, ['sku' => $product->getSku()]);

        $this->assertArrayHasKey('products', $response);
        $items = $response['products']['items'];
        $this->assertCount(1, $items);

        $item = $items[0];
        $this->assertSame('GQL-TEST-001', $item['sku']);
        $this->assertArrayHasKey('miron_enriched_data', $item);

        $enriched = $item['miron_enriched_data'];
        $this->assertGreaterThan(0.0, $enriched['gross_price']);
        $this->assertGreaterThan(0.0, $enriched['tax_rate']);
        $this->assertContains($enriched['availability_status'], ['IN_STOCK', 'OUT_OF_STOCK', 'BACKORDER']);
    }

    #[Test]
    public function returnsEmptyItemsForNonExistentSku(): void
    {
        $response = $this->graphQlQuery(self::PRODUCT_QUERY, ['sku' => 'DOES-NOT-EXIST']);

        $this->assertArrayHasKey('products', $response);
        $this->assertCount(0, $response['products']['items']);
    }
}

5. GraphQL-Schema-Erweiterungen testen

Magento 2 erlaubt es Modulen, das GraphQL-Schema durch schema.graphqls-Dateien zu erweitern. Custom-Felder, neue Query-Types und Mutations werden so hinzugefügt. Diese Schema-Erweiterungen müssen getestet werden – nicht nur die Resolver-Logik, sondern auch die korrekte Schema-Registrierung, die Typsicherheit der Rückgabewerte und die Abwärtskompatibilität bei Änderungen.

Ein pragmatischer Ansatz: Ein Test sendet eine Introspection-Query gegen das Schema und prüft, ob die erwarteten Felder vorhanden sind. Das ist kein Ersatz für Resolver-Tests, aber eine schnelle Prüfung der Schema-Vollständigkeit. Eine separate Test-Suite für Schema-Introspection kann automatisch sicherstellen, dass keine Schema-Felder versehentlich entfernt werden – ein häufiger Fehler beim Upgrade von Modulen.

6. Fehlerfälle und Error-Responses testen

API-Tests für Fehlerfälle sind mindestens genauso wichtig wie die Happy-Path-Tests. Ein Magento-REST-Endpoint, der bei ungültiger Eingabe einen 500-Fehler statt einem validierten 400-Fehler zurückgibt, ist ein Bug – auch wenn der Normalfall korrekt funktioniert. GraphQL-Endpoints, die bei Berechtigungsfehlern einen 200-Response mit leeren Daten statt einem Fehler zurückgeben, verstoßen gegen das GraphQL-Protokoll.

Für GraphQL-Fehlerfälle wirft graphQlQuery() eine ResponseContainsErrorsException, wenn die Response ein errors-Array enthält. Man kann mit expectException auf diese Exception testen und mit expectExceptionMessage auf den spezifischen Fehlertext. Alternativ verwendet man $this->graphQlMutation() für Mutations-spezifische Tests mit erwartetem Fehler.


<?php
declare(strict_types=1);

namespace Mironsoft\Catalog\Test\Integration\GraphQl;

use Magento\Framework\Exception\AuthorizationException;
use Magento\TestFramework\Fixture\DataFixture;
use Magento\Catalog\Test\Fixture\Product as ProductFixture;
use PHPUnit\Framework\Attributes\Test;

/**
 * Error case tests for GraphQL endpoints.
 * Validates that invalid input, missing auth, and not-found cases
 * produce correct error responses, not silent failures.
 */
#[DataFixture(ProductFixture::class, ['sku' => 'ERR-TEST-001', 'status' => 1], 'product')]
final class ProductGraphQlErrorTest extends \Magento\TestFramework\TestCase\GraphQlAbstract
{
    private const ADMIN_MUTATION = <<<'GQL'
    mutation UpdateProductStock($sku: String!, $qty: Float!) {
        mironsoftUpdateStock(sku: $sku, qty: $qty) {
            success
            message
        }
    }
    GQL;

    #[Test]
    public function mutationRequiresAdminAuthentication(): void
    {
        $this->expectException(\Magento\TestFramework\TestCase\GraphQlResponseContainsErrorsException::class);
        $this->expectExceptionMessageMatches('/authorization|unauthenticated/i');

        // No auth header — should fail with authorization error
        $this->graphQlMutation(self::ADMIN_MUTATION, ['sku' => 'ERR-TEST-001', 'qty' => 5.0]);
    }

    #[Test]
    public function queryReturnsValidationErrorForNegativePrice(): void
    {
        $this->expectException(\Magento\TestFramework\TestCase\GraphQlResponseContainsErrorsException::class);

        $query = <<<'GQL'
        query {
            products(filter: { price: { lt: "-1" } }) {
                items { sku }
            }
        }
        GQL;

        $this->graphQlQuery($query);
    }

    #[Test]
    public function restEndpointReturns400ForInvalidPayload(): void
    {
        $adminToken = $this->getAdminToken();

        $this->getRequest()
             ->setMethod('POST')
             ->setHeader('Authorization', 'Bearer ' . $adminToken)
             ->setHeader('Content-Type', 'application/json')
             ->setContent('{"invalid": "payload missing required fields"}');

        $this->dispatch('/rest/V1/mironsoft/products/stock');

        $statusCode = $this->getResponse()->getStatusCode();
        $this->assertContains($statusCode, [400, 422], 'Expected client error for invalid payload');

        $body = json_decode($this->getResponse()->getBody(), true, 512, JSON_THROW_ON_ERROR);
        $this->assertArrayHasKey('message', $body);
    }

    private function getAdminToken(): string
    {
        /** @var \Magento\Integration\Api\AdminTokenServiceInterface $service */
        $service = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()
            ->get(\Magento\Integration\Api\AdminTokenServiceInterface::class);
        return $service->createAdminAccessToken('admin', \Magento\TestFramework\Bootstrap::ADMIN_PASSWORD);
    }
}

7. Fixtures für API-Tests: Daten sauber aufbauen

API-Tests ohne saubere Fixture-Strategie werden schnell zerbrechlich. Wenn ein REST-Test davon abhängt, dass in der Datenbank ein bestimmtes Produkt mit einer bestimmten SKU existiert, und dieser Zustand nicht explizit durch ein Fixture hergestellt wird, ist der Test von der Ausführungsreihenfolge anderer Tests abhängig – ein klassischer Flakiness-Auslöser.

Für API-Tests empfiehlt sich die Kombination aus #[DataFixture]-Attributen für Datenbankzustand und DataFixtureStorageManager::getStorage()->get('alias') für den Zugriff auf die erstellten Entitäten im Test. Dieser Ansatz ist deklarativ, zeigt am Anfang der Testklasse alle benötigten Daten und stellt durch den automatischen Rollback nach dem Test sicher, dass kein Zustand in andere Tests leckt. Für komplexe Fixture-Szenarien mit abhängigen Entities (Kategorie → Produkt → Preisregel) nutzt man die Referenzsyntax $alias.id$ in den Fixture-Parametern.

8. REST vs. GraphQL Tests im Vergleich

REST- und GraphQL-Tests unterscheiden sich in ihrer Struktur und in den typischen Herausforderungen. Das Verständnis dieser Unterschiede hilft beim Aufbau einer effizienten API-Test-Suite für Magento 2.

Aspekt REST-Tests GraphQL-Tests Empfehlung
Basisklasse AbstractController GraphQlAbstract Je nach API-Typ
Response-Format JSON-String, manuell dekodieren PHP-Array direkt GraphQL einfacher
Fehlerfälle HTTP-Status-Code prüfen Exception abfangen Beide explizit testen
Auth-Handling Header manuell setzen Headers-Array übergeben Hilfsmethode kapseln
Schema-Prüfung Manuell über Response-Struktur Introspection-Query möglich GraphQL-Vorteil nutzen

Ein praktischer Tipp: Die Admin-Token-Generierung und die Customer-Token-Generierung sollten in einer Basis-Testklasse oder einem Trait gekapselt sein, die alle API-Tests im Modul verwenden. Duplizierter Token-Generierungscode ist in gewachsenen Testsuiten häufig und führt zu Maintenance-Aufwand, wenn sich der Testbenutzer ändert. Eine gemeinsame ApiTestCase-Klasse mit Hilfsmethoden wie getAdminToken(), getCustomerToken() und graphQlQueryAsAdmin() macht Tests kürzer und fokussierter auf die fachliche Aussage.

9. Zusammenfassung

REST- und GraphQL-Integrationstests in Magento 2 sind mit dem Magento-Testrahmen strukturiert und wartbar aufzubauen. AbstractController für REST, GraphQlAbstract für GraphQL – beide Basisklassen stellen das vollständige Magento-Framework für interne Requests bereit, ohne Netzwerk-Overhead. Fixtures stellen reproduzierbaren Datenbankzustand her, Transaktions-Rollback sorgt für Isolation zwischen Tests.

Die wichtigsten Testfälle für jeden API-Endpoint: Happy Path mit validen Daten und korrekter Auth, Fehlerfall mit ungültiger Eingabe (400/422), Authentifizierungsfehler ohne Token (401), Berechtigungsfehler mit falschem Token (403) und Not-Found-Fall (404). Wer alle fünf Kategorien abdeckt, hat eine vollständige API-Test-Suite, die Regressionssicherheit für alle wesentlichen Szenarien bietet. Schema-Introspection für GraphQL-Erweiterungen schützt zusätzlich vor versehentlichen Breaking Changes im Schema.

Magento 2 API-Tests — Das Wichtigste auf einen Blick

REST-Tests

AbstractController: dispatch() schickt interne Requests ohne TCP. Status-Code und JSON-Body explizit validieren.

GraphQL-Tests

GraphQlAbstract: graphQlQuery() gibt PHP-Array zurück. Fehlerfälle werfen ResponseContainsErrorsException.

Authentifizierung

Admin-Token für Admin-Endpoints, Customer-Token für Customer-Endpoints, kein Token für öffentliche Endpoints. Hilfsmethoden kapseln.

Fehlerfälle testen

Happy Path, 400 (invalid input), 401 (kein Token), 403 (falsche Berechtigung), 404 (not found). Alle fünf Kategorien für vollständige Abdeckung.

10. FAQ: GraphQL und REST Endpoints in Magento mit PHPUnit testen

1Basisklasse für REST-API-Tests?
AbstractController: dispatch() für interne Requests ohne TCP, getResponse() für Status und Body.
2Basisklasse für GraphQL-Tests?
GraphQlAbstract: graphQlQuery() gibt PHP-Array zurück. Fehlerfälle werfen ResponseContainsErrorsException.
3Authentifizierte REST-Endpoints testen?
Admin: AdminTokenServiceInterface. Customer: CustomerTokenServiceInterface. Token als Bearer-Header setzen.
4GraphQL-Fehlerfälle testen?
expectException(ResponseContainsErrorsException::class) + expectExceptionMessageMatches() für spezifische Fehlermeldungen.
5Reproduzierbarer DB-Zustand für API-Tests?
#[DataFixture]-Attribut mit automatischem Rollback. DataFixtureStorageManager::getStorage()->get('alias') für Zugriff auf erstellte Entitäten.
6GraphQL-Schema-Erweiterungen testen?
Introspection-Queries prüfen, ob Felder und Typen registriert sind. Direkte Resolver-Tests validieren Response-Struktur und Werte.
7GraphQL-Mutations testen?
graphQlMutation() statt graphQlQuery() – identische Signatur, semantisch korrekt für Mutations. Fehlerfälle identisch behandeln.
8Token-Generierung kapseln?
Gemeinsame ApiTestCase-Klasse oder Trait mit getAdminToken(), getCustomerToken(). Alle API-Tests erben oder verwenden den Trait.
9Fünf Pflicht-Testfälle je Endpoint?
Happy Path, ungültige Eingabe (400), kein Token (401), fehlende Berechtigung (403), nicht gefunden (404). Alle fünf für vollständige Abdeckung.
10Geteilten DB-Zustand zwischen API-Tests vermeiden?
Magento-Integrationstests: automatischer Transaktions-Rollback nach jedem Test. Explizite Fixtures pro Test – kein Abhängigkeit von Daten anderer Tests.