Skip to content

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\:

Vendor\ModuleName\Test\Integration\Management\MyManagementTest

#[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().

Vendor/ModuleName/Test/Unit/Service/MyServiceTest.php
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.

Vendor/ModuleName/Test/Integration/MyServiceTest.php
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:

Vendor/ModuleName/Test/Integration/Fixture/MyFixture.php
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.
$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.

Vendor/ModuleName/Test/Functional/Api/ProductTest.php
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:

#[TestContext(config: [
    'default_null' => [
        'sales/tax/apply_after_discount' => true,
    ],
])]

Config overrides set at class level are merged with method-level overrides, with the method-level values taking precedence.