14. Тестування. PHPUnit або Pest. Тестування Laravel Action. Test coverage.

Відео версія
(YouTube - для зворотнього зв'язку)

TL;DR

Набір порад по роботі з тестами. Деякі поради відносяться саме до Action підходу.

Підтримка - НАБАГАТО дорожча, ніж розробка. Розробка з тестами - це +20% до часу. Підтримка без тестів - це кіт у мішку.

Пишіть тести. Навіть якщо робите MVP. Якщо ви найнятий розробник - MVP переписати, скоріше за все, не дадуть. Зате змусять підтримувати той ASAP код аж до смерті проєкту, або ж до звільнення. А це зазвичай основний core код.

Pest vs PHPUnit - це питання синтаксису і все. Не важливо, що обрати для проєкту. Але, будь ласка, використовуйте щось одне. Розробник не повинен кожен раз робити вибір.

Тести - це також код. Їх також треба підтримувати. Тому правила code style повинні підтримуватися. І стандарти code complexity також!

Test coverage - не ідеальний показник, але він ігрофікує розробку - раджу використовувати.

Feature в Laravel - це інтеграційні тести - всі тести, що тестують кінцевий метод. $this->get(), $this->post(), $this->getJson(), $this->postJson()...

Unit test - тест конкретного класу: action, filter, controller, form і так далі.

Ніколи не тестуйте приватні методи. Навіть якщо знайшли бібліотеки для цього.

Ніколи не використовуйте in-memory database (sqlite). Завжди тестуйте в тих DB, з якими працюєте, з тими ж версіями!

Для Action не бійтесь додавати декоратори, якщо це спростить тестування. Наприклад, для складних AsController - тестуйте доступ до методу в feature тестах, далі додайте AsObject (навіть якщо не використовується як об'єкт) і протестуйте реалізацію в unit тесті.

AsFake - дуже корисний декоратор. Mock Action зробить тестування Action, що використовують інші Action, дуже комфортним. Але це працює у випадку, якщо кожна Action протестована. І взагалі mock дуже корисна штука.

"Один тест - одна умова" - просте правило, яке дозволить зробити ваші тести дійсно лаконічними.

Тестуйте один функціонал за раз. Не треба викликати метод збереження даних перед тим, як їх отримувати. Використовуйте фабрики та mocks.

Тести не заміняють Manual QA.

Test DB in docker

Не використовуйте in-memory DB. Це або обмежить вам можливості використовувати особливості основної БД, або змусить писати безліч специфічних умов.

У докері створити базу даних для тестів займе 5 хвилин.

docker-compose.yml
db:
  image: &db-image postgis/postgis:16-3.4-alpine
  restart: unless-stopped
  container_name: bh-db
  environment:
    POSTGRES_DB: ${DB_DATABASE}
    POSTGRES_USER: ${DB_USERNAME}
    POSTGRES_PASSWORD: ${DB_PASSWORD}
  ports:
    - 5432:5432
  volumes:
    - db_data:/var/lib/postgresql/data
  networks:
    - bh-db-network
  healthcheck:
    test: pg_isready -U ${DB_USERNAME} -d ${DB_DATABASE}
    interval: 3s
    timeout: 10s
    retries: 3

db-test:
  image: *db-image
  container_name: bh-db-test
  command: postgres -c 'max_connections=250' -c 'max_locks_per_transaction=128'
  restart: unless-stopped
  environment:
    POSTGRES_DB: test_bh_db
    POSTGRES_USER: test_bh_user
    POSTGRES_PASSWORD: test_bh_user_password
  networks:
    - bh-db-network
  • &db-image - посилання на image версію основної бази даних. *db-image - використання тієї ж версії, що і за посиланням.
  • POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD - можна хардкодити, ваші тестові дані навряд чи кому цікаві, і вони ізольовані в контейнері.
  • command: postgres -c 'max_connections=250' -c 'max_locks_per_transaction=128' - розширюємо кількість одночасних підключень до Postgres БД та кількість транзакцій. Важливо для комфортного запуску паралельних тестів.

Laravel config

phpunit.xml
<source>
  <include>
    <directory>./Modules/</directory>
  </include>
  <exclude>
    <directory>./Modules/**/Routes</directory>
    <directory>./Modules/**/Tests</directory>
    <directory>./Modules/**/Database</directory>
  </exclude>
</source>
<php>
  <env name="LOG_CHANNEL" value="stderr"/>
  <env name="DB_CONNECTION" value="pgsql"/>
  <env name="DB_HOST" value="bh-db-test"/>
  <env name="DB_DATABASE" value="test_bh_db"/>
  <env name="DB_PORT" value="5432"/>
  <env name="DB_USERNAME" value="test_bh_user"/>
  <env name="DB_PASSWORD" value="test_bh_user_password"/>
</php>
  • У секції php налаштування БД та LOG_CHANNEL - тести пишуть у ваші log за замовчуванням.
  • У секції source - директорії для сканування test coverage. Додавайте та виключайте директорії звідти - в залежності від структури проєкту.
tests/TestCase.php
use RefreshDatabase;

private bool $dropTypes = true;
  • RefreshDatabase - кожен тест проходить у транзакції, яка скасовується в кінці тесту. Найпродуктивніший спосіб ізолювати тести один від одного.
  • dropTypes - видаляє кастомні типи даних при міграції, для Postgres обов'язково.
  • Якщо використовуєте view, встановіть dropViews до true.

Test coverage setup

Test coverage - важка операція і вимагає багато часу. Не треба її встановлювати на CI/CD. Але локально слід дозволити перевірку. Вона допоможе розробнику, нагадавши, що щось не протестовано.

XDebug.

Swoole не працює з Xdebug. Дивним чином падає та конфліктує !

dev.Dockerfile
COPY php/xdebug.ini /usr/local/etc/php/conf.d

RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS linux-headers autoconf linux-headers && \
pecl install -o -f xdebug && \    
apk del .build-deps

В ini активуйте Xdebug з coverage mode

xdebug.ini
xdebug.mode=coverage

Pcov

dev.Dockerfile
COPY php/pcov.ini /usr/local/etc/php/conf.d

RUN apk add --no-cache --virtual .build-deps $PHPIZE_DEPS linux-headers && \
pecl install pcov && docker-php-ext-enable pcov && \
apk del .build-deps

В ini активуйте pcov та вкажіть root directory

pcov.ini
pcov.enabled = 1
pcov.directory = /var/www

CI/CD

Раджу встановити запуск тестів як команду composer. Це дасть змогу додати команду до запуску тестів.

composer.json
{
  "scripts": {
    "test": [
      "@php artisan config:clear",
      "@php artisan test --parallel --stop-on-failure --stop-on-error"
    ]
  }
}

Також - запускайте тести на pre-push git hook. Це зменшить навантаження на build сервер. А насправді це комфорт розробників, які будуть знати, що їхні проколи не бачить лідер :)

.husky/pre-push
docker exec -t bh-app npx validate-branch-name
docker exec -t bh-app composer test

І обов'язково як CI/CD степ.

Тут популярний варіант розбити CI/CD на різні етапи. Перший етап - встановлення залежностей. На другому етапі - паралельно запускаємо тести та static analysis в різних jobs. Це звучить ок, але на практиці composer залежностей зазвичай до 100 MB. А Docker контейнер понад 100 MB. І "економлячи" час на завантаженні залежностей, ми як мінімум розгортаємо Docker 3 рази, і контейнери в ньому мінімум 2 рази (передати як кеш або артефакт їх не вдасться). Тому рекомендую проганяти і тести, і статичний аналіз в одній job. Це не так красиво, але набагато продуктивніше.

.gitlab-ci.yml
stages:
  - test

default:
  image: docker:26.1.3-alpine3.19
  services:
    - docker:26.1.3-dind

test:
  stage: test
  before_script:
    - cp .env.example .env
    - echo UID=$(id -u) >> .env
    - echo GID=$(id -g) >> .env
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker compose up app db-test -d --no-deps
    - docker-compose exec app composer config -- gitlab-token.gitlab.com gitlab-ci-token "${CI_JOB_TOKEN}"
    - docker compose exec app composer install --no-scripts
    - docker compose exec app php artisan key:generate
  script:
    - docker compose exec app php artisan insights --no-interaction
    - docker compose exec app vendor/bin/phpstan
    - docker compose exec app php artisan migrate --seed --env=test
    - docker compose exec app php artisan migrate:refresh --env=test
    - docker compose exec app composer test

Тут зверніть увагу на migration команди. Цього ви не знайдете в рекомендаціях, але особисто я раджу. Така перевірка одразу покаже міграції зі зламаним down методом (якщо використовуєте). І перевірить seed.

Одна проблема - запускати треба в окремому .env файлі з мінімальною конфігурацією (підключення до БД). Обов'язково не називайте його testing - бо доведеться налаштовувати всі конфіги (для тестів власне).

.env.test
APP_NAME=Laravel
APP_ENV=test
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost

DB_CONNECTION=pgsql
DB_HOST=bh-db-test
DB_PORT=5432
DB_DATABASE=test_bh_db
DB_USERNAME=test_bh_user
DB_PASSWORD=test_bh_user_password

Code Code Code