Test API Platform resources
Edit on GitHubThis document describes how to write and run tests for your API Platform resources in your project.
Overview
API Platform provides a comprehensive testing infrastructure built on top of:
- Codeception: Test framework for PHP
- API Platform Test Client: Specialized HTTP client for API testing
- PHPUnit Assertions: Rich set of assertion methods
- Test Helpers: Custom helpers for test data management
The testing infrastructure supports both Backend and Storefront API types with dedicated base classes and configuration.
Test tiers
Tests split into two tiers by what they cover and what they cost. Put a test in the cheapest tier that can carry it.
| Tier | Covers | Kernel | Cost per test |
|---|---|---|---|
| Logic | Provider and processor mapping, and the mapping from an error to a status. | Shared, built once per process. | About 0.4 ms, after a first boot of about 250 ms. |
| Integration | Full-stack CRUD and the real error surface: authentication to 401 and 403, validation to 422, serialization and the response envelope, and ?include= compound documents. |
The real stack. | About 2–3 s, after a one-time database template build. |
Both tiers resolve the system under test from the container, so both exercise the real service wiring. The difference is how far a request travels.
The logic tier stubs the collaborators a test names and calls provide() or process() directly. Nothing else is stubbed, and there is no automatic doubling—a collaborator you want to control must be registered explicitly.
$wishlistTransfer = $this->tester->haveWishlistTransfer();
$this->tester->setService(
WishlistClientInterface::class,
$this->tester->createClientStub(WishlistClientInterface::class, [
'getWishlistByFilter' => $this->tester->haveSuccessfulWishlistResponseTransfer($wishlistTransfer),
]),
);
$provider = $this->tester->getProvider(WishlistsStorefrontProvider::class);
$result = $provider->provide(
$this->tester->getGetOperation(WishlistsStorefrontResource::class),
['uuid' => $wishlistTransfer->getUuid()],
$this->tester->getAuthenticatedContext(),
);
The integration tier runs without Docker. The booted Glue kernel drives the real client and facade, and each client-to-Zed remote call is dispatched in-process to the gateway controller against a SQLite database, preserving the JSON round trip. Only OAuth token introspection is stubbed. Everything on the data path is real.
Authentication and input-validation negatives belong in the integration tier even though they persist nothing, because the firewall and the framework validator answer before the data layer is reached.
Do not assert on validation constraint messages or JSON structure in the logic tier. Those belong in the integration tier.
Test architecture
Test class hierarchy
AbstractApiTestCase (base class from core)
├── BackendApiTestCase (for Backend API tests)
└── StorefrontApiTestCase (for Storefront API tests)
Key components
| Component | Purpose |
|---|---|
AbstractApiTestCase |
Base class providing API Platform integration |
BackendApiTestCase |
Pre-configured for Backend API testing |
StorefrontApiTestCase |
Pre-configured for Storefront API testing |
ApiTestKernel |
Lightweight Symfony kernel for testing |
ApiTestAssertionsTrait |
API-specific assertions (from API Platform) |
Test helper classes
The testing infrastructure provides specialized Codeception helpers to streamline test development:
| Helper Class | Purpose |
|---|---|
BootstrapHelper |
Configures application plugin providers for test environments via codeception.yml. Allows different test suites to use different factory implementations without hardcoding dependencies in test infrastructure. |
ApiPlatformHelper |
Configures resource generation and cache lifecycle for the test kernel. Has two modes — project (default, preserves the compiled container for speed) and core (generates fresh resources per suite and cleans the container cache afterwards, used when testing the API Platform module itself). See ApiPlatformHelper modes. |
ApiPlatformConfigBuilder |
Provides a fluent interface for building test-specific API Platform configurations. Useful for creating isolated test scenarios with custom settings. |
ApiResourceGeneratorHelper |
Assists with testing resource generation functionality. Provides methods to generate test resources, validate generation output, and clean up generated files. |
These helpers are automatically available in your test cases through the Codeception actor and provide essential functionality for testing API Platform resources effectively.
Setting up your test environment
1. Configure autoloading for generated test resources
Update your project-level composer.json to include the test API namespace:
composer.json (project root)
{
"autoload-dev": {
"psr-4": {
"PyzTest\\": "tests/PyzTest/",
"Generated\\TestApi\\": "tests/_data/Api/"
}
}
}
2. Optional: Configure application plugin providers
If your tests require application plugins to be registered (for example, service providers or middleware), configure the BootstrapHelper in your suite’s codeception.yml:
tests/PyzTest/Glue/Customer/BackendApi/codeception.yml
modules:
enabled:
- \SprykerTest\Shared\Testify\Helper\BootstrapHelper:
applicationPluginProvider:
class: Spryker\Glue\GlueBackendApiApplication\GlueBackendApiApplicationFactory
method: getApplicationPlugins
For Storefront API tests, use the appropriate factory:
tests/PyzTest/Glue/Customer/StorefrontApi/codeception.yml
modules:
enabled:
- \SprykerTest\Shared\Testify\Helper\BootstrapHelper:
applicationPluginProvider:
class: Spryker\Glue\GlueStorefrontApiApplication\GlueStorefrontApiApplicationFactory
method: getApplicationPlugins
Configuration options:
class: The fully qualified class name of the factory that provides application pluginsmethod: The method name to call on the factory (typicallygetApplicationPlugins)
If no applicationPluginProvider is configured, the helper returns an empty array, and tests run without additional application plugins.
3. Create test directory structure
tests/
├── PyzTest/
│ └── Glue/
│ └── Customer/
│ ├── BackendApi/
│ │ ├── codeception.yml
│ │ └── CustomersBackendApiTest.php
│ └── StorefrontApi/
│ ├── codeception.yml
│ └── CustomersStorefrontApiTest.php
└── _data/
└── Api/
├── Backend/
│ └── CustomersBackendResource.php (generated)
└── Storefront/
└── CustomersStorefrontResource.php (generated)
4. Generate API resources for testing
The resources and the container are automatically generated right before the test suite runs.
Automatic resource generation and cleanup
The test infrastructure handles resource lifecycle automatically:
- Generation (core mode only): Test-specific API resources are generated into
tests/_data/Api/{ApiType}/before each suite executes. In project mode, the helper instead validates that the project-generated resources already exist on disk. - Cleanup (core mode only): The
ApiPlatformHelperclears the compiled Symfony test kernel cache and the generated resources after the suite completes. Project mode deliberately skips this step so the compiled container can be reused across runs. - Mode selection: Choose the mode in
codeception.yml— see ApiPlatformHelper modes below for the trade-offs.
This automation ensures that:
- Tests always run against the latest schema definitions
- No manual cache clearing is required between test runs
- Test failures related to stale cache are eliminated
Writing Backend API tests
Basic test structure
Backend API tests extend BackendApiTestCase and use the BackendApiTester tester which gets automatically injected into your tests by Codeception.
tests/PyzTest/Glue/Customer/BackendApi/CustomersBackendApiTest.php
<?php
namespace PyzTest\Glue\Customer\BackendApi;
use PyzTest\Glue\Customer\BackendApiTester;
use SprykerTest\ApiPlatform\Test\BackendApiTestCase;
/**
* @group PyzTest
* @group Glue
* @group Customer
* @group BackendApi
* @group CustomersBackendApiTest
*/
class CustomersBackendApiTest extends BackendApiTestCase
{
protected BackendApiTester $tester;
public function testGivenValidDataWhenCreatingCustomerViaPostThenCustomerIsCreatedSuccessfully(): void
{
// Arrange
$customerData = [
'email' => '[email protected]',
'firstName' => 'John',
'lastName' => 'Doe',
];
// Act
static::createClient()->request('POST', '/customers', ['json' => $customerData]);
// Assert
$this->assertResponseIsSuccessful();
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains(['email' => '[email protected]']);
$this->assertJsonContains(['firstName' => 'John']);
$this->assertJsonContains(['lastName' => 'Doe']);
}
}
Testing GET operations
Single resource
public function testGivenExistingCustomerWhenRetrievingViaGetThenCustomerDataIsReturned(): void
{
// Arrange
$customerTransfer = $this->tester->haveCustomer([
'email' => '[email protected]',
'firstName' => 'Jane',
'lastName' => 'Smith',
]);
// Act
static::createClient()->request(
'GET',
sprintf('/customers/%s', $customerTransfer->getCustomerReference())
);
// Assert
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['email' => '[email protected]']);
$this->assertJsonContains(['firstName' => 'Jane']);
}
Collection with pagination
public function testGivenMultipleCustomersWhenRetrievingCollectionViaGetThenAllCustomersAreReturned(): void
{
// Arrange
$this->tester->haveCustomer(['email' => '[email protected]']);
$this->tester->haveCustomer(['email' => '[email protected]']);
$this->tester->haveCustomer(['email' => '[email protected]']);
// Act
static::createClient()->request('GET', '/customers');
// Assert
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['@type' => 'Collection']);
$this->assertJsonContains(['totalItems' => 3]);
}
public function testGivenPaginationParamsWhenRetrievingCollectionThenPaginatedResultsAreReturned(): void
{
// Arrange
for ($i = 1; $i <= 15; $i++) {
$this->tester->haveCustomer(['email' => sprintf('customer%[email protected]', $i)]);
}
// Act
static::createClient()->request('GET', '/customers?page=2&itemsPerPage=5');
// Assert
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['@type' => 'Collection']);
$this->assertJsonContains(['view' => ['@id' => '/customers?page=2&itemsPerPage=5']]);
}
Testing POST operations
Successful creation
public function testGivenValidDataWhenCreatingCustomerViaPostThenCustomerIsCreatedSuccessfully(): void
{
// Arrange
$customerData = [
'email' => '[email protected]',
'firstName' => 'New',
'lastName' => 'Customer',
];
// Act
$response = static::createClient()->request('POST', '/customers', [
'json' => $customerData,
]);
// Assert
$this->assertResponseIsSuccessful();
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains($customerData);
$this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8');
// Verify the resource was created and has an ID
$responseData = $response->toArray();
$this->assertArrayHasKey('customerReference', $responseData);
$this->assertNotEmpty($responseData['customerReference']);
}
Validation errors
public function testGivenInvalidDataWhenCreatingCustomerViaPostThenValidationErrorIsReturned(): void
{
// Arrange
$invalidCustomerData = [
'email' => 'invalid-email', // Invalid email format
'firstName' => '', // Empty first name
];
// Act
static::createClient()->request('POST', '/customers', [
'json' => $invalidCustomerData,
]);
// Assert
$this->assertResponseStatusCodeSame(422);
$this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8');
$this->assertJsonContains(['@type' => 'ConstraintViolationList']);
$this->assertJsonContains([
'violations' => [
['propertyPath' => 'email'],
['propertyPath' => 'firstName'],
['propertyPath' => 'lastName'],
],
]);
}
Business rule violations
public function testGivenDuplicateEmailWhenCreatingCustomerViaPostThenErrorIsReturned(): void
{
// Arrange
$this->tester->haveCustomer(['email' => '[email protected]']);
$duplicateData = [
'email' => '[email protected]',
'firstName' => 'Duplicate',
'lastName' => 'Customer',
];
// Act
static::createClient()->request('POST', '/customers', [
'json' => $duplicateData,
]);
// Assert
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains(['@type' => 'Error']);
$this->assertJsonContains(['detail' => 'Customer with this email already exists']);
}
Testing PATCH operations
public function testGivenExistingCustomerWhenUpdatingViaPatchThenCustomerIsUpdatedSuccessfully(): void
{
// Arrange
$customerTransfer = $this->tester->haveCustomer([
'email' => '[email protected]',
'firstName' => 'Original',
'lastName' => 'Name',
]);
$updateData = [
'firstName' => 'Updated',
'lastName' => 'Name',
];
// Act
static::createClient()->request(
'PATCH',
sprintf('/customers/%s', $customerTransfer->getCustomerReference()),
[
'json' => $updateData,
'headers' => [
'Content-Type' => 'application/merge-patch+json',
],
]
);
// Assert
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['firstName' => 'Updated']);
$this->assertJsonContains(['email' => '[email protected]']); // Unchanged
}
Testing DELETE operations
public function testGivenExistingCustomerWhenDeletingViaDeleteThenCustomerIsDeletedSuccessfully(): void
{
// Arrange
$customerTransfer = $this->tester->haveCustomer([
'email' => '[email protected]',
]);
// Act
static::createClient()->request(
'DELETE',
sprintf('/customers/%s', $customerTransfer->getCustomerReference())
);
// Assert
$this->assertResponseStatusCodeSame(204);
$this->assertResponseHasNoContent();
}
public function testGivenNonExistentCustomerWhenDeletingViaDeleteThen404IsReturned(): void
{
// Act
static::createClient()->request('DELETE', '/customers/NON-EXISTENT-REFERENCE');
// Assert
$this->assertResponseStatusCodeSame(404);
}
Testing relationships
The relationships feature enables resources to include related resources via the ?include= query parameter. For details on configuring relationships, see Resource relationships.
Testing include parameter
public function testGivenCustomerWithAddressesWhenRequestingWithIncludeThenAddressesAreIncluded(): void
{
// Arrange
$customerTransfer = $this->tester->haveCustomer();
$this->tester->haveAddress(['customerReference' => $customerTransfer->getCustomerReference()]);
$this->tester->haveAddress(['customerReference' => $customerTransfer->getCustomerReference()]);
// Act
$response = static::createClient()->request(
'GET',
sprintf('/customers/%s?include=addresses', $customerTransfer->getCustomerReference())
);
// Assert
$this->assertResponseIsSuccessful();
$data = $response->toArray();
// Assert relationships section exists
$this->assertArrayHasKey('relationships', $data['data']);
$this->assertArrayHasKey('addresses', $data['data']['relationships']);
// Assert included section contains addresses
$this->assertArrayHasKey('included', $data);
$this->assertCount(2, $data['included']);
}
Testing JSON:API structure
public function testGivenIncludedResourcesWhenRetrievingThenJsonApiStructureIsValid(): void
{
// Arrange
$customerTransfer = $this->tester->haveCustomer();
$this->tester->haveAddress(['customerReference' => $customerTransfer->getCustomerReference()]);
// Act
$response = static::createClient()->request(
'GET',
sprintf('/customers/%s?include=addresses', $customerTransfer->getCustomerReference())
);
// Assert
$data = $response->toArray();
// Verify main resource structure
$this->assertArrayHasKey('data', $data);
$this->assertArrayHasKey('type', $data['data']);
$this->assertArrayHasKey('id', $data['data']);
$this->assertArrayHasKey('attributes', $data['data']);
$this->assertArrayHasKey('relationships', $data['data']);
// Verify included resources structure
foreach ($data['included'] as $includedResource) {
$this->assertArrayHasKey('type', $includedResource);
$this->assertArrayHasKey('id', $includedResource);
$this->assertArrayHasKey('attributes', $includedResource);
}
// Verify relationship linkage
$relationshipData = $data['data']['relationships']['addresses']['data'];
foreach ($relationshipData as $linkage) {
$this->assertArrayHasKey('type', $linkage);
$this->assertArrayHasKey('id', $linkage);
}
}
Writing Storefront API tests
Basic test structure
Storefront API tests extend StorefrontApiTestCase and typically use mocks for read-only operations.
tests/PyzTest/Glue/Customer/StorefrontApi/CustomersStorefrontApiTest.php
<?php
namespace PyzTest\Glue\Customer\StorefrontApi;
use Codeception\Stub;
use Pyz\Client\Customer\CustomerClientInterface;
use PyzTest\Glue\Customer\StorefrontApiTester;
use SprykerTest\ApiPlatform\Test\StorefrontApiTestCase;
/**
* @group PyzTest
* @group Glue
* @group Customer
* @group StorefrontApi
* @group CustomersStorefrontApiTest
*/
class CustomersStorefrontApiTest extends StorefrontApiTestCase
{
protected StorefrontApiTester $tester;
public function testGivenAuthenticatedCustomerWhenRetrievingProfileViaGetThenCustomerDataIsReturned(): void
{
// Arrange
$customerClientStub = Stub::makeEmpty(CustomerClientInterface::class, [
'getCustomer' => (new CustomerTransfer())
->setEmail('[email protected]')
->setFirstName('John')
->setLastName('Doe'),
]);
$this->setService(CustomerClientInterface::class, $customerClientStub);
// Act
static::createClient()->request('GET', '/customers/me');
// Assert
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['email' => '[email protected]']);
}
}
Testing with service mocks
Register mocks with setService() rather than setting them on the container yourself. It is the supported seam, and it binds the mock in whichever tier the test runs. For when to call it, see Register stubs before you resolve the system under test.
public function testGivenMultipleCustomersWhenRetrievingCollectionViaGetThenAllCustomersAreReturned(): void
{
// Arrange
$customerClientStub = Stub::makeEmpty(CustomerClientInterface::class, [
'getCustomerCollection' => [
(new CustomerTransfer())->setEmail('[email protected]'),
(new CustomerTransfer())->setEmail('[email protected]'),
],
]);
$this->setService(CustomerClientInterface::class, $customerClientStub);
// Act
static::createClient()->request('GET', '/customers');
// Assert
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['@type' => 'Collection']);
}
Available assertions
HTTP response assertions
// Status codes
$this->assertResponseIsSuccessful(); // 2xx status code
$this->assertResponseStatusCodeSame(200); // Exact status code
$this->assertResponseStatusCodeSame(201); // Created
$this->assertResponseStatusCodeSame(204); // No content
$this->assertResponseStatusCodeSame(400); // Bad request
$this->assertResponseStatusCodeSame(401); // Unauthorized
$this->assertResponseStatusCodeSame(403); // Forbidden
$this->assertResponseStatusCodeSame(404); // Not found
$this->assertResponseStatusCodeSame(422); // Validation error
// Headers
$this->assertResponseHasHeader('Content-Type');
$this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8');
$this->assertResponseHeaderNotSame('X-Custom-Header', 'value');
// Content
$this->assertResponseHasNoContent(); // Empty response body
JSON assertions
// Content matching
$this->assertJsonContains(['email' => '[email protected]']);
$this->assertJsonContains(['@type' => 'Customer']);
$this->assertJsonContains(['@type' => 'Collection']);
// Array keys
$responseData = $response->toArray();
$this->assertArrayHasKey('customerReference', $responseData);
$this->assertArrayNotHasKey('password', $responseData);
// Validation violations
$this->assertJsonContains(['@type' => 'ConstraintViolationList']);
$this->assertJsonContains([
'violations' => [
['propertyPath' => 'email'],
],
]);
// Collection metadata
$this->assertJsonContains(['totalItems' => 10]);
$this->assertJsonContains(['view' => ['@id' => '/customers?page=1']]);
Custom API Platform assertions
// JSON-LD context
$this->assertJsonContains(['@context' => '/contexts/Customer']);
// Hydra collections
$this->assertJsonContains(['hydra:totalItems' => 5]);
$this->assertJsonContains(['hydra:member' => []]);
// IRI matching
$iri = $this->getIriFromResource($resource);
$this->assertMatchesRegularExpression('~^/customers/[A-Z0-9\-]+$~', $iri);
Test data management
Using Codeception helpers
Create test data using your project’s tester helpers:
// Create a customer
$customerTransfer = $this->tester->haveCustomer([
'email' => '[email protected]',
'firstName' => 'John',
'lastName' => 'Doe',
]);
// Create multiple customers
for ($i = 1; $i <= 10; $i++) {
$this->tester->haveCustomer([
'email' => sprintf('customer%[email protected]', $i),
]);
}
Cleanup strategies
Automatic cleanup (default)
The test kernel automatically cleans up after each test. No manual cleanup needed.
Manual cleanup (when needed)
protected function tearDown(): void
{
// Custom cleanup logic
$this->tester->cleanupCustomers();
parent::tearDown();
}
Testing different media types
JSON-LD (default)
public function testJsonLdFormat(): void
{
static::createClient()->request('GET', '/customers', [
'headers' => [
'Accept' => 'application/ld+json',
],
]);
$this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8');
$this->assertJsonContains(['@context' => '/contexts/Customer']);
}
JSON:API
public function testJsonApiFormat(): void
{
static::createClient()->request('GET', '/customers', [
'headers' => [
'Accept' => 'application/vnd.api+json',
],
]);
$this->assertResponseHeaderSame('Content-Type', 'application/vnd.api+json; charset=utf-8');
$this->assertJsonContains(['data' => ['type' => 'Customer']]);
}
HAL+JSON
public function testHalJsonFormat(): void
{
static::createClient()->request('GET', '/customers', [
'headers' => [
'Accept' => 'application/hal+json',
],
]);
$this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8');
$this->assertJsonContains(['_links' => ['self' => ['href' => '/customers']]]);
}
Advanced testing patterns
Testing with filters
public function testGivenFilterParamsWhenRetrievingCollectionThenFilteredResultsAreReturned(): void
{
// Arrange
$this->tester->haveCustomer(['email' => '[email protected]', 'status' => 'active']);
$this->tester->haveCustomer(['email' => '[email protected]', 'status' => 'inactive']);
// Act
static::createClient()->request('GET', '/customers?status=active');
// Assert
$this->assertResponseIsSuccessful();
$responseData = static::createClient()->getResponse()->toArray();
$this->assertCount(1, $responseData['hydra:member']);
}
Testing sorting
public function testGivenSortParamsWhenRetrievingCollectionThenSortedResultsAreReturned(): void
{
// Arrange
$this->tester->haveCustomer(['lastName' => 'Zulu']);
$this->tester->haveCustomer(['lastName' => 'Alpha']);
$this->tester->haveCustomer(['lastName' => 'Bravo']);
// Act
static::createClient()->request('GET', '/customers?order[lastName]=asc');
// Assert
$this->assertResponseIsSuccessful();
$responseData = static::createClient()->getResponse()->toArray();
$members = $responseData['hydra:member'];
$this->assertEquals('Alpha', $members[0]['lastName']);
$this->assertEquals('Bravo', $members[1]['lastName']);
$this->assertEquals('Zulu', $members[2]['lastName']);
}
Testing error scenarios
public function testGivenMalformedJsonWhenCreatingCustomerViaPostThenBadRequestIsReturned(): void
{
// Act
static::createClient()->request('POST', '/customers', [
'body' => '{invalid-json}',
'headers' => [
'Content-Type' => 'application/json',
],
]);
// Assert
$this->assertResponseStatusCodeSame(400);
}
public function testGivenUnauthorizedRequestWhenAccessingProtectedResourceThen401IsReturned(): void
{
// Act
static::createClient()->request('GET', '/customers/me');
// Assert
$this->assertResponseStatusCodeSame(401);
}
Running tests
The Storefront API tiers, StorefrontApiLogic and StorefrontApiIntegration, run on your host without Docker and without any running service. Backend API integration suites, BackendApiIntegration, boot the GLUE_BACKEND kernel in-process but run against the environment’s own database, so they run inside Docker.
Generate the code the suites need
The suites depend on generated code that is not in version control. Run this once per checkout, and again after any schema change:
vendor/bin/console transfer:generate
vendor/bin/console transfer:databuilder:generate
vendor/bin/console propel:schema:copy
vendor/bin/console propel:model:build
vendor/bin/console transfer:entity:generate
vendor/bin/console search:setup:source-map
GLUE_APPLICATION=GLUE_STOREFRONT vendor/bin/glue api:generate
GLUE_APPLICATION=GLUE_BACKEND vendor/bin/glue api:generate
vendor/bin/console rest-api:build-request-validation-cache
vendor/bin/console testify:build:sqlite-template
The order matters. Entity transfers derive from the merged Propel schema, so the schema copy and the model build come first.
transfer:databuilder:generate and testify:build:sqlite-template are registered only when development console commands are enabled:
DEVELOPMENT_CONSOLE_COMMANDS=1
search:setup:source-map writes the Generated\Shared\Search\*IndexMap classes. Only search-backed resources need them, but the catalog query plugins reference them while the query is being built, so a missing map is a fatal error rather than an empty result.
api:generate is a Glue console command, so use vendor/bin/glue and not vendor/bin/console. It removes src/Generated/Api/{ApiType} before it parses anything, and --dry-run does not suppress that. An interrupted run therefore leaves no resources behind, and every test fails with Class "Generated\Api\Storefront\…Resource" not found. Re-run the command to recover, and pass --keep-existing when you only want to inspect the output. Each run cleans only its own API type’s output directory, so generate both types.
rest-api:build-request-validation-cache is needed because the Storefront application still reaches legacy Glue plugins through the compatibility bridge, and their request validator refuses to run without its cache file.
testify:build:sqlite-template leaves an existing template alone. Pass --force to drop and rebuild it after a Zed schema change, or --path to build it somewhere other than the configured default. A suite run rebuilds a missing template on its own, so calling it explicitly only front-loads the cost or forces a rebuild.
Run a suite
vendor/bin/codecept build -c tests/PyzTest/Glue/<Module>/codeception.yml
APPLICATION_ENV=devtest vendor/bin/codecept run -c tests/PyzTest/Glue/<Module>/codeception.yml StorefrontApiIntegration
Name the suite. A module configuration can still carry legacy Docker-lane suites next to the API Platform tiers.
Enable the test container
Both tiers resolve services from the container, which needs Symfony’s test container. Turn framework.test on for the environment the suites run in—it is configured per application in config/<App>/packages/framework.php. Without it, the suites fail with Could not find service "test.service_container". It is already on for devtest, dockerdev, and dockerci.
The container is compiled on first use and then cached, which takes roughly 45 seconds. That is a one-time cost per checkout and after any change to configuration or generated resources. Run the suites once after regenerating and the compile is behind you.
Rebuild the class-resolver cache after adding a project override
src/Generated/Shared/Kernel/Pyz/resolvableClassCache*.php maps every resolvable class to the winning namespace, and it is read in preference to live resolution. It never expires, so a Pyz class added after the cache was written is silently ignored and the module resolves to the core class. There is no error—just core behavior where your project’s should be. After adding an override, run:
vendor/bin/console cache:class-resolver:build
CI runs from a bare checkout where the file is absent and resolution is live, so this affects local runs only.
In CI
The suites are found by convention. Every tests/PyzTest/Glue/*/codeception.yml that declares a StorefrontApiLogic or a StorefrontApiIntegration suite is picked up, and each of those suites runs in its own codecept process.
Use exactly those two suite names. A new module then needs no workflow change. Use different names and the suites either run nowhere or land in the Docker lane, which they cannot survive.
One process per suite is a requirement, not a preference. These suites boot the Glue kernel in-process and need APPLICATION=GLUE_STOREFRONT. The constant is process-global and first-wins, so a suite sharing a process with the Docker Glue lane inherits whatever that lane set. The umbrella helper fails the test with an explicit error when it detects that.
Running inside Docker
Test suites that predate the host lane, and the BackendApiIntegration suites, run through the Docker SDK:
# Run Backend API tests only
docker/sdk cli vendor/bin/codecept run -c path/to/codeception.yml -g BackendApi
# Run Storefront API tests only
docker/sdk cli vendor/bin/codecept run -c path/to/codeception.yml -g StorefrontApi
Codeception configuration
Suite configuration
Configure your test suite’s codeception.yml to enable the necessary helpers:
tests/PyzTest/Glue/Customer/BackendApi/codeception.yml
suite_namespace: PyzTest\Glue\Customer\BackendApi
actor: BackendApiTester
modules:
enabled:
- \SprykerTest\Shared\Testify\Helper\BootstrapHelper:
applicationPluginProvider:
class: Spryker\Glue\GlueBackendApiApplication\GlueBackendApiApplicationFactory
method: getApplicationPlugins
paths:
tests: .
data: ../../../../../_data
support: _support
output: ../../../../../_output
settings:
bootstrap: _bootstrap.php
colors: true
memory_limit: 1024M
Key configuration points:
- BootstrapHelper: Provides application plugins for the test kernel. This is optional and can be omitted if your tests do not require application-level dependencies.
- suite_namespace: Must match your test suite’s PHP namespace
- actor: The tester class name (for example,
BackendApiTester,StorefrontApiTester)
Wire a suite with an umbrella helper
Each tier needs a stack of helpers in a specific order. Rather than repeating that stack in every suite, enable one umbrella helper that registers it:
modules:
enabled:
- \SprykerTest\ApiPlatform\Helper\StorefrontApiIntegrationHelper:
environmentModule: '\PyzTest\Shared\Testify\Helper\Environment'
projectNamespaces: ['Pyz']
- \SprykerTest\ApiPlatform\Helper\OauthKeyContentsHelper
- \SprykerTest\Client\StorageDatabase\Helper\SqliteStorageHelper
- \SprykerTest\Shared\Customer\Helper\CustomerDataHelper
- \SprykerTest\Shared\Wishlist\Helper\WishlistHelper
- \SprykerTest\ApiPlatform\Helper\ApiLoginHelper
- \SprykerTest\Shared\Wishlist\Helper\WishlistApiTestHelper
- \SprykerTest\ApiPlatform\Helper\ApiRequestHelper
- \SprykerTest\ApiPlatform\Helper\ApiPlatformHelper:
mode: 'project'
apiType: 'Storefront'
bootOnce: true
reuseApplicationContainer: true
StorefrontApiIntegrationHelper registers the integration stack: the database lane, bootstrap, locator, configuration, dependency and data cleanup, transactions, processor resolution, and the in-process Zed transport. Setting publish: true adds the in-process publish leg. With publish: true, the umbrella also registers PublishHelper, QueueHelper, EventHelper, EventBehaviorHelper, BusinessHelper, ClientHelper, and DependencyProviderHelper, so do not list them. StorefrontApiLogicHelper does the same for the logic tier—container resolution only, with no database and no HTTP.
BackendApiIntegrationHelper and BackendApiLogicHelper are the Backend API equivalents and set APPLICATION to GLUE_BACKEND. The Backend integration umbrella runs against the environment’s own database rather than SQLite and has no publish option. Pair it with \SprykerTest\ApiPlatform\Helper\BackendApiLoginHelper and \SprykerTest\Shared\User\Helper\UserDataHelper.
Two keys are yours to supply, because the core helper cannot know them:
environmentModuleis required. It points at the project helper that defines theAPPLICATIONconstants. The umbrella creates it in the one position it must occupy—after the bootstrap and before the locator freezes the configuration—which a plain entry in yourenabledlist could not guarantee.projectNamespacestells the class resolver about your project namespace. Without it, a module your project overrides resolves to the Spryker base class and silently loses every plugin you registered.
Three rules govern the list:
- Enable the umbrella helper first.
- Keep
ApiPlatformHelperlast. Codeception runs_afterSuitein reverse order, and the kernel reset has to happen before the database lane deletes its work database. - Never re-list one of the umbrella’s own child helpers after it. Codeception creates the child a second time and silently discards the configuration forwarded to the replaced instance.
Per-child configuration overrides go under modules: config:. The exceptions are application, projectNamespaces, applicationPluginProvider, and environmentModule, which are umbrella configuration keys. Set them on the umbrella. It pushes application, applicationPluginProvider, and a non-empty projectNamespaces onto the child after creating it, which overrides anything set on the child.
Do not enable ContainerHelper in an integration suite. Its _after() nulls the shared container delegator whenever its container was touched, and the database-fixture path touches it. That discards the compiled container between methods, so every method after the first fails with a null-container TypeError. The logic umbrellas register ContainerHelper themselves, because the logic tier’s container stays untouched. Do not list it again.
ApiPlatformHelper modes
ApiPlatformHelper runs in one of two modes, selected in the suite’s codeception.yml:
modules:
enabled:
- \SprykerTest\ApiPlatform\Helper\ApiPlatformHelper:
mode: 'project' # default; or 'core' for module-level tests
| Mode | Use this when | Before suite | After suite |
|---|---|---|---|
project (default) |
Testing your own project’s API resources end-to-end. | Uses the pre-generated resources in src/Generated/Api/{ApiType}/. When apiType is set and none are found, logs a debug note rather than failing. Skips generation. |
Resets the shared kernel and the container delegator, so no resolved service leaks into the next suite. Keeps the compiled container cache on disk for fast subsequent runs. |
core |
Testing the ApiPlatform module itself (or any module that ships its own schemas in isolation from a project). |
Generates fresh resources into tests/_data/Api/{ApiType}/. Requires apiType to be set on the helper. |
Removes the generated resources and clears the compiled test kernel cache so the next suite starts from a clean slate. |
Use project mode for almost all real-world test suites — it is significantly faster because the compiled Symfony container is reused. Reach for core mode only when you intentionally want each suite to regenerate resources from scratch (typical when testing schema generation or a single module without a project around it).
When using core mode, declare which API type the suite exercises so the helper knows what to generate:
modules:
enabled:
- \SprykerTest\ApiPlatform\Helper\ApiPlatformHelper:
mode: 'core'
apiType: 'Storefront' # or 'Backend'
Fast-path configuration keys
These keys are all opt-in. Omitting one keeps the slower per-method boot with debug on.
- \SprykerTest\ApiPlatform\Helper\ApiPlatformHelper:
mode: 'project' # or 'core'
apiType: 'Storefront' # or 'Backend'
debug: false # Symfony debug off on warm resources; default true
bootOnce: true # one kernel per suite, reset between methods; default false
reuseApplicationContainer: true # keep the container delegator singleton; default false
With bootOnce, the kernel is built once per suite rather than once per test method, and ApiPlatformHelper takes it before each method runs. The container is still reset between methods, which is what lets each method bind its own stubs—a service already bound in the container cannot be replaced.
Register stubs before you resolve the system under test
Call setService($id, $stub) before anything in the test method resolves that service—before the getProcessor(), getProvider(), or request that uses it. A stub registered before the kernel boots is bound at boot. A stub registered against a kernel that is already up is bound immediately, as long as the container has not built that service yet. Once the container has built it, Symfony refuses the replacement and the stub is silently ignored: the real service keeps answering.
The container knows nothing about stubs at compile time. They are bound afterwards through Symfony’s test container.
Infrastructure stand-in helpers
The host lane has no Redis, no Elasticsearch, and nothing draining the queue. These helpers put a real substitute behind each one, so the code above them runs unchanged. None of them stubs the resource under test. SqliteStorageHelper, StorageCacheHelper, and SearchResponseStubHelper are not part of the umbrella; list them after it.
| Helper | Stands in for | Provides |
|---|---|---|
\SprykerTest\Client\StorageDatabase\Helper\SqliteStorageHelper |
Redis, read side | Points the Storage client at the storage-database plugin, so reads hit the spy_*_storage tables of the lane’s SQLite database. Configuration only, no methods. |
\SprykerTest\Zed\Publisher\Helper\PublishHelper |
The queue | publishPendingEvents() drains what the test just created. publishEntities($eventName, $ids) publishes rows that never raised an event. registerEventSubscribers($subscribers) restores legacy event subscribers; call it before anything writes. The synchronization leg stays off, because it only pushes into Redis. |
\SprykerTest\Shared\Testify\Helper\StorageCacheHelper |
— | resetStorageCaches(), plus a reset before every test. Storage clients memoize in statics that survive a container reset, so a read taken before the arrange step otherwise pins the empty result for the whole process. Add further caches from the suite’s codeception.yml with caches: { \Some\Client\Reader: [staticPropertyName] }. |
\SprykerTest\Client\Search\Helper\SearchResponseStubHelper |
Elasticsearch | stubSearchResult(array $formattedSearchResult) replaces the search-adapter plugin list. The Catalog client, query plugins, and query expanders still run, but the query is discarded and the result formatters are bypassed. So the array is the post-formatting result, keyed by formatter name such as products and pagination, and not a raw Elasticsearch body. |
\SprykerTest\Client\Queue\Helper\QueueHelper |
— | Backs the publish leg’s in-memory queue. Set application: Zed for a Glue-namespaced suite: without it, the configuration resolver guesses the application from the suite namespace, guesses Glue, and finds no queue configuration. The umbrella helper forces this when publish: true. |
A storage resource whose name does not derive its table as spy_<resource>_storage needs an entry in SprykerTest\Client\StorageDatabase\Sqlite\SqliteStorageDatabaseConfig. Three exist by default: translation maps to spy_glossary_storage, product_search_config_extension maps to spy_product_search_config_storage, and product_abstract_tax_set maps to spy_tax_product_storage.
Miss the translation entry and every error response becomes PDOException: no such table, because the error provider translates its message before rendering. The reported exception then has nothing to do with the actual failure.
Helper classes
Create helper classes to manage test data:
tests/PyzTest/Glue/Customer/Helper/CustomerHelper.php
<?php
namespace PyzTest\Glue\Customer\Helper;
use Codeception\Module;
use Generated\Shared\Transfer\CustomerTransfer;
use Pyz\Zed\Customer\Business\CustomerFacadeInterface;
class CustomerHelper extends Module
{
public function haveCustomer(array $seed = []): CustomerTransfer
{
$customerTransfer = (new CustomerTransfer())
->fromArray($seed, true)
->setEmail($seed['email'] ?? sprintf('customer-%[email protected]', uniqid()))
->setFirstName($seed['firstName'] ?? 'Test')
->setLastName($seed['lastName'] ?? 'Customer');
return $this->getCustomerFacade()->createCustomer($customerTransfer);
}
protected function getCustomerFacade(): CustomerFacadeInterface
{
return $this->getModule('\\PyzTest\\Shared\\Testify\\Helper\\Environment')
->getFacade('Customer');
}
}
Best practices
1. Use descriptive test method names
// ✅ Good
public function testGivenInvalidEmailWhenCreatingCustomerViaPostThenValidationErrorIsReturned(): void
// ❌ Bad
public function testCreate(): void
2. Follow Arrange-Act-Assert pattern
public function testExample(): void
{
// Arrange - Set up test data and preconditions
$data = ['email' => '[email protected]'];
// Act - Execute the operation being tested
static::createClient()->request('POST', '/customers', ['json' => $data]);
// Assert - Verify the results
$this->assertResponseIsSuccessful();
}
3. Test one thing per test
// ✅ Good - Tests one specific validation rule
public function testGivenMissingEmailWhenCreatingCustomerThenValidationErrorIsReturned(): void
{
static::createClient()->request('POST', '/customers', ['json' => []]);
$this->assertJsonContains(['violations' => [['propertyPath' => 'email']]]);
}
// ❌ Bad - Tests multiple unrelated things
public function testCustomerCreation(): void
{
// Tests validation, creation, retrieval, update all in one test
}
4. Use meaningful test data
// ✅ Good
$customerData = [
'email' => '[email protected]', // Realistic email
'firstName' => 'John', // Realistic name
'lastName' => 'Doe',
];
// ❌ Bad
$customerData = [
'email' => '[email protected]', // Not realistic
'firstName' => 'x', // Not meaningful
'lastName' => 'y',
];
5. Clean up test data appropriately
// For Backend API tests - use tester helpers for setup
$customer = $this->tester->haveCustomer(['email' => '[email protected]']);
// Cleanup happens automatically via test kernel shutdown
6. Test error cases
// Always test both success and failure scenarios
public function testSuccessfulCreation(): void { /* ... */ }
public function testValidationErrors(): void { /* ... */ }
public function testDuplicateEmail(): void { /* ... */ }
public function testNotFound(): void { /* ... */ }
7. Use constants for repeated values
class CustomersBackendApiTest extends BackendApiTestCase
{
private const TEST_EMAIL = '[email protected]';
private const TEST_FIRST_NAME = 'John';
public function testExample(): void
{
$data = [
'email' => self::TEST_EMAIL,
'firstName' => self::TEST_FIRST_NAME,
];
// ...
}
}
8. Group related tests
/**
* @group PyzTest
* @group Glue
* @group Customer
* @group BackendApi
* @group CustomersBackendApiTest
* @group ValidationTests
*/
class CustomersBackendApiTest extends BackendApiTestCase
{
// Run only validation tests:
// vendor/bin/codecept run -g ValidationTests
}
9. Provision fixtures through data helpers
Build transfers through data builders, reached through a module’s own helper. Never hand-roll a transfer in a test, and never let a test carry its own UUIDs, names, or counts.
A test owns the data it asserts against. In the integration tier, that means creating the products, customers, and carts the scenario needs rather than relying on data another test or an installer left behind.
Module-owned data and assertions belong in that module’s helper, shipped from the core module so that projects can enable it. For example, a wishlist helper builds the transfers a stubbed client returns and asserts a resource against them:
$wishlistTransfer = $this->tester->haveWishlistTransfer(); // non-persisting, logic tier
$wishlistTransfer = $this->tester->haveWishlist([WishlistTransfer::FK_CUSTOMER => $customerTransfer->getIdCustomer()]); // DB-backed, integration tier
The authenticated customer transfer comes from the core \SprykerTest\Shared\Customer\Helper\CustomerDataHelper: haveCustomerTransfer() for the logic tier and haveCustomer() for the integration tier. The API test lanes carry no customer code of their own.
Authenticate with \SprykerTest\ApiPlatform\Helper\ApiLoginHelper—actingAsCustomer(), actingAsCompanyUser(), actingWithScopes(), or actingWithInvalidToken(). A request left anonymous exercises the real firewall. Dispatch requests with \SprykerTest\ApiPlatform\Helper\ApiRequestHelper, for example $this->tester->handleApiRequest('GET', '/wishlists/' . $uuid). Enable \SprykerTest\ApiPlatform\Helper\OauthKeyContentsHelper in any suite that mints or verifies a real token.
Troubleshooting
Generated resources not found
Problem: Test fails with “Class not found” for generated resource.
Solution:
- Verify autoload configuration in
composer.json:
{
"autoload-dev": {
"psr-4": {
"PyzTest\\": "tests/PyzTest/",
"Generated\\TestApi\\": "tests/_data/Api/"
}
}
}
- Run composer dump-autoload:
docker/sdk cli composer dump-autoload
Test kernel boot failures
Problem: Tests fail with kernel boot errors.
Solution:
Ensure your test case extends the correct base class:
// For Backend API
use SprykerTest\ApiPlatform\Test\BackendApiTestCase;
class CustomersBackendApiTest extends BackendApiTestCase
{
// ...
}
// For Storefront API
use SprykerTest\ApiPlatform\Test\StorefrontApiTestCase;
class CustomersStorefrontApiTest extends StorefrontApiTestCase
{
// ...
}
Assertion failures with JSON-LD
Problem: JSON assertions fail with @context or @type fields.
Solution:
Use JSON-LD specific assertions:
// ✅ Correct
$this->assertJsonContains(['@type' => 'Customer']);
$this->assertJsonContains(['@context' => '/contexts/Customer']);
// ❌ Wrong
$this->assertJsonContains(['type' => 'Customer']);
Tester helper not found
Problem: $this->tester property shows as undefined.
Solution:
- Verify your tester class exists in the correct location
- Check that the tester is properly type-hinted in your test:
class CustomersBackendApiTest extends BackendApiTestCase
{
protected BackendApiTester $tester; // Must be declared
}
- Rebuild Codeception actors:
docker/sdk cli vendor/bin/codecept build
Thank you!
For submitting the form