Перший arch-тест у проєкті майже завжди однаковий: expect('App\Domain')->not->toUse('Illuminate'). Користь від нього цілком реальна, він справді ловить помилки на рев'ю, і про нього вже є окрема стаття. Проблема починається далі: люди дописують ще десяток правил «щоб було строгіше», половина з них ніколи не спрацьовує, друга половина обростає ->ignoring() до стану, коли правило нічого не перевіряє.
Нижче сім правил; кожне закриває конкретний спосіб зламати шари. Приклади на Pest 5 (у проєкті pestphp/pest: ^5.1, PHP 8.4), але API архітектурних тестів однакове починаючи з Pest 2.
1. Порт - це інтерфейс, і він лежить в Application
arch('ports are interfaces')
->expect('PhpUkraine\Hiring\Application\Port')
->toBeInterfaces();
Ловить це правило абстрактний клас із половиною реалізації всередині. Коли JobRepository перестає бути інтерфейсом і стає abstract class з кешем у полі, підмінити його фейком у тесті вже не вийде без успадкування, і фейк починає тягнути ту саму логіку, яку мав би замінити. Правило дешеве, падає рідко, але падає рівно в момент, коли хтось збирається зрізати кут.
Друга половина правила стосується розташування. Порт, оголошений поруч із реалізацією в Infrastructure, формально інтерфейс, а фактично частина інфраструктури:
arch('infrastructure declares no interfaces of its own')
->expect('PhpUkraine\Hiring\Infrastructure')
->interfaces()
->toBeUsedInNothing();
2. Eloquent-моделі не виходять за межі Infrastructure
arch('eloquent models stay behind repositories')
->expect('App\Models')
->toOnlyBeUsedIn([
'App\Models',
'PhpUkraine\Hiring\Infrastructure',
'PhpUkraine\Content\Infrastructure',
'Database\Factories',
'Database\Seeders',
]);
З усього набору це правило приносить найбільше користі, і працює воно інакше, ніж «домен не знає про Illuminate». Там перевірка йде зсередини домену назовні, тут навпаки, з боку моделі всередину. Різниця помітна на типовому витоку: App\Models\Job не належить до неймспейсу Illuminate, тож класична заборона його не побачить. Use case приймає Job $job, потім хтось у контролері робить $job->company->name, і ви отримали ліниве завантаження в шарі, який мав бути чистим.
toOnlyBeUsedIn формулює правило один раз для всіх нинішніх і майбутніх контекстів. Якщо з'явиться src/Analytics, модель туди не пролізе, поки хтось явно не допише рядок у список, а це вже свідоме рішення, а не випадковість.
3. Контекст бачить лише себе і Shared
Чорний список («не використовуй Infrastructure») ловить те, про що ви вже подумали. Білий список ловить решту:
arch('hiring does not depend on other contexts')
->expect('PhpUkraine\Hiring')
->toOnlyUse([
'PhpUkraine\Hiring',
'PhpUkraine\Shared',
'Illuminate',
'Psr',
]);
Від чого захищає: від циклів між контекстами. Hiring тягне Content, щоб показати статті в картці вакансії, Content тягне Hiring, щоб порахувати згадки, і модульний моноліт тихо перетворюється на звичайний. Жоден із чотирьох базових layer-тестів цього не побачить, бо обидва імпорти цілком «легальні» за шарами.
Перший запуск такого правила дає довгий список порушень, і це нормально: там будуть і справжні зв'язки контекстів, і Symfony\Component\* з якоїсь бібліотеки, і власні винятки. Список і є планом. Дозволяти щось у ньому варто по одному рядку з коментарем, чому саме цей неймспейс тут доречний.
4. Value object - final і readonly
arch('value objects are immutable')
->expect('PhpUkraine\Hiring\Domain\Job\ValueObject')
->toBeFinal()
->toBeReadonly();
Це страховка від $salary->setAmount(), який з'явиться рівно тоді, коли комусь швидше змінити об'єкт, ніж створити новий. readonly класи (PHP 8.2+) роблять це неможливим на рівні мови, final не дає обійти інваріанти через успадкування. Правило корисне саме тим, що на рев'ю про це сперечатись нецікаво: або клас final readonly, або CI червоний.
Дрібниця з практики: toBeReadonly() вимагає, щоб клас був оголошений readonly, а не просто мав readonly-поля. Це різні речі, і друга не заважає додати мутабельне поле наступним комітом.
5. Годинник і випадковість живуть в одному місці
arch('time and randomness go through explicit ports')
->expect(['time', 'microtime', 'date', 'mktime', 'rand', 'mt_rand', 'uniqid'])
->toOnlyBeUsedIn([
'PhpUkraine\Shared\Clock',
'PhpUkraine\Shared\Random',
]);
Крім класів, Pest уміє перевіряти й окремі функції, і тут ця можливість закриває справжній біль. Мотив суто практичний: тести, які падають раз на тиждень і які команда просто звикає перезапускати. Коли time() дозволено рівно в двох класах, для підміни часу в тесті достатньо передати FrozenClock, замість того щоб шукати, де саме код підглянув у системний годинник. Практична частина цієї теми розібрана окремо: час, випадковість і зовнішні API в тестах.
Список функцій можна доповнювати: random_int, shuffle, array_rand. Головне тримати його чесним. Якщо uniqid() у трьох місцях інфраструктури вас влаштовує, додайте ці місця у виняток явно, а не видаляйте функцію з правила.
6. Винятки домену не тягнуть інфраструктуру
arch('domain exceptions share one base')
->expect('PhpUkraine\Hiring\Domain\Exception')
->toExtend('PhpUkraine\Shared\Exception\DomainException')
->toHaveSuffix('Exception');
arch('database exceptions never leave infrastructure')
->expect('Illuminate\Database')
->toOnlyBeUsedIn([
'PhpUkraine\Hiring\Infrastructure',
'PhpUkraine\Content\Infrastructure',
'App',
]);
Від чого захищає: від catch (QueryException $e) у контролері. Це виглядає як обробка помилки, а насправді означає, що деталь зберігання просочилася крізь два шари і тепер веб-шар знає про унікальні індекси. Спільна база для доменних винятків дає одну точку, де їх ловити в HTTP-шарі, і робить видимим момент, коли хтось кидає з домену голий \RuntimeException.
Суфікс у тому самому ланцюжку виглядає косметикою, але саме він не дає назвати клас JobNotFound і сховати його серед агрегатів.
7. Unit-тести не ходять у базу
arch('unit tests run without the database')
->expect([
'Illuminate\Foundation\Testing\RefreshDatabase',
'Illuminate\Foundation\Testing\DatabaseTransactions',
])
->not->toBeUsedIn('Tests\Unit');
Тестовий код теж код, і він деградує швидше за продакшен. Типова історія: unit-тест на use case з фейками, потім у сусідній кейс додають один RefreshDatabase, бо «так простіше перевірити», і через півроку tests/Unit виконується півтори хвилини. Правило тримає межу, яку всі вважають очевидною і ніхто не перевіряє. Поруч добре працює заборона sleep у всьому сьюті: expect('sleep')->not->toBeUsed().
Чого охороняти не варто
Pest дозволяє перевіряти кількість рядків у класі, наявність конструктора, префікси методів. Спокуса зробити з arch-тестів лінтер сильна, але ці правила майже не ловлять поломок архітектури: вони ловлять стиль, і для стилю є Pint і PHPStan, які роблять це швидше й точніше.
Готові пресети (arch()->preset()->php(), ->security(), ->laravel()) дають розумний базовий рівень і не знають нічого про ваші шари. Вмикати їх можна, але жодне з семи правил вище вони не замінюють.
Критерій відбору один: правило варте рядка в сьюті, якщо ви можете назвати конкретну поломку, яку воно ловить, і ця поломка вже траплялась у вашому коді або в коді, який ви читали. Правило «про всяк випадок» через рік перетвориться на ->ignoring() з п'ятьма неймспейсами, і його однаково доведеться видалити.
Впроваджувати краще по одному: додали правило, подивились на список порушень, вирішили, чи чинити код, чи саме правило, і тільки після зеленого CI берете наступне. Сім правил за сім підходів, а не сім за один вечір, бо інакше ви отримаєте сотню падінь і бажання позначити весь файл як todo.