Testing GraphQL and REST Endpoints in Magento with PHPUnit
AI generated
@test
assert
PHPUnit · GraphQL · REST · Magento 2 · Integration Tests
GraphQL and REST Endpoints
in Magento 2 with PHPUnit

Magento 2 exposes complex business logic through REST and GraphQL APIs. Anyone implementing custom endpoints, schema extensions, or API policies needs reliable integration tests, not manual Postman clicking, not "works on staging, fingers crossed".

18 min read GraphQL · REST · Fixtures · Auth · Response Validation Magento 2.4.8 · PHPUnit 10 · PHP 8.4

1. API Tests in Magento 2: Where They Sit in the Test Pyramid

API tests for Magento REST and GraphQL endpoints are integration tests, they run against a fully initialized Magento instance with a real database. That makes them slower than unit tests, but they verify something qualitatively different: the interplay of routing, ACL, repository, business logic, and serialization within a single request. A unit test for the resolver alone is not enough when the bug lives in the routing framework or the ACL configuration.

Magento 2 ships its own test base classes for both API styles. For REST: Magento\TestFramework\TestCase\AbstractController, which sends HTTP requests against the internal router without real network overhead. For GraphQL: Magento\TestFramework\TestCase\GraphQlAbstract, which executes GraphQL queries internally and returns the response as an array. Both base classes automatically integrate the fixture system, transaction rollbacks, and the Magento configuration environment.

The most important decision before building API tests is scoping: what should be tested, your own business logic, the correct schema, the permission model, or all three? Different answers lead to different tests. Anyone trying to cover everything in a single test ends up with bloated tests that fail for various reasons on the first change and are hard to debug.

2. REST API Tests with AbstractController

Magento's AbstractController provides the methods $this->getRequest() and $this->dispatch(). With dispatch('/rest/V1/products/TEST-001'), the test sends a GET request to the internal Magento router without establishing a TCP connection. The response is accessible via $this->getResponse(), the HTTP status via getStatusCode(), and the body as a JSON string via getBody().

For POST, PUT, and DELETE requests you set the request body explicitly: $this->getRequest()->setMethod('POST')->setContent(json_encode($payload)). The Content-Type header is set to application/json for JSON APIs. The Authorization header carries the bearer token of the test user. These three lines are the complete setup for every REST API test, the rest is domain-specific assertion on the 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. Authentication in REST Tests: Admin and Customer Tokens

REST API tests in Magento 2 must correctly simulate authentication. For admin-protected endpoints, use the admin token service, which generates a JWT for the test admin user. For customer-protected endpoints, you need a customer account in the test database, either as a fixture or as a bootstrap user, plus the corresponding customer token.

A common mistake: the test sends no Authorization header and is then surprised by a 401 response. That is not a bug in the API endpoint but missing test setup. A second common mistake: the test uses the admin token for customer endpoints or vice versa. This produces 403 responses that lead to a false conclusion about the endpoint's actual behavior. Keep a clear separation: admin tests use the admin token, customer tests use the customer token, public endpoints are tested without a token.


<?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 and Query Structure

Magento GraphQL tests extend Magento\TestFramework\TestCase\GraphQlAbstract. The central method is $this->graphQlQuery(string $query, array $variables = [], string $operationName = '', array $headers = []). It executes the GraphQL query internally and returns the response as a PHP array, without a network round trip. Errors are thrown as an exception, so you can use expectException for error cases.

A GraphQL test consists of three parts: the query string, optionally variables, and the assertion on the response. The query string should be defined in a constant or a private method of the test class, not inline in the test. This improves readability and allows reusing the same query in multiple tests with different fixtures or variables.


<?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. Testing GraphQL Schema Extensions

Magento 2 lets modules extend the GraphQL schema via schema.graphqls files. Custom fields, new query types, and mutations get added this way. These schema extensions need to be tested, not just the resolver logic, but also correct schema registration, type safety of return values, and backward compatibility across changes.

A pragmatic approach: a test sends an introspection query against the schema and checks whether the expected fields are present. That is not a substitute for resolver tests, but a fast check of schema completeness. A separate test suite for schema introspection can automatically make sure no schema fields are accidentally removed, a common mistake during module upgrades.

6. Testing Error Cases and Error Responses

API tests for error cases are at least as important as happy-path tests. A Magento REST endpoint that returns a 500 error instead of a validated 400 error on invalid input is a bug, even if the normal case works correctly. GraphQL endpoints that return a 200 response with empty data on authorization failures instead of an error violate the GraphQL protocol.

For GraphQL error cases, graphQlQuery() throws a ResponseContainsErrorsException whenever the response contains an errors array. You can test for this exception with expectException and for the specific error text with expectExceptionMessage. Alternatively, use $this->graphQlMutation() for mutation-specific tests with an expected error.


<?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 for API Tests: Building Clean Data

API tests without a clean fixture strategy quickly become brittle. If a REST test depends on a specific product with a specific SKU existing in the database, and that state is not established explicitly through a fixture, the test depends on the execution order of other tests, a classic source of flakiness.

For API tests, the recommended combination is #[DataFixture] attributes for database state and DataFixtureStorageManager::getStorage()->get('alias') for accessing the created entities in the test. This approach is declarative, shows all required data at the top of the test class, and ensures through automatic rollback after the test that no state leaks into other tests. For complex fixture scenarios with dependent entities (category to product to price rule), use the reference syntax $alias.id$ in the fixture parameters.

8. REST vs. GraphQL Tests Compared

REST and GraphQL tests differ in structure and in their typical challenges. Understanding these differences helps in building an efficient API test suite for Magento 2.

Aspect REST Tests GraphQL Tests Recommendation
Base class AbstractController GraphQlAbstract Depends on API type
Response format JSON string, decode manually PHP array directly GraphQL simpler
Error cases Check HTTP status code Catch exception Test both explicitly
Auth handling Set header manually Pass headers array Encapsulate in a helper method
Schema check Manually via response structure Introspection query possible Use the GraphQL advantage

A practical tip: admin token generation and customer token generation should be encapsulated in a shared base test class or a trait that all API tests in the module use. Duplicated token generation code is common in grown test suites and creates maintenance overhead when the test user changes. A shared ApiTestCase class with helper methods such as getAdminToken(), getCustomerToken(), and graphQlQueryAsAdmin() makes tests shorter and more focused on the domain-specific assertion.

9. Summary

REST and GraphQL integration tests in Magento 2 can be built in a structured, maintainable way using the Magento test framework. AbstractController for REST, GraphQlAbstract for GraphQL, both base classes provide the full Magento framework for internal requests without network overhead. Fixtures establish reproducible database state, and transaction rollback provides isolation between tests.

The essential test cases for every API endpoint: happy path with valid data and correct auth, error case with invalid input (400/422), authentication error without a token (401), authorization error with the wrong token (403), and not-found case (404). Anyone covering all five categories has a complete API test suite that provides regression safety for all essential scenarios. Schema introspection for GraphQL extensions additionally guards against accidental breaking changes in the schema.

Magento 2 API Tests, the Essentials at a Glance

REST Tests

AbstractController: dispatch() sends internal requests without TCP. Explicitly validate status code and JSON body.

GraphQL Tests

GraphQlAbstract: graphQlQuery() returns a PHP array. Error cases throw ResponseContainsErrorsException.

Authentication

Admin token for admin endpoints, customer token for customer endpoints, no token for public endpoints. Encapsulate in helper methods.

Testing error cases

Happy path, 400 (invalid input), 401 (no token), 403 (wrong permission), 404 (not found). All five categories for complete coverage.

10. FAQ: Testing GraphQL and REST Endpoints in Magento with PHPUnit

1Base class for REST API tests?
AbstractController: dispatch() for internal requests without TCP, getResponse() for status and body.
2Base class for GraphQL tests?
GraphQlAbstract: graphQlQuery() returns a PHP array. Error cases throw ResponseContainsErrorsException.
3Testing authenticated REST endpoints?
Admin: AdminTokenServiceInterface. Customer: CustomerTokenServiceInterface. Set the token as a Bearer header.
4Testing GraphQL error cases?
expectException(ResponseContainsErrorsException::class) + expectExceptionMessageMatches() for specific error messages.
5Reproducible DB state for API tests?
#[DataFixture] attribute with automatic rollback. DataFixtureStorageManager::getStorage()->get('alias') for access to the created entities.
6Testing GraphQL schema extensions?
Introspection queries check whether fields and types are registered. Direct resolver tests validate response structure and values.
7Testing GraphQL mutations?
graphQlMutation() instead of graphQlQuery(), identical signature, semantically correct for mutations. Handle error cases identically.
8Encapsulating token generation?
A shared ApiTestCase class or trait with getAdminToken(), getCustomerToken(). All API tests inherit or use the trait.
9Five mandatory test cases per endpoint?
Happy path, invalid input (400), no token (401), missing permission (403), not found (404). All five for complete coverage.
10Avoiding shared DB state between API tests?
Magento integration tests: automatic transaction rollback after every test. Explicit fixtures per test, no dependency on data from other tests.