Testing
The test suite is built on PHPUnit with Mockery support. Three base classes cover the full testing spectrum:
BaseUnit for pure isolated logic, BaseIntegration for service-level tests against a real booted kernel
and database transaction, and BaseFunctional for HTTP endpoint testing. All integration and functional
tests wrap each test in a database transaction that is rolled back in tearDown, keeping the database
clean between tests.
Directory structure
Each module places its tests under a Test/ subdirectory that mirrors the module's source layout:
code/
Vendor/
ModuleName/
Test/
Unit/
Service/
MyServiceTest.php
Integration/
Management/
MyManagementTest.php
Fixture/
MyFixture.php
Functional/
Api/
MyControllerTest.php
The Test/ directory is only autoloaded in test environments. All test classes follow the same namespace
convention as the source, prefixed with Test\:
#[TestContext] attribute
Amarant\Framework\Attribute\Test\TestContext configures the kernel that is booted for a test. Apply it at
class level (applies to all methods) or at method level (overrides the class attribute). When both are
present, the method-level context wins and its config array is merged on top of the class-level config.
use Amarant\Framework\Attribute\Test\TestContext;
use Amarant\Framework\Contract\Application\AreaInterface;
#[TestContext(
area: AreaInterface::AREA_BACKEND,
host: TestContext::BACKEND_HOST,
config: [
'sales/shipping/origin_address/country_id' => 'HR',
ConfigEnum::DELIVERY_METHOD_FLAT_RATE_ACTIVE->value => true,
]
)]
class MyTest extends BaseIntegration
{
| Parameter | Type | Default | Description |
|---|---|---|---|
area |
string |
AreaInterface::AREA_FRONTEND |
Area to boot the kernel in. |
mode |
string |
ContextInterface::MODE_PRODUCTION |
Context mode. |
host |
string |
TestContext::FRONTEND_HOST |
HTTP host used for synthetic requests. Use TestContext::BACKEND_HOST for backend tests. |
debug |
bool |
false |
Whether to enable debug mode. |
fixtures |
string[] |
[] |
Reserved for future use — see Fixtures for the current pattern. |
config |
array |
[] |
Config overrides. Keys are raw string paths or BackedEnum->value. Overrides apply to all ReaderInterface calls during the test. |
Predefined hosts:
| Constant | Value |
|---|---|
TestContext::FRONTEND_HOST |
merchant.amarant2.local |
TestContext::BACKEND_HOST |
backend.amarant2.local |
Unit tests — BaseUnit
Amarant\Framework\Test\Unit\BaseUnit extends MockeryTestCase. No kernel is booted; the class exists to
give unit tests Mockery integration alongside PHPUnit's createMock().
use Amarant\Framework\Test\Unit\BaseUnit;
class MyServiceTest extends BaseUnit
{
private MyDependency|MockObject $dependency;
private MyService $subject;
protected function setUp(): void
{
$this->dependency = $this->createMock(MyDependency::class);
$this->subject = new MyService($this->dependency);
}
public function testDoSomething(): void
{
$this->dependency
->expects($this->once())
->method('fetch')
->willReturn('result');
$this->assertSame('processed: result', $this->subject->doSomething());
}
}
Integration tests — BaseIntegration
Amarant\Framework\Test\Integration\BaseIntegration boots the full kernel, wraps each test in a database
transaction, and tears down cleanly after each test — rolling back the transaction, clearing all cache
pools, and purging all message queues.
use Amarant\Framework\Attribute\Test\TestContext;
use Amarant\Framework\Test\Integration\BaseIntegration;
#[TestContext(
config: [
'module/feature/enabled' => true,
]
)]
class MyServiceTest extends BaseIntegration
{
private MyService $subject;
protected function setUp(): void
{
parent::setUp();
$this->subject = $this->getContainer()->get(MyService::class);
}
public function testCreate(): void
{
$result = $this->subject->create('value');
$this->assertSame('value', $result->getName());
}
}
Key methods
| Method | Description |
|---|---|
getContainer(): ConfigurableContainerInterface |
Returns the booted DI container. |
getConnection(): ConnectionInterface |
Returns the active database connection (already in a transaction). |
getRepository(string $name): RepositoryInterface |
Convenience accessor for a named repository. |
makeClient(): HttpClient |
Creates an HttpClient scoped to the current test context. |
beforeBootKernel(): void |
Override hook called before kernel boot — use to register post-load DI callbacks. |
afterBootKernel(): void |
Override hook called after kernel boot. |
switchKernelWhilePreservingDbConnections(TestContext $context): void |
Reboots the kernel with a new TestContext while keeping the same DB connection and transaction open. Useful when testing cross-area behaviour in a single test. |
setCommitTransactionLevel(int $level, bool $commit, ?ConnectionInterface $connection): void |
Controls the transaction nesting level at which commit events fire. The default for tests is level 1. |
Container customisation before boot
Override beforeBootKernel() to register a postLoadCallback on the kernel before it boots. This is the
correct place to replace a bound service with a test double:
protected function beforeBootKernel(): void
{
parent::beforeBootKernel();
$this->kernel->registerPostLoadCallback(
function (LoaderContextInterface $loaderContext): void {
$loaderContext->getContainer()->instance(
MyExternalClient::class,
$this->createMock(MyExternalClient::class)
);
}
);
}
Fixtures
Fixtures seed the database with data that must exist before the per-test transaction begins (so that the
data survives rollback and is visible within the transaction). Register them by assigning closures to the
static $fixturesOnceBeforeTransaction array before calling parent::setUp(). A fixture with a given
name runs only once per test class, regardless of how many test methods the class has.
protected function setUp(): void
{
static::$fixturesOnceBeforeTransaction['my_fixture'] = function (): void {
// runs once before the first test in this class starts its transaction
$product = new Product();
$product->setName('Test product');
$this->getContainer()->get(ProductRepository::class)->save($product);
};
parent::setUp(); // boot kernel + begin transaction after fixtures
}
For larger datasets, extract the setup into a dedicated fixture class implementing
Amarant\Framework\Contract\Test\FixtureInterface:
use Amarant\Framework\Contract\Application\KernelInterface;
use Amarant\Framework\Contract\Test\FixtureInterface;
class MyFixture implements FixtureInterface
{
public function execute(KernelInterface $kernel): void
{
$repository = $kernel->getContainer()->get(ProductRepository::class);
// seed data…
}
}
And call it from the $fixturesOnceBeforeTransaction closure:
static::$fixturesOnceBeforeTransaction['my_fixture'] = function (): void {
(new MyFixture())->execute($this->kernel);
};
HTTP client — HttpClient
Amarant\Framework\Test\Integration\HttpClient drives synthetic HTTP requests through the real application
stack. Obtain one from $this->makeClient(). The client carries cookies across requests automatically.
$client = $this->makeClient();
[$response, $requestContext] = $client->makeRequest(
uri: '/api/products/v1',
content: json_encode(['name' => 'Test']),
method: Request::METHOD_POST,
);
$this->assertSame(Response::HTTP_CREATED, $response->getStatusCode());
makeRequest() parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
uri |
string |
— | Request URI. |
content |
mixed |
'' |
Request body. Pass a JSON string for API requests. |
method |
string |
Request::METHOD_GET |
HTTP method. |
host |
?string |
null |
Overrides the host from TestContext. |
parameters |
array |
[] |
Query string / form parameters. |
cookies |
array |
[] |
Per-request cookies (overrides client cookie jar). |
files |
array |
[] |
Uploaded files. |
server |
array |
[] |
Additional $_SERVER keys (e.g. extra headers: ['HTTP_ACCEPT' => 'application/json']). |
beforeRequest |
?Closure |
null |
One-off before-request hook for this call only. |
makeRequest() returns array{0: Response, 1: RequestContextInterface}.
Request lifecycle hooks
| Method | Signature | Called when |
|---|---|---|
setBeforeRequest(Closure) |
fn (RequestContextInterface, HttpClient): void |
Before every request. Useful for auth headers. |
setAfterRequest(Closure) |
fn (RequestContextInterface, HttpClient): void |
After every request. |
setBeforeFirstRequest(Closure) |
fn (RequestContextInterface, HttpClient): void |
Before the very first request only. |
Cookie management
$client->addCookie(new Cookie('session_id', 'abc'));
$client->getCookies(); // returns all stored cookies
$client->clearCookies(); // remove all stored cookies
Building a request context without dispatching
Use createRequestContext() when you need to manipulate the request object before passing it elsewhere
(e.g. in authenticator tests where you push the context to the container manually):
$requestContext = $client->createRequestContext('/api/uri');
$requestContext->getRequest()->headers->set('X-Api-Key', $apiKey);
Response helpers
$dataObject = $client->responseJsonToDataObject($response);
// DataObject supports dot-path access:
$dataObject->getData('details/0/messageId');
Functional tests — BaseFunctional
Amarant\Framework\Test\Functional\BaseFunctional extends BaseIntegration and adds
ResponseAssertTrait plus a set of endpoint testing helpers. Use it for tests that drive an HTTP endpoint
and assert on the response.
doEndpointTest()
The primary entry point for functional tests. It dispatches the request, asserts the expected HTTP status,
and passes the response and a DataObject-wrapped JSON body to your assertion closure.
use Amarant\Framework\Test\Functional\BaseFunctional;
class ProductTest extends BaseFunctional
{
public function testGetProduct(): void
{
$client = $this->makeClient();
$this->doEndpointTest(
requestClosure: fn () => $client->makeRequest('/api/products/v1/1'),
expectedStatus: Response::HTTP_OK,
assertResponseClosure: function (Response $response, DataObject $responseData): void {
$this->assertSame(1, $responseData->getData('id'));
$this->assertSame('Test product', $responseData->getData('name'));
},
client: $client
);
}
}
| Parameter | Type | Default | Description |
|---|---|---|---|
requestClosure |
Closure(): array{0: Response, 1: RequestContextInterface} |
— | Performs the request. Usually fn () => $client->makeRequest(...). |
expectedStatus |
?int |
Response::HTTP_OK |
Expected HTTP status code. Pass null to skip status assertion. |
assertResponseClosure |
?Closure(Response, DataObject): void |
null |
Receives the response and the decoded JSON body. |
client |
?HttpClient |
null |
Client instance for responseJsonToDataObject(). Defaults to a new client. |
Other functional helpers
// Assert single successful response
$this->assertSuccessfulResponse('/api/products/v1');
$this->assertSuccessfulOptionsResponse('/api/products/v1'); // OPTIONS 200
// Assert several methods return specific status codes
$this->assertResponseStatusByMethods('/api/products/v1/999', [
Request::METHOD_GET => Response::HTTP_NOT_FOUND,
Request::METHOD_DELETE => Response::HTTP_NOT_FOUND,
]);
// Assert multiple methods return 404 or 403
$this->assertNotFoundByMethods('/api/products/v1/999', [Request::METHOD_GET, Request::METHOD_PUT]);
$this->assertForbiddenByMethods('/admin/secret', [Request::METHOD_GET, Request::METHOD_POST]);
Collection endpoint tests — BaseDataModelCollectionProviderFunctional
Amarant\Framework\Test\Functional\BaseDataModelCollectionProviderFunctional extends BaseFunctional and
adds assertCollectionResponse() for testing paginated or filtered collection API endpoints that return
{ data: [...] }.
$this->assertCollectionResponse(
endpoint: '/api/directory/v1/countries',
assertions: [
[
'filter' => [],
'expectedResponseItemData' => [
['id' => 1, 'name' => 'Croatia'],
['id' => 2, 'name' => 'Germany'],
],
],
[
'filter' => ['name' => 'Croatia'],
'expectedResponseItemData' => [
['id' => 1, 'name' => 'Croatia'],
],
],
],
canonical: true // use assertEqualsCanonicalizing for order-independent matching
);
Filters are sent as ?q[key]=value query parameters. The method asserts HTTP 200 and that the
data array contains exactly as many items as expectedResponseItemData, then checks each item
partially (or canonicalizing when $canonical = true).
The class also exposes getEntityRepository(), getAttributeRepository(), and
getAttributeSetRepository() for tests that need to inspect or seed attribute metadata.
Authentication in functional tests
The application never uses PHP sessions. Identity is carried between requests entirely via HTTP-only
cookies — a signed JWT cookie for authenticated users and a unique-ID cookie for guests. In production,
a PersistentIdentityProvider reads the cookie on each request, verifies it, and restores the identity.
Storage-aware identities can hold per-user data (locale, flash messages, etc.) but that storage is
backed by Redis, not a PHP session.
In tests there is no real cookie round-trip across synthetic requests, so loginUser() bypasses cookie
persistence entirely: it pushes the request context and calls UserAccountFacade::login() directly,
establishing the identity in security storage for the duration of that request.
Backend user login
UserAccountTrait provides loginUser(), which returns a closure suitable for setBeforeRequest().
The closure creates the user if it does not exist and authenticates it directly on the request context
before each request is dispatched.
use Amarant\Framework\Attribute\Test\TestContext;
use Amarant\Framework\Contract\Application\AreaInterface;
use Amarant\Framework\Test\Functional\BaseFunctional;
use Amarant\Framework\Test\UserAccountTrait;
#[TestContext(area: AreaInterface::AREA_BACKEND, host: TestContext::BACKEND_HOST)]
class MyBackendTest extends BaseFunctional
{
use UserAccountTrait;
public function testProtectedEndpoint(): void
{
$client = $this->makeClient();
$client->setBeforeRequest($this->loginUser(
username: 'admin',
email: 'admin@example.com',
password: 'password'
));
$this->doEndpointTest(
requestClosure: fn () => $client->makeRequest('/api/admin/v1/dashboard'),
);
}
}
loginUser() defaults to username: 'user', email: 'user@example.com', password: 'password'.
API key authentication
use Amarant\Framework\Test\UserAccountTrait;
$this->addUserAccount('user', 'user@example.com', 'password');
[, $rawKey] = $this->addApiKey('user', active: true);
$client = $this->makeClient();
$client->setBeforeRequest(function (RequestContextInterface $requestContext) use ($rawKey): void {
$requestContext->getRequest()->headers->set('X-Api-Key', $rawKey);
});
addApiKey() returns [UserAccountApiKey $entity, string $rawKey]. The raw key string is only available
at creation time.
Assert traits
JsonAssertTrait
Included in BaseIntegration automatically.
| Method | Description |
|---|---|
assertJsonKeys(string $json, array $keys) |
Asserts that all listed top-level keys are present in the JSON object. |
assertJsonPartially(string $json, array $values) |
Asserts key–value pairs against the JSON body, using dot-path notation ('nested/key'). |
assertArrayPartially(array $data, array $values) |
Same as above but for a PHP array. |
assertArrayPartiallyCanonicalizing(array $items, array $expected) |
Asserts a list of items matches $expected regardless of order, checking only the keys present in $expected. |
ResponseAssertTrait
Included in BaseFunctional automatically.
| Method | Description |
|---|---|
assertForbidden(HttpClient, Response) |
Asserts HTTP 403 with error message ID SEC-001. |
assertApiForbiddenResource(HttpClient, Response, string $expectedMessage, int $statusCode) |
Asserts a specific API error code API-008 with a custom message. |
assertNotFound(HttpClient, Response) |
Asserts HTTP 404 with "Not found." in the response. |
assertResourceItemNotFound(HttpClient, Response) |
Asserts HTTP 404 with error message ID API-003. |
UriTestTrait
use Amarant\Framework\Test\UriTestTrait;
// Normalises relative URIs using the test host before comparing
$this->assertUri('/products/my-product', $model->getUrl());
$this->assertUri('/products/my-product', $model->getUrl(), secure: true); // https://
AttributeTestTrait
For tests that operate on the attribute system. Requires the class to also have access to
getAttributeRepository() and getAttributeSetRepository() (i.e. from
BaseDataModelCollectionProviderFunctional).
use Amarant\Framework\Test\AttributeTestTrait;
$this->removeAllAttributes(); // delete all attribute records
$this->createAttributes(
attributes: [new Attribute([Attribute::FIELD_CODE => 'colour'])],
entityName: 'product',
attributeSetName: AttributeSet::DEFAULT_NAME
);
Config overrides
The config key in #[TestContext] replaces the value returned by ReaderInterface for the matched path.
Keys can be plain strings (config path) or the string value of a BackedEnum:
#[TestContext(config: [
'sales/tax/apply_after_discount' => true,
ConfigEnum::CAPTCHA_ENABLED->value => false,
])]
To target a specific config scope, use {scope}_{scopeCode} as a key prefix inside a nested array:
Config overrides set at class level are merged with method-level overrides, with the method-level values taking precedence.