مقاله

Test Coverage و CI در Laravel — PHPUnit، Pest و GitHub Actions

Test Coverage و CI در Laravel — PHPUnit، Pest و GitHub Actions

Xdebug coverage، حداقل درصد معقول، گزارش در CI و pipeline تست خودکار با GitHub Actions.

تست بدون CI روی لپ‌تاپ developer می‌ماند. Coverage هم عدد راهنماست نه هدف — اما در PR نشان می‌دهد کدام بخش بدون تست merge شده. این مقاله pipeline عملی Laravel + Pest/PHPUnit + GitHub Actions را می‌چیند.

خلاصه: «تست‌ها پاس شدند» یعنی رفتارهایی که تست کرده‌اید درست کار می‌کنند؛ «coverage» فقط نشان می‌دهد هنگام اجرای تست‌ها کدام خطوط کد اجرا شده‌اند. برای coverage یک driver مثل Xdebug یا PCOV لازم است. در CI بهتر است شکست تست و شکست حد coverage را جدا ببینید، خروجی خطا را نگه دارید و مطمئن شوید تست‌ها هرگز به دیتابیس واقعی وصل نمی‌شوند.

پاس شدن تست در برابر coverage

  • تستی که هیچ assertion معناداری ندارد هم می‌تواند coverage را بالا ببرد؛ اجرا شدن یک خط به معنی درست بودن آن نیست.
  • coverage بالا تضمین نمی‌کند حالت‌های مرزی، خطاها و دسترسی‌ها تست شده باشند.
  • coverage پایین در یک بخش، نقشهٔ جاهایی است که هیچ تستی از آن‌ها عبور نمی‌کند — مفیدترین کاربرد گزارش همین است.

اجرای coverage محلی

# PCOV سریع‌تر از Xdebug برای coverage
php -d pcov.enabled=1 ./vendor/bin/pest --coverage

# حداقل درصد
php -d pcov.enabled=1 ./vendor/bin/pest --coverage --min=70

نصب PCOV یا Xdebug در php.ini محیط dev.

دستورهای بالا فقط وقتی کار می‌کنند که Pest در پروژه نصب باشد (عدد 70 هم فقط مثال است). اگر پروژه فقط PHPUnit دارد، معادلش این است:

# گزارش متنی در ترمینال از طریق artisan
php artisan test --coverage

# مستقیم با PHPUnit و خروجی برای ابزارهای دیگر
./vendor/bin/phpunit --coverage-text
./vendor/bin/phpunit --coverage-html coverage-html
./vendor/bin/phpunit --coverage-clover coverage.xml

پیش‌نیاز driver

  • PCOV: فقط برای coverage ساخته شده؛ باید extension نصب و فعال باشد.
  • Xdebug 3: باید حالت coverage فعال باشد، مثلاً با XDEBUG_MODE=coverage یا xdebug.mode=coverage در php.ini.

اگر driver نباشد، php artisan test --coverage با پیام Code coverage driver not available متوقف می‌شود و PHPUnit هم هشدار «No code coverage driver available» می‌دهد. برای اطمینان، php -m را اجرا کنید و دنبال pcov یا xdebug بگردید.

خواندن گزارش

در گزارش متنی برای هر کلاس درصد خطوط (Lines)، متدها (Methods) و کلاس‌ها (Classes) آمده است. به‌جای عدد کل، دنبال این‌ها باشید: کلاس‌های مهم کسب‌وکار با coverage صفر، متدهایی که فقط مسیر موفق‌شان اجرا شده و بخش‌هایی مثل پرداخت یا دسترسی که هیچ تستی ندارند. گزارش HTML خطوط اجرا‌نشده را رنگی نشان می‌دهد و برای این بررسی راحت‌تر است.

phpunit.xml تنظیم coverage

<source>
    <include>
        <directory>app</directory>
    </include>
    <exclude>
        <directory>app/Providers</directory>
    </exclude>
</source>

Provider و route list معمولاً exclude — تمرکز روی domain.

چند درصد کافی است؟

  • عدد جهانی درستی وجود ندارد؛ حد مناسب به ریسک کد، سن پروژه و نوع تست‌ها بستگی دارد.
  • برای پروژهٔ قدیمی بدون تست، حد بالا از روز اول فقط pipeline را قرمز نگه می‌دارد. اول coverage فعلی را اندازه بگیرید و حد را کمی پایین‌تر از آن بگذارید تا فقط از افت جلوگیری کند.
  • بخش‌های پرریسک (پرداخت، دسترسی، محاسبات مالی) را با تست هدفمند پوشش دهید، نه با بالا بردن عدد کل.
  • ۱۰۰٪ هدف معمولاً waste — کیفیت تست مهم‌تر

اگر حد coverage باعث شود تیم تست‌های بی‌assertion بنویسد تا عدد بالا برود، آن حد به کیفیت ضرر زده است. می‌توانید مدتی coverage را فقط گزارش کنید و بدون حد اجباری پیش بروید.

GitHub Actions — workflow پایه

name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, pdo_mysql
          coverage: pcov

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist

      - name: Copy env
        run: cp .env.example .env

      - name: Generate key
        run: php artisan key:generate

      - name: Run tests
        run: php artisan test --parallel --coverage --min=70

php artisan test همان Pest/PHPUnit — Laravel 12.

این workflow یک نقطهٔ شروع است، نه نسخهٔ آماده برای هر پروژه. قبل از استفاده این‌ها را با پروژهٔ خودتان تطبیق دهید:

  • نسخهٔ PHP: php-version را با محدودهٔ require.php در composer.json و نسخهٔ production یکی کنید.
  • extensionها: هر extensionی که پروژه لازم دارد (مثلاً gd، intl یا pdo_sqlite) را در لیست بیاورید.
  • --parallel: فقط با پکیج brianium/paratest در require-dev کار می‌کند؛ بدون آن artisan خطای پیش‌نیاز ParaTest می‌دهد.
  • driver coverage: مقدار coverage: pcov در setup-php، PCOV را در runner نصب می‌کند؛ اگر coverage لازم ندارید، coverage: none بگذارید.
  • Vite: اگر viewها از @vite استفاده می‌کنند و manifest بیلدشده در repo نیست، یا در CI npm ci و npm run build اجرا کنید یا در تست‌ها $this->withoutVite() بزنید.

جدا کردن شکست تست از شکست coverage

در php artisan test --coverage --min=… حد coverage فقط وقتی بررسی می‌شود که همهٔ تست‌ها پاس شده باشند. پس دو نوع شکست، خروجی متفاوت دارند:

  • شکست تست: نام تست‌های FAILED، پیام assertion و stack trace در خروجی می‌آید. یعنی رفتار کد خراب است.
  • شکست coverage: همهٔ تست‌ها پاس شده‌اند و آخر خروجی پیام Code coverage below expected همراه درصد فعلی و حداقل آمده. یعنی کد درست کار می‌کند ولی تست‌ها بخش کمتری از کد را اجرا می‌کنند.

برای خواناتر شدن pipeline می‌توانید دو step جدا بگذارید: یک step برای اجرای تست‌ها با گزارش JUnit و step بعدی برای گزارش یا حد coverage. هزینه‌اش این است که تست‌ها دو بار اجرا می‌شوند؛ اگر زمان CI برایتان مهم است، یک step با --min و خواندن دقیق پیام آخر کافی است.

      - name: Run tests
        run: ./vendor/bin/phpunit --log-junit junit.xml

      - name: Coverage report
        run: ./vendor/bin/phpunit --coverage-clover coverage.xml --coverage-text

      - name: Upload logs on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: test-logs
          path: |
            junit.xml
            storage/logs/*.log

step آخر با if: failure() فقط وقتی اجرا می‌شود که stepهای قبلی شکست خورده باشند و فایل‌های لاگ را برای بررسی نگه می‌دارد.

MySQL در CI

services:
  mysql:
    image: mysql:8.0
    env:
      MYSQL_DATABASE: testing
      MYSQL_ROOT_PASSWORD: password
    ports:
      - 3306:3306

.env.testing با DB testing — یا SQLite :memory: برای سرعت.

ایمنی env و دیتابیس تست

  • متغیرهای <env> در phpunit.xml (مثل APP_ENV=testing و DB_DATABASE) را صریح بنویسید تا تست‌ها هیچ‌وقت به دیتابیس .env اصلی وصل نشوند. تست‌هایی که RefreshDatabase دارند جداول را پاک و دوباره می‌سازند.
  • اگر روی سیستم خودتان php artisan config:cache زده‌اید، config کش‌شده می‌تواند env تست را نادیده بگیرد؛ قبل از تست php artisan config:clear بزنید.
  • در CI هرگز اطلاعات اتصال دیتابیس یا سرویس‌های production را قرار ندهید؛ secretهای لازم را از GitHub Secrets بخوانید و echo نکنید.

برای نحوهٔ کار env و فایل‌های .env.*، راهنمای تنظیمات env در Laravel را ببینید.

گزارش coverage در PR

Actionهای third-party مثل codecov یا coveralls — upload بعد از test:

- name: Upload coverage
  uses: codecov/codecov-action@v4
  with:
    files: ./coverage.xml

Pest: --coverage-clover coverage.xml

در PHPUnit هم همین گزینه وجود دارد: ./vendor/bin/phpunit --coverage-clover coverage.xml. نسخهٔ action و نیاز به token را از مستندات همان سرویس بررسی کنید.

Pest parallel در CI

php artisan test --parallel --recreate-databases

سریع‌تر — worker جدا. Pest.

اجرای موازی به brianium/paratest نیاز دارد و تست‌هایی را که به ترتیب اجرا یا state مشترک وابسته‌اند آشکار می‌کند. اگر بعد از فعال کردن --parallel تستی تصادفی شکست خورد، احتمالاً مشکل از وابستگی بین تست‌هاست، نه از CI.

شکست pipeline = شکست merge

branch protection: require status check «Tests» قبل از merge.

چه تستی در CI؟

  1. Unit + Feature همه (برای تست درخواست‌های HTTP، Feature test در Laravel)
  2. بدون test skipped بدون دلیل
  3. lint اختیاری: Pint در job جدا

اشتباهات

  • coverage ۱۰۰٪ اجباری — تست بی‌معنی
  • بدون .env.testing — flaky DB
  • secret در workflow log
  • فرض اینکه driver coverage در runner نصب است؛ بدون آن، step coverage شکست می‌خورد در حالی که تست‌ها سالم‌اند.
  • حد coverage بدون اندازه‌گیری وضعیت فعلی پروژه.

تست محلی سبز، CI قرمز

وقتی تست‌ها روی سیستم شما پاس می‌شوند ولی در CI شکست می‌خورند، معمولاً یکی از این تفاوت‌هاست:

  • config کش‌شده یا .env محلی: تست محلی از تنظیماتی استفاده می‌کند که در CI وجود ندارد.
  • حساسیت به حروف بزرگ و کوچک: ویندوز و macOS معمولاً نام فایل را بدون حساسیت به حروف می‌خوانند، ولی runner لینوکسی حساس است؛ Models/post.php در برابر Post در CI پیدا نمی‌شود.
  • extension یا نسخهٔ PHP متفاوت: extensionی که روی XAMPP فعال است در runner نصب نشده.
  • Vite manifest: فایل‌های build روی سیستم شما هست ولی در CI نه.
  • ترتیب تست‌ها: تستی که به داده یا state تست قبلی وابسته است، با ترتیب یا اجرای موازی متفاوت شکست می‌خورد.
  • دیتابیس متفاوت: تست محلی روی SQLite و CI روی MySQL (یا برعکس)؛ تفاوت در collation، نوع ستون یا رفتار foreign key.

اولین قدم همیشه خواندن کامل پیام خطا در لاگ CI است؛ برای بازتولید، همان دستور CI را با همان env محلی اجرا کنید.

جمع‌بندی

CI تضمین regression؛ coverage نقشه blind spot. Mock: Mockery. Doubles: Fakes. Deploy بعدی در DevOps cluster.

ترتیب پیشنهادی: اول تست‌ها را در CI سبز و پایدار کنید، بعد driver coverage را اضافه کنید و گزارش را بخوانید، و فقط وقتی تصویر روشنی از وضعیت فعلی داشتید دربارهٔ حد coverage تصمیم بگیرید.

CI/CD برای پروژه Laravel

اشتراک‌گذاری