AP2I is a Symfony 7.3-based API application that is part of the OpenTalent ecosystem. It's a comprehensive API platform built with modern PHP (8.2+) and leverages API Platform 4.1 for REST/GraphQL API capabilities.
The application handles multiple business domains including:
src/
├── Message/ # Symfony Messenger messages and handlers
├── DataFixtures/ # Database fixtures for testing/development
├── Validator/ # Custom validation logic
├── Enum/ # Domain-specific enumerations
├── Service/ # Business logic services
└── ... # Other domain-specific directories
tests/
├── Fixture/ # Test data fixtures and factories
├── Application/ # Application-level integration tests
├── Unit/ # Unit tests organized by domain
└── ...
config/ # Symfony configuration
public/ # Web root directory
templates/ # Twig templates
migrations/ # Doctrine database migrations
tests/Unit/Service/Typo3/Typo3ServiceTest.php as the reference example for writing service testsEach test class should follow this structure:
<?php
namespace App\Tests\Unit\Service\MyDomain;
use App\Service\MyDomain\MyService;
use PHPUnit\Framework\TestCase;
use SomeDependency\Interface;
use PHPUnit\Framework\MockObject\MockObject;
// Create testable class for protected methods access
class TestableMyService extends MyService
{
public function protectedMethod(): mixed
{
return parent::protectedMethod();
}
}
class MyServiceTest extends TestCase
{
private Interface|MockObject $dependency;
public function setUp(): void
{
// Mock all dependencies in setUp
$this->dependency = $this->getMockBuilder(Interface::class)
->disableOriginalConstructor()
->getMock();
}
private function getMyServiceMockFor(string $methodName): TestableMyService|MockObject
{
return $this->getMockBuilder(TestableMyService::class)
->setConstructorArgs([$this->dependency])
->setMethodsExcept([$methodName])
->getMock();
}
}
setMethodsExcept() to mock all methods except the one being tested (ignore deprecation warnings)getMyClassMockFor(string $methodName)getMockBuilder()setUp)setMethodsExcept()expects(self::never()) when verifying a method should NOT be calledExample:
public function testClearSiteCache(): void
{
$typo3Service = $this->getTypo3ServiceMockFor('clearSiteCache');
$response = $this->getMockBuilder(ResponseInterface::class)
->disableOriginalConstructor()
->getMock();
$typo3Service->expects(self::once())
->method('sendCommand')
->with('/otadmin/site/clear-cache', ['organization-id' => 1])
->willReturn($response);
$typo3Service->clearSiteCache(1);
}
TestableTypo3Service example)class TestableMyService extends MyService
{
public function protectedMethodToTest(string $param): ResponseInterface
{
return parent::protectedMethodToTest($param);
}
}
testMethod (where "Method" is the actual method name)testMethodWhenNoInputtestMethodWithInvalidContexttestMethodWithSpecialConditionUse @see annotations to reference the tested method: ```php /**
@see MyService::clearSiteCache() */ public function testClearSiteCache(): void
#### Test Structure Best Practices
- **One test per execution branch/path**
- **Use assertions** when the method should return a result
- **Use expectations** when the method should call other methods
- **Use `expects(self::never())`** when verifying a method should NOT be called
- **Cover edge cases and error scenarios**
- **Test method variants** (e.g., with/without optional parameters)
#### Dependencies and Isolation
- **All dependencies must be mocked** in `setUp` method
- **Tests must be isolated and independent**
- **Use `disableOriginalConstructor()`** for mock builders
- **Mock return values** using `willReturn()` for expected behaviors
#### Example Test Patterns
**Simple method expectation:**
```php
public function testCreateSite(): void
{
$typo3Service = $this->getTypo3ServiceMockFor('createSite');
$response = $this->getMockBuilder(ResponseInterface::class)
->disableOriginalConstructor()->getMock();
$typo3Service->expects(self::once())
->method('sendCommand')
->with('/otadmin/site/create', ['organization-id' => 1])
->willReturn($response);
$typo3Service->createSite(1);
}
Method with conditional parameters:
public function testSetSiteDomainWithRedirection(): void
{
$typo3Service = $this->getTypo3ServiceMockFor('setSiteDomain');
$response = $this->getMockBuilder(ResponseInterface::class)
->disableOriginalConstructor()->getMock();
$typo3Service->expects(self::once())
->method('sendCommand')
->with('/otadmin/site/set-domain', [
'organization-id' => 1,
'domain' => 'new-domain',
'redirect' => 1
])
->willReturn($response);
$typo3Service->setSiteDomain(1, 'new-domain', true);
}
# Run all unit tests
docker exec ap2i php -d memory_limit=-1 vendor/phpunit/phpunit/phpunit --testsuite unit --configuration phpunit.xml.dist
# Run application tests (adjust testsuite as needed)
docker exec ap2i vendor/bin/phpunit --configuration phpunit.xml.dist
# Run specific test class
docker exec ap2i vendor/bin/phpunit tests/Unit/Service/MyDomain/MyServiceTest.php
# Run with coverage (if configured)
docker exec ap2i vendor/bin/phpunit --coverage-html coverage/
The project enforces strict code quality standards:
# Run analysis
docker exec ap2i vendor/bin/phpstan analyse
# Clear cache if needed
docker exec ap2i vendor/bin/phpstan clear-result-cache
# Check code style
docker exec ap2i php vendor/bin/php-cs-fixer check --config=.php-cs-fixer.dist.php
# Fix code style automatically
docker exec ap2i php vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php
The application uses Symfony Messenger for async processing:
# Start consuming messages
docker exec ap2i php bin/console messenger:consume async
# Setup transports if needed
docker exec ap2i php bin/console messenger:setup-transports
docker exec ap2i composer install for dependenciesdocker exec ap2i php bin/console doctrine:migrations:migratedocker exec ap2i php bin/console cache:clear