Двадцятихвилинний CI ламає не швидкість, а звичку. Розробник відкриває PR, перемикається на іншу задачу, повертається через годину. Ревʼю розтягується на день, а «маленький фікс до релізу» мержать, не дочекавшись білда. Тому прискорення пайплайна — це не про красиві цифри в бейджі, а про те, чи люди взагалі дивляться на результат.
Інстинктивна реакція — «треба переписати тести» або «додаймо паралель». Обидві дорогі, і обидві зазвичай не там, де проблема. Нижче — порядок, у якому варто діяти: спершу те, що коштує нуль зусиль і нічого не ламає, і лише в кінці те, що вимагає правити самі тести.
Спершу розбийте 20 хвилин на частини
Ніхто не прискорює те, чого не бачив. Потрібні дві цифри: скільки триває джоб цілком і скільки з того займає власне запуск тестів. У GitHub Actions тривалість кожного кроку видно в логах джоба — випишіть її для checkout, setup-php, composer install, npm ci, збірки ассетів, міграцій і тестів. Дуже часто виявляється, що до першого assert пайплайн живе більшу частину свого часу.
Далі — усередину сьюта:
php artisan test --profile # 10 найповільніших тестів
vendor/bin/pest --profile
vendor/bin/phpunit --log-junit build/junit.xml
--profile дає швидку відповідь, JUnit-XML — повну: у ньому час кожного тесту, і його можна згрупувати за файлами чи теками. Важлива не сама верхівка списку, а форма розподілу. Якщо десять тестів зʼїдають половину часу — це точкова проблема, її ріжуть локально. Якщо профіль плаский і тисячі тестів по кілька сотень мілісекунд — точкових виправлень не буде, працює лише спільна вартість одного тесту (буткап фреймворку, база, хешування) і паралель.
Найдешевше: Xdebug, кеші, ассети
Xdebug. Найчастіша безкоштовна перемога. Якщо розширення просто завантажене в CI, кожен тест платить за це, навіть коли покриття ніхто не збирає. У shivammathur/setup-php це одне поле:
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
Якщо PHP їде з власного Docker-образу, де Xdebug вкомпільовано, вимкніть його змінною XDEBUG_MODE=off. Покриття потрібне? Збирайте його через pcov і в окремому джобі, який не блокує мерж, — тоді повільний драйвер не стоїть на критичному шляху.
Залежності. Кешуйте не vendor/, а composer-кеш: він переживає зміну версії PHP і не тягне за собою битих автолоадів.
- id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: composer-${{ hashFiles('composer.lock') }}
Для npm достатньо actions/setup-node з cache: npm.
Ассети. Окреме питання — чи потрібен npm run build перед тестами взагалі. Він потрібен лише тоді, коли Blade рендерить @vite(...), інакше Laravel кине ViteException про відсутній маніфест. Якщо тести перевіряють розмітку, а не бандл, вимкніть Vite у базовому TestCase:
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
}
Ціна чесна: ви більше не перевіряєте, що збірка взагалі проходить. Тому збірку варто лишити — але окремим джобом, паралельно до тестів, а не перед ними.
База даних: де насправді горить час
RefreshDatabase мігрує базу один раз на процес, а кожен тест загортає в транзакцію і відкочує. Тобто міграції — не «на кожен тест», але з ростом проєкту разовий прогін перетворюється на сотні DDL-запитів, помножені на кількість паралельних процесів. Лікується дампом схеми:
php artisan schema:dump --prune
Після цього Laravel вантажить database/schema/{connection}-schema.sql замість того, щоб програвати всю історію міграцій. --prune видаляє старі файли міграцій — якщо це для вас надто радикально, робіть дамп без нього.
Другий важіль — не платити за базу там, де вона не потрібна. LazilyRefreshDatabase мігрує лише тоді, коли тест реально торкнувся підключення:
uses(Illuminate\Foundation\Testing\LazilyRefreshDatabase::class)->in('Feature');
Для юніт-тестів, які працюють через порти й фейки, база не піднімається взагалі.
Третій — вибір драйвера. SQLite в памʼяті (DB_CONNECTION=sqlite, DB_DATABASE=:memory:) прибирає мережевий раунд-тріп і підняття сервісного контейнера. Але це інша СУБД: інакше поводяться JSON-функції, повнотекстовий пошук, блокування, суворість типів. Робоче правило: швидкий цикл на SQLite, і хоча б один джоб на тій самій СУБД, що в проді. Якщо ваші запити нетривіальні, SQLite буде брехати саме там, де вам потрібна правда.
Дрібниці, які множаться на кількість тестів
Кілька налаштувань у phpunit.xml, які часто забувають:
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="MAIL_MAILER" value="array"/>
BCRYPT_ROUNDS — найпомітніший: bcrypt за замовчуванням рахує 12 раундів, і кожна фабрика користувача з паролем платить цю ціну повністю. У Laravel 11 і новіших драйвер кешу задається змінною CACHE_STORE, у старіших — CACHE_DRIVER; звірте з config/cache.php, щоб не поставити змінну, яку ніхто не читає.
Окремо — реальний час і реальна мережа. sleep() усередині логіки повторних спроб виконується в тестах так само чесно, як у проді, а стороннє HTTP-зʼєднання, яке ніхто не замокав, спокійно чекає таймауту. Обидва випадки закриваються в базовому TestCase:
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Sleep;
protected function setUp(): void
{
parent::setUp();
Http::preventStrayRequests();
Sleep::fake();
}
preventStrayRequests() перетворює забутий зовнішній виклик із повільного таймауту на миттєвий виняток — це і швидше, і чесніше.
Паралель — останньою, не першою
php artisan test --parallel (під капотом brianium/paratest) розкидає тести по процесах і створює для кожного окрему базу з суфіксом _test_1, _test_2 тощо. Прапорець --recreate-databases перестворює їх, коли схема змінилася, а хуки ParallelTesting::setUpProcess() дозволяють доналаштувати кожен процес.
Чому це останній крок, а не перший: паралель не прискорює тести — вона робить видимим увесь спільний стан, який досі рятувала послідовність. Тести, що пишуть в один файл, беруть один ключ кешу, покладаються на фіксовані ID або на порядок виконання, починають падати випадково. Якщо ввімкнути паралель до того, як прибрано Xdebug і полагоджено базу, ви заплатите flaky-тестами за виграш, який могли отримати безкоштовно.
Поряд із паралеллю всередині сьюта працює паралель на рівні джобів: статичний аналіз, Pint і збірка ассетів не мають стояти в черзі за тестами. PHPStan при цьому варто зробити інкрементальним — його result cache лежить у tmpDir (задається в phpstan.neon), і якщо цю теку кешувати між прогонами, повторний аналіз перевіряє лише змінене.
Порядок дій
- Виміряти: тривалість кожного кроку джоба плюс
--profileі--log-junitдля сьюта. - Вимкнути Xdebug у джобі, який блокує мерж; покриття — окремо, на pcov.
- Закешувати composer- і npm-кеші; винести збірку ассетів у паралельний джоб або вимкнути Vite у тестах.
schema:dump,LazilyRefreshDatabase, ревізія сідерів.BCRYPT_ROUNDS, драйвери в памʼяті,Sleep::fake(),Http::preventStrayRequests().- І лише тепер
--parallel— разом із готовністю ловити перші flaky-тести.
Після кожного кроку міряйте заново: вузьке місце зміщується, і те, що на початку виглядало як «повільні тести», після третього пункту цілком може виявитись повільним composer install. Двадцять хвилин майже ніколи не одна проблема — це три-чотири дрібні, які склалися.