<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
ТЕСТУВАННЯ · ЯКІСТЬ КОДУ

Архітектурні тестиArchitecture tests

Тест, який читає код, а не запускає його: хто кого імпортує, чи класи final, чи всюди strict_types. Межі шарів перестають бути домовленістю.

11 хв читання Middle
01 · ЩО ЦЕ

Правило, яке перевіряє збірка

У ARCHITECTURE.md написано: домен не знає про Laravel. Потім хтось поспішає й додає в доменний клас Config::get(). Unit-тести зелені, бо поведінка правильна, рев'ю дивиться на логіку, і межа зникає непомітно. перевіряє саму будову коду: які класи використовує простір імен, чи класи final, як вони названі.

СЛОВНИКАрхітектурний тест. Тест на будову коду, а не на поведінку: залежності між просторами імен, модифікатори класів, назви. Падає з назвою класу, який порушив правило.

У Pest такий тест починається з arch(). Далі expect() з простором імен і : not->toUse(), toOnlyUse(), toOnlyBeUsedIn(), toUseStrictTypes(), а після classes() ще toBeFinal(), toExtend(), toImplement(), toHaveSuffix(). Pest розбирає файли PHP-парсером і нічого не виконує, тож база й контейнер не потрібні. Залежністю він вважає use, повне імʼя класу просто в коді й виклик функції.

СЛОВНИКОчікування. Перевірка в ланцюжку expect(). Для архітектури в Pest є toUse, toOnlyUse, toOnlyBeUsedIn, toBeFinal, toExtend, toImplement, toHaveSuffix, toUseStrictTypes і десятки інших.

Для типових правил є : php забороняє var_dump(), print_r(), die і схожі, security забороняє eval(), exec(), md5(), rand(), unserialize(). Свідомий виняток описують через , а не вимикають правило. Альтернативи поза Pest: і , обидва запускаються окремою командою в CI.

СЛОВНИКПресет. Готовий набір правил: arch()->preset()->php(), security(), laravel(), strict(), relaxed(). Застосовується до просторів імен з autoload у composer.json.
СЛОВНИКignoring(). Виключає з правила клас, простір імен або залежність. Працює і для окремого arch(), і для пресету: ->preset()->security()->ignoring('md5').
СЛОВНИКDeptrac. Окремий інструмент: шари описують у конфігу, а Deptrac перевіряє, що залежності між ними йдуть лише дозволеними напрямками.
СЛОВНИКPHPArkitect. Окремий інструмент з правилами на PHP у власному конфігу. Зручний, коли тести на PHPUnit і Pest у проєкті немає.
02 · МЕЖА

Pest читає код і звіряє залежності з правилом

ДІАГРАМА · УСЯ СХЕМА
Кроки: 4
APP\HIRING\DOMAINexpect()читає файлиLayersTestarch()Pest archPHP-парсер, без запускуЗвіт✗ SalaryRangeJobuse App\Shared\ClockSalaryRangeConfig::get()«interface»App\Shared\ClockфреймворкIlluminate\…\Config
APP\HIRING\DOMAINLayersTestarch()Pest archPHP-парсер, без запускуЗвіт✗ SalaryRangeJobuse App\Shared\ClockSalaryRangeConfig::get()«interface»App\Shared\ClockфреймворкIlluminate\…\Config
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Домен Інфраструктура Клієнт
  • App\Hiring\Domain містить: Job, SalaryRange.
  • LayersTest використовує Pest arch (expect()).
  • Дані йдуть від Pest arch до App\Hiring\Domain.
  • Job використовує App\Shared\Clock.
  • SalaryRange використовує Illuminate\…\Config.
  • Pest arch створює Звіт.
03 · ВАРІАНТИ

Одна межа, пʼять способів її записати

Модуль вакансій: домен у App\Domain, порт JobRepository, Eloquent-реалізація в App\Infrastructure\Persistence, привʼязка в сервіс-провайдері. Перемикайте вкладки: межа та сама, змінюється те, з якого боку на неї дивиться правило.

php HiringTest.php not->toUse
1arch('domain does not use the framework')
2 ->expect('App\Domain')
3 ->not->toUse(['Illuminate', 'Livewire']);
ЩО ПЕРЕВІРЯЄ
Жоден клас App\Domain не згадує класів з префіксами Illuminate чи Livewire. Якщо SalaryRange імпортує Config, Pest відповість: Expecting 'App\Domain' not to use 'Illuminate'.
КОЛИ БРАТИ
Ви знаєте, що саме заборонено: фреймворк, сусідній модуль, інфраструктуру. Найпростіший і найпоширеніший варіант.
php HiringTest.php toOnlyUse
1arch('domain depends only on itself and the shared kernel')
2 ->expect('App\Domain')
3 ->toOnlyUse(['App\Domain', 'App\Shared']);
ЩО ПЕРЕВІРЯЄ
Список дозволеного замість списку забороненого. Вбудовані класи PHP, як DomainException чи DateTimeImmutable, правило не чіпає, а все інше назве: However, it also uses 'Illuminate\Support\Facades\Config'.
КОЛИ БРАТИ
Шар має залежати майже ні від чого, і нова бібліотека в ньому має бути помітною подією. Бонус: одруківка в цьому списку робить тест червоним, а не зеленим.
php HiringTest.php toOnlyBeUsedIn
1arch('only service providers know Eloquent repositories')
2 ->expect('App\Infrastructure')
3 ->toOnlyBeUsedIn('App\Providers');
ЩО ПЕРЕВІРЯЄ
Погляд з іншого боку: хто має право використовувати інфраструктуру. Класи всередині App\Infrastructure можуть брати один одного, а контролер, що просить EloquentJobRepository в аргументі, отримає: Expecting 'App\Infrastructure\Persistence\EloquentJobRepository' not to be used on 'App\Http\JobController'.
КОЛИ БРАТИ
Важливо, щоб до реалізацій ходили лише через інтерфейс, а привʼязку робив контейнер.
php HiringTest.php classes()
1arch('persistence classes are final')
2 ->expect('App\Infrastructure\Persistence')
3 ->classes()
4 ->toBeFinal();
5
6arch('domain ports are implemented by repositories')
7 ->expect('App\Infrastructure\Persistence')
8 ->classes()
9 ->implementing('App\Domain\JobRepository')
10 ->toHaveSuffix('Repository');
ЩО ПЕРЕВІРЯЄ
Будову самих класів. classes() відкидає інтерфейси, трейти й enum, implementing() лишає тільки реалізації порту. Нефінальний JobMapper у тому самому просторі імен дасть Expecting 'app/Infrastructure/Persistence/JobMapper.php' to be final.
КОЛИ БРАТИ
Команда домовилась про форму класів: final за замовчуванням, суфікс за роллю, обовʼязковий інтерфейс.
php HiringTest.php Пресети
1arch()->preset()->php();
2
3arch()->preset()->security()->ignoring('md5');
ЩО ПЕРЕВІРЯЄ
php забороняє дебаг і застарілі функції: dump, var_dump, print_r, die, echo, mysql_*, xdebug_*. dd() у цьому списку немає, тому LayersTest нижче забороняє його окремо. security забороняє eval, exec, shell_exec, unserialize, rand, md5, sha1 та інші. ignoring('md5') знімає один пункт: наприклад, md5 рахує ключ кешу, а не пароль.
КОЛИ БРАТИ
Перший архітектурний тест у проєкті. Два рядки без жодного власного правила вже ловлять забутий var_dump() перед деплоєм.
04 · КОД

Запустіть і подивіться

PHP 8.5
1<?php
2
31// Три файли модуля Hiring. Архітектурний тест читає текст, код не запускає.
4$files = [
5 'src/Hiring/Domain/Job.php' => <<<'PHP'
6 <?php
7
8 declare(strict_types=1);
9
10 namespace App\Hiring\Domain;
11
12 use App\Shared\Clock;
13
14 final class Job
15 {
16 public function publish(Clock $clock): void {}
17 }
18 PHP,
192 'src/Hiring/Domain/SalaryRange.php' => <<<'PHP'
20 <?php
21
22 namespace App\Hiring\Domain;
23
24 final class SalaryRange
25 {
26 public function currency(): string
27 {
28 return \Illuminate\Support\Facades\Config::get('hiring.currency');
29 }
30 }
31 PHP,
32 'src/Hiring/Application/PublishJob.php' => <<<'PHP'
33 <?php
34
35 declare(strict_types=1);
36
37 namespace App\Hiring\Application;
38
39 use App\Hiring\Domain\Job;
40 use App\Hiring\Infrastructure\EloquentJobRepository;
41
42 final class PublishJob
43 {
44 public function __construct(private EloquentJobRepository $jobs) {}
45 }
46 PHP,
47];
48
493// Правила, як у Pest: простір імен і префікси, яких він не має торкатися.
50$rules = [
51 'App\Hiring\Domain' => ['Illuminate', 'App\Hiring\Infrastructure'],
52 'App\Hiring\Application' => ['Illuminate', 'App\Hiring\Infrastructure'],
53];
54
554/**
56 * Простір імен файлу й усі імена класів, які він згадує: з use і повні імена в коді.
57 *
58 * @return array{string, list<array{string, int}>}
59 */
60function dependencies(string $source): array
61{
62 preg_match('/^namespace\s+([\w\\\\]+);/m', $source, $ns);
63 $found = [];
64
65 foreach (explode("\n", $source) as $i => $line) {
66 if (preg_match('/^use\s+([\w\\\\]+)/', $line, $m)) {
67 $found[] = [$m[1], $i + 1];
68 } elseif (preg_match_all('/\\\\([A-Z]\w*(?:\\\\\w+)+)/', $line, $m)) {
69 foreach ($m[1] as $name) {
70 $found[] = [$name, $i + 1];
71 }
72 }
73 }
74
75 return [$ns[1] ?? '', $found];
76}
77
785function startsWith(string $name, string $prefix): bool
79{
80 return $name === $prefix || str_starts_with($name, $prefix.'\\');
81}
82
836$failed = 0;
84
85foreach ($files as $path => $source) {
86 [$namespace, $uses] = dependencies($source);
87
88 if (! str_contains($source, 'declare(strict_types=1);')) {
89 echo "✗ {$path}: немає declare(strict_types=1)\n";
90 $failed++;
91 }
92
93 foreach ($rules as $layer => $forbidden) {
94 if (! startsWith($namespace, $layer)) {
95 continue;
96 }
97
98 foreach ($uses as [$name, $line]) {
99 foreach ($forbidden as $prefix) {
100 if (startsWith($name, $prefix)) {
101 echo "✗ {$path}:{$line}: {$layer} не має використовувати {$name}\n";
102 $failed++;
103 }
104 }
105 }
106 }
107}
108
109echo $failed === 0 ? "✓ Межі шарів цілі\n" : "\nПорушень: {$failed}. Тест червоний, збірка зупиняється.\n";
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 3–18

    Тест працює з текстом файлів, а не з обʼєктами. Тому йому не потрібні ні Laravel, ні база, ні навіть існування класу Config.

  2. 2 рядки 19–31

    SalaryRange не має use, але згадує \Illuminate\...\Config повним іменем просто в коді, і без declare(strict_types=1). Пошук лише по рядках use цього б не помітив.

  3. 3 рядки 49–53

    Правило: простір імен і заборонені префікси. Так само влаштований not->toUse([...]) у Pest.

  4. 4 рядки 55–76

    Збираємо залежності: рядки use і повні імена класів. Pest робить те саме PHP-парсером, тому знаходить ще й виклики функцій.

  5. 5 рядки 78–81

    Префікс порівнюємо до межі сегмента: App\Hiring\Domain не має збігатися з App\Hiring\DomainEvents.

  6. 6 рядки 83–109

    Кожне порушення називає файл, рядок і заборонене імʼя. Червоний тест у CI зупиняє злиття й деплой.

  7. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
05 · ЯК У PHP UKRAINE

LayersTest тримає модульний моноліт

Модулі PHP Ukraine лежать у src/, кожен зі своїми Domain, Application і Infrastructure. Що з чим може говорити, вирішує не рев'ю, а цей файл: він проганяється разом з усіма тестами перед кожним деплоєм. Домен і спільне ядро не бачать Laravel, Livewire й app/, сценарії не бачать інфраструктури, вебшар не бере інфраструктуру Hiring, ContentOps і Projects напряму, у всьому PhpUkraine є strict_types, а dd() і dump() не доживають до продакшну.

php LayersTest.php read-only
1<?php
2
3declare(strict_types=1);
4
5arch('domain layers stay framework-free')
6 ->expect(['PhpUkraine\Hiring\Domain', 'PhpUkraine\ContentOps\Domain', 'PhpUkraine\Seo\Domain', 'PhpUkraine\Search\Domain', 'PhpUkraine\Analytics\Domain', 'PhpUkraine\Content\Domain', 'PhpUkraine\Contact\Domain', 'PhpUkraine\Development\Domain', 'PhpUkraine\Projects\Domain'])
7 ->not->toUse(['Illuminate', 'Livewire', 'App']);← домен без фреймворку
8
9arch('shared kernel stays framework-free')
10 ->expect('PhpUkraine\Shared')
11 ->not->toUse(['Illuminate', 'Livewire', 'App', 'PhpUkraine\Hiring', 'PhpUkraine\ContentOps']);← ядро не знає модулів
12
13arch('application layers do not reach into infrastructure or the web layer')
14 ->expect(['PhpUkraine\Hiring\Application', 'PhpUkraine\ContentOps\Application', 'PhpUkraine\Search\Application', 'PhpUkraine\Analytics\Application', 'PhpUkraine\Contact\Application', 'PhpUkraine\Projects\Application'])
15 ->not->toUse(['PhpUkraine\Hiring\Infrastructure', 'PhpUkraine\ContentOps\Infrastructure', 'PhpUkraine\Projects\Infrastructure', 'App\Http', 'App\Livewire', 'Livewire', 'Illuminate']);← сценарії без інфраструктури
16
17arch('the web layer never touches infrastructure directly')
18 ->expect(['App\Http', 'App\Livewire'])
19 ->not->toUse(['PhpUkraine\Hiring\Infrastructure', 'PhpUkraine\ContentOps\Infrastructure', 'PhpUkraine\Projects\Infrastructure']);← вебшар без інфраструктури
20
21arch('domain code uses strict types')
22 ->expect('PhpUkraine')
23 ->toUseStrictTypes();← strict_types у всьому src/
24
25arch('no debugging leftovers')
26 ->expect(['dd', 'dump', 'ray', 'var_dump'])
27 ->not->toBeUsed();← функції теж залежності
06 · ТИПОВІ ПОМИЛКИ

Як архітектурні тести брешуть

✕ 1

Одруківка в просторі імен

not->toUse('Iluminate') чи expect('App\Domian') проходять зеленими: імені, якого не знає автозавантажувач, Pest просто не знаходить, і порушувати нічого. Тест є, а правила немає.

Натомість: написавши правило, один раз зламайте його навмисно й подивіться на червоний тест. Для шарів, які майже ні від чого не залежать, беріть toOnlyUse: одруківка в його списку робить тест червоним.

✕ 2

Хелпери проходять повз правило про Illuminate

now(), config(), app() і request() це глобальні функції без простору імен. Доменний клас з now() проходить not->toUse('Illuminate'), хоча звʼязок з Laravel той самий, що й через фасад.

Натомість: перелічіть хелпери поруч із простором імен: ->not->toUse(['Illuminate', 'now', 'config', 'app']). Pest вважає виклик функції такою самою залежністю, як use класу.

✕ 3

Новий модуль поза тестом

Правило перелічує простори імен явно, як LayersTest на цьому сайті. Хтось додає модуль Billing, а його Domain не потрапляє в жоден expect(), і межа в ньому не перевіряється.

Натомість: зірочка в імені покриває один сегмент: expect('PhpUkraine\*\Domain') бере домен кожного модуля, і новий модуль потрапляє під правило сам.

✕ 4

Фільтр, що діє лише на перше очікування

expect('App\Shared')->classes()->toBeFinal()->toHaveSuffix('Service'). classes() відсіяв інтерфейси для toBeFinal(), а toHaveSuffix() у тому самому ланцюжку знову бачить усе, і падає на інтерфейсі Clock, якого правило не стосувалося.

Натомість: одне очікування після classes(), implementing() чи extending() на один arch(). Два правила з однаковим expect() читаються не гірше й перевіряють саме те, що написано.

07 · ДЕРЕВО РІШЕНЬ

Чим перевіряти межі у вашому проєкті

1
Чи є у вас правило про залежності, яке можна сказати одним реченням?
Наприклад: домен не імпортує Laravel, контролери не беруть Eloquent-репозиторії напряму.
НІ →Почніть з arch()->preset()->php() і ->security(). Вони ловлять var_dump() і eval() без жодного власного правила.
2
Тести в проєкті пишуть на Pest?
У Pest arch() є одразу: плагін pest-plugin-arch іде залежністю самого Pest.
НІ →Візьміть Deptrac або PHPArkitect. Обидва працюють окремою командою, тож у CI їх ставлять кроком поруч із PHPUnit.
3
Правил небагато, і вони вкладаються у «простір імен A не чіпає B»?
Шари всередині модулів, заборона фреймворку в домені, final для класів.
НІ →Подивіться на Deptrac: шари описують один раз у конфігу, і він перевіряє напрямки залежностей між усіма шарами разом.
ТАК НА ВСІ →Pest arch(). Тест лежить у tests/Architecture, запускається разом з рештою набору й падає з назвою класу.
08 · СЛОВНИК
Архітектурний тест
Тест на будову коду, а не на поведінку: залежності між просторами імен, модифікатори класів, назви. Падає з назвою класу, який порушив правило.
Очікування
Перевірка в ланцюжку expect(). Для архітектури в Pest є toUse, toOnlyUse, toOnlyBeUsedIn, toBeFinal, toExtend, toImplement, toHaveSuffix, toUseStrictTypes і десятки інших.
Пресет
Готовий набір правил: arch()->preset()->php(), security(), laravel(), strict(), relaxed(). Застосовується до просторів імен з autoload у composer.json.
ignoring()
Виключає з правила клас, простір імен або залежність. Працює і для окремого arch(), і для пресету: ->preset()->security()->ignoring('md5').
Deptrac
Окремий інструмент: шари описують у конфігу, а Deptrac перевіряє, що залежності між ними йдуть лише дозволеними напрямками.
PHPArkitect
Окремий інструмент з правилами на PHP у власному конфігу. Зручний, коли тести на PHPUnit і Pest у проєкті немає.
09 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з тестування →

Unit-тест запускає код і перевіряє результат. Архітектурний тест код не запускає: він читає файли й перевіряє будову, тобто хто кого імпортує, чи клас final, чи є strict_types. Поведінка може бути правильною, а межа шарів уже порушеною, і саме це він ловить.

not->toUse це чорний список: заборонено Illuminate, решту можна. toOnlyUse це білий список: можна лише перелічене, а будь-яка нова залежність робить тест червоним. Білий список строгіший і краще переживає одруківки, зате його доводиться оновлювати щоразу, коли шар законно отримує нову залежність.

now() це глобальна функція, у неї немає простору імен Illuminate, тож префікс не збігається. Pest рахує виклики функцій залежностями, тому досить додати їх у список: ->not->toUse(['Illuminate', 'now', 'config']). А краще передавати час через інтерфейс Clock, тоді й правило, і тест домену простіші.

Не чекати, доки все виправлять. Правило вмикається одразу, а наявні порушники йдуть у ->ignoring([...]) явним списком. Тоді нові класи перевіряються з першого дня, а список винятків видно в рев'ю, і його скорочують разом з рефакторингом. Те саме робить baseline у PHPStan для типів.