test(architecture): refactor architecture tests and Pest configuration

This commit is contained in:
Jérémy Munsch 2026-09-20 17:16:15 +02:00
parent 039557f95e
commit 4d922eb4db
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
6 changed files with 124 additions and 211 deletions

View file

@ -2,30 +2,54 @@
declare(strict_types=1);
describe('application coding rules', function (): void {
arch('no Domain Entities are used in Controllers')
->expect('Application\\Http\\Controllers')
->not->toUse('Domain\\Models\\Entities');
use Shared\DDD\UseCases\Input;
use Shared\DDD\UseCases\Output;
use Shared\DDD\UseCases\UseCase;
arch('no Domain Entities are used in Policies')
->expect('Application\\Policies')
->not->toUse('Domain\\Models\\Entities');
arch('app Repositories implement Application\Contracts\Repository')
->expect('Application\\Repositories')
->toImplement('Application\\Contracts\\Repository');
arch('app Directories implement Application\Contracts\Directory')
->expect('Application\\Directories')
->toImplement('Application\\Contracts\\Directory');
arch('app DTOs are final readonly classes')
->expect('Application\\Dto')
describe('application layer architecture rules', function (): void {
arch('use cases implement UseCase and are final')
->expect('Application\\UseCases')
->classes()
->toBeFinal()
->implementing(UseCase::class)
->toBeFinal();
arch('use cases implement UseCase and are readonly')
->expect('Application\\UseCases')
->classes()
->implementing(UseCase::class)
->toBeReadonly();
arch('no debug calls in production code')
->expect(['dd', 'dump', 'die', 'var_dump', 'sleep'])
->not->toBeUsed();
arch('use case inputs implement Input interface and are final')
->expect('Application\\UseCases')
->classes()
->implementing(Input::class)
->toBeFinal();
arch('use case inputs implement Input interface and are readonly')
->expect('Application\\UseCases')
->classes()
->implementing(Input::class)
->toBeReadonly();
arch('use case outputs implement Output interface and are final')
->expect('Application\\UseCases')
->classes()
->implementing(Output::class)
->toBeFinal();
arch('use case outputs implement Output interface and are readonly')
->expect('Application\\UseCases')
->classes()
->implementing(Output::class)
->toBeReadonly();
arch('application exceptions extend Exception and are final')
->expect('Application')
->classes()
->extending(\Exception::class)
->toBeFinal();
arch('application layer does not use Infrastructure or framework internals')
->expect('Application')
->not->toUse(['Infrastructure', 'Illuminate', 'Symfony']);
});

View file

@ -6,47 +6,19 @@ describe('coding standards', function (): void {
arch()->preset()->php();
arch()->preset()->security();
arch('it has no shell execution functions are used')
->expect(['exec', 'shell_exec', 'system', 'passthru', 'proc_open'])
->not->toBeUsed();
arch('all source files use strict types')
->expect(['Application', 'Domain', 'Shared'])
->toUseStrictTypes();
arch('it has no eval usage')
->expect(['eval'])
->not->toBeUsed();
arch('it has no dangerous file functions')
->expect(['file_get_contents', 'file_put_contents', 'fopen'])
->not->toBeUsed();
arch('it has no globals are used')
->expect(['$GLOBALS', 'global'])
->not->toBeUsed();
arch('it has no singletons')
->expect(['getInstance', 'instance'])
->not->toBeUsed();
arch('it has no insecure hashing')
->expect(['md5', 'sha1'])
->not->toBeUsed();
arch('it has no sleep function are used')
arch('no sleep functions in production code')
->expect(['sleep', 'usleep'])
->not->toBeUsed();
arch('it has no debug globals are used')
->expect(['var_dump', 'die', 'print_r'])
arch('no singletons in production code')
->expect(['getInstance', 'instance'])
->not->toBeUsed();
arch('all app source files use strict types')
->expect('Application')
->toUseStrictTypes();
arch('all domain source files use strict types')
->expect('Domain')
->toUseStrictTypes();
arch('all shared source files use strict types')
->expect('Shared')
->toUseStrictTypes();
arch('no direct filesystem writes in domain or application')
->expect(['Application', 'Domain'])
->not->toUse(['file_put_contents', 'fopen']);
});

View file

@ -5,43 +5,39 @@ declare(strict_types=1);
use Shared\DDD\Models\Entity;
use Shared\DDD\Ports\Repository;
describe('domain coding rules', function (): void {
arch('entities implements Entity')
describe('domain layer architecture rules', function (): void {
arch('entities implement Entity and are final readonly')
->expect('Domain\\Models\\Entities')
->toImplement(Entity::class);
arch('entities are immutable (disabled due to mock-final-classes conflict)')
->expect('Domain\\Models\\Entities')
->toBeReadonly()
->toBeFinal();
arch('valueObjects are immutable (disabled due to mock-final-classes conflict)')
->expect('Domain\\Models\\ValueObjects')
->toBeReadonly()
->toBeFinal();
arch('ports contains only interfaces')
->expect('Domain\\Ports')
->classes()
->toBeInterface();
->toImplement(Entity::class)
->toBeFinal()
->toBeReadonly();
arch('port Repositories extend Shared\DDD\Repository')
arch('value objects classes are readonly')
->expect('Domain\\Models\\ValueObjects')
->classes()
->toBeReadonly();
arch('value objects classes are final')
->expect('Domain\\Models\\ValueObjects')
->classes()
->toBeFinal();
arch('domain exceptions extend DomainException and are final')
->expect('Domain\\Exceptions')
->classes()
->toExtend(\DomainException::class)
->toBeFinal();
arch('ports contain only interfaces')
->expect('Domain\\Ports')
->toBeInterfaces();
arch('repository ports extend Shared Repository')
->expect('Domain\\Ports\\Repositories')
->toExtend(Repository::class);
arch('no Application classes are used in Domain')
arch('domain does not depend on external layers or frameworks')
->expect('Domain')
->not->toUse('Application');
arch('no Infrastructure classes are used in Domain')
->expect('Domain')
->not->toUse('Infrastructure');
arch('no Illuminate classes are used in Domain')
->expect('Domain')
->not->toUse('Illuminate');
arch('no Symfony components are used in Domain')
->expect('Domain')
->not->toUse('Symfony');
->not->toUse(['Application', 'Infrastructure', 'Illuminate', 'Symfony']);
});

View file

@ -2,62 +2,23 @@
declare(strict_types=1);
describe('naming conventions in application', function (): void {
arch('casts have Cast suffix')
->expect('Application\\Cast')
->toHaveSuffix('Cast');
use Shared\DDD\UseCases\Input;
arch('adapters repositories have Repository suffix')
->expect('Application\\Adapters\\Repositories')
->toHaveSuffix('Repository');
arch('exceptions have Exception suffix')
->expect('Application\\Exceptions')
describe('naming conventions', function (): void {
arch('all exceptions have Exception suffix')
->expect(['Application', 'Domain', 'Shared'])
->classes()
->extending(\Throwable::class)
->toHaveSuffix('Exception');
arch('controllers have Controller suffix')
->expect('Application\\Http\\Controllers')
->toHaveSuffix('Controller');
arch('requests have Request suffix')
->expect('Application\\Http\\Requests')
->toHaveSuffix('Request');
arch('resources have Resource suffix')
->expect('Application\\Http\\Resources')
->toHaveSuffix('Resource');
arch('policies have Policy suffix')
->expect('Application\\Policies')
->toHaveSuffix('Policy');
arch('providers have Provider suffix')
->expect('Application\\Providers')
->toHaveSuffix('Provider');
arch('services have Service suffix')
->expect('Application\\Services')
->toHaveSuffix('Service');
});
describe('naming conventions in domain', function (): void {
arch('dtos have Dto suffix')
->expect('Domain\\Dto')
->toHaveSuffix('Dto');
arch('exceptions have Exception suffix')
->expect('Domain\\Exceptions')
->toHaveSuffix('Exception');
arch('policies have Policy suffix')
->expect('Domain\\Policies')
->toHaveSuffix('Policy');
arch('port repositories have Repository suffix')
arch('port repository interfaces have Repository suffix')
->expect('Domain\\Ports\\Repositories')
->interfaces()
->toHaveSuffix('Repository');
arch('domain services have Service suffix')
->expect('Domain\\Services')
->toHaveSuffix('Service');
arch('use case inputs are named Input')
->expect('Application\\UseCases')
->classes()
->implementing(Input::class)
->toHaveSuffix('Input');
});

View file

@ -2,46 +2,31 @@
declare(strict_types=1);
use Shared\DDD\Models\Entity;
use Shared\DDD\Ports\Repository;
describe('shared coding rules', function (): void {
arch('entities implements Entity')
->expect('Shared\\Domain\\Models\\Entities')
->toImplement(Entity::class);
arch('entities are immutable (disabled due to mock-final-classes conflict)')
->expect('Shared\\Entities')
->toBeReadonly()
->toBeFinal();
arch('valueObjects are immutable (disabled due to mock-final-classes conflict)')
->expect('Shared\\Models\\ValueObjects')
->toBeReadonly()
->toBeFinal();
arch('ports contains only interfaces')
->expect('Shared\\Ports')
describe('shared layer architecture rules', function (): void {
arch('shared value objects classes are final')
->expect('Shared\\ValueObjects')
->classes()
->toBeInterface();
->toBeFinal();
arch('port Repositories extend Shared\DDD\Repository')
->expect('Shared\\Ports\\Repositories')
->toExtend(Repository::class);
arch('shared value objects classes are readonly')
->expect('Shared\\ValueObjects')
->classes()
->toBeReadonly();
arch('no Application classes are used in Shared')
arch('shared ports contain only interfaces')
->expect('Shared\\DDD\\Ports')
->toBeInterfaces();
arch('shared usecase contracts are interfaces')
->expect('Shared\\DDD\\UseCases')
->toBeInterfaces();
arch('shared exceptions extend Exception')
->expect('Shared\\DDD\\Exceptions')
->classes()
->toExtend(\Exception::class);
arch('shared layer is completely isolated from other application layers')
->expect('Shared')
->not->toUse('Application');
arch('no Infrastructure classes are used in Shared')
->expect('Shared')
->not->toUse('Infrastructure');
arch('no Illuminate classes are used in Shared')
->expect('Shared')
->not->toUse('Illuminate');
arch('no Symfony components are used in Shared')
->expect('Shared')
->not->toUse('Symfony');
->not->toUse(['Application', 'Domain', 'Infrastructure', 'Illuminate', 'Symfony']);
});

View file

@ -5,23 +5,11 @@ declare(strict_types=1);
use DG\BypassFinals;
use Tests\TestCase;
// Prevents mock-final-classes/bypass-finals from removing final/readonly.
if (class_exists(BypassFinals::class)) {
BypassFinals::denyPaths([
dirname(__DIR__) . '/src/*',
]);
}
/*
*--------------------------------------------------------------------------
* Test Case
*--------------------------------------------------------------------------
*
* The closure you provide to your test functions is always bound to a specific PHPUnit test
* case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
* need to change it using the "pest()" function to bind different classes or traits.
*
*/
BypassFinals::enable(bypassReadOnly: false, bypassFinal: true);
BypassFinals::denyPaths([
'*/src/*',
'*/tests/*',
]);
pest()->extend(TestCase::class)
->in('Architecture', 'Unit');
@ -30,16 +18,3 @@ pest()->extend(TestCase::class)
pest()->group('architecture')->in('Architecture');
pest()->group('unit')->in('Unit');
/*
*--------------------------------------------------------------------------
* Expectations
*--------------------------------------------------------------------------
*
* When you're writing tests, you often need to check that values meet certain conditions. The
* "expect()" function gives you access to a set of "expectations" methods that you can use
* to assert different things. Of course, you may extend the Expectation API at any time.
*
*/
expect()->extend('toBeOne', fn () => $this->toBe(1));