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".
Table of Contents
- 1. API Tests in Magento 2: Where They Sit in the Test Pyramid
- 2. REST API Tests with AbstractController
- 3. Authentication in REST Tests: Admin and Customer Tokens
- 4. GraphQL Tests: GraphQlAbstract and Query Structure
- 5. Testing GraphQL Schema Extensions
- 6. Testing Error Cases and Error Responses
- 7. Fixtures for API Tests: Building Clean Data
- 8. REST vs. GraphQL Tests Compared
- 9. Summary
- 10. FAQ
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?
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?
7Testing GraphQL mutations?
graphQlMutation() instead of graphQlQuery(), identical signature, semantically correct for mutations. Handle error cases identically.8Encapsulating token generation?
ApiTestCase class or trait with getAdminToken(), getCustomerToken(). All API tests inherit or use the trait.