404 Not Found

404 Not Found


nginx

الاختبار الآلي في Laravel

الاختبارات هي "شبكة الأمان" في Laravel — تشغيلها مع كل تغيير في الكود يضمن اكتشاف أي أخطاء فورًا وعدم وصولها إلى بيئة الإنتاج.

1. ما ستتعلمه


2. قصة حقيقية لمطور في نوبة ليلية

(1) المشكلة: أخطاء تظهر في كل مرة ننشر فيها

نشر Bob نسخة جديدة من ShopMetrics ليلة الجمعة — غيّر منطق خصم الطلبات، واختبر يدويًا بعض السيناريوهات للتأكد من أن كل شيء يعمل، ثم أطلقه. وتبين أنه صباح الإثنين، أبلغت Alice أن الخصومات السالبة تسببت في إجمالي الطلبات السالب، مما أدى إلى حصول أكثر من 100 مستخدم على منتجاتهم مجانًا. قال Charlie: "لو كانت لديك اختبارات آلية، لكان هذا الخطأ قد اكتُشف عند إرسال الكود."

(2) حلول الاختبار الآلي

الاختبارات الآلية تعمل تلقائيًا مع كل تغيير في الكود — منطق الخصم مغطى بالكامل بالاختبارات، وحالات اختبار الخصومات السالبة تفشل فورًا، مما يضمن عدم وصول الأخطاء إلى الإنتاج.

PHP
// اختبار أن الخصم السالب مرفوض
test('order rejects negative discount', function () {
    $response = $this->postJson('/api/v1/orders', [
        'items' => [['product_id' => 1, 'quantity' => 1]],
        'discount' => -10,  // يجب أن يُرفض!
    ]);
    $response->assertJsonValidationErrors('discount');
});

(3) النتيجة

بعد أن أضاف Bob الاختبارات، تم اكتشاف خطأ الخصومات السالبة أثناء التطوير، ولم تواجه Alice أبدًا حادثة "التسوق المجاني" مرة أخرى.


3. تهيئة بيئة الاختبار

(1) phpunit.xml

XML
<!-- phpunit.xml -->
<phpunit>
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
        </testsuite>
    </testsuites>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="CACHE_DRIVER" value="array"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
    </php>
</phpunit>

(2) .env.testing

BASH
# .env.testing — بيئة اختبار مخصصة
APP_ENV=testing
APP_KEY=base64:test-key-for-testing-only
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
CACHE_DRIVER=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
MAIL_MAILER=array

(3) هرم الاختبار

100%
graph TD
    A["اختبارات الوحدة<br/>(سريعة، كثيرة)<br/>طرق النماذج، منطق الخدمة"] --> B["اختبارات وظيفية<br/>(متوسطة، بعضها)<br/>طلبات HTTP، استعلامات DB"]
    B --> C["اختبارات API/E2E<br/>(بطيئة، قليلة)<br/>دورة حياة الطلب الكاملة"]
المستوى الكمية السرعة محتوى الاختبار
الوحدة كثيرة سريعة (مللي ثانية) المنطق البحت، طرق النماذج
الوظيفية متوسطة متوسطة (100 مللي ثانية) طلبات HTTP، عمليات DB
API/E2E قليلة بطيئة (ثوانٍ) سلسلة الطلب الكاملة

(1) ▶ مثال: تهيئة بيئة اختبار ShopMetrics

BASH
# إنشاء .env.testing
cp .env .env.testing

# تحرير .env.testing
# DB_CONNECTION=sqlite
# DB_DATABASE=:memory:
# QUEUE_CONNECTION=sync

# تشغيل الاختبارات
php artisan test           # جميع الاختبارات
php artisan test --parallel  # متوازي (أسرع)
php artisan test --coverage  # مع تقرير التغطية

الناتج:

TEXT
# تم تنفيذ الأمر بنجاح

4. اختبار الوحدة

(1) PHPUnit مقابل Pest

البُعد PHPUnit Pest
الصياغة فئة + طريقة وظيفية
نموذج الكود أكثر أقل
قابلية القراءة متوسطة ✅ عالية
التوافق 100% مبني على PHPUnit
مناسب لـ الاختبار المعقد بسيط وسريع

(2) أسلوب PHPUnit

PHP
// tests/Unit/Models/ShopTest.php
class ShopTest extends TestCase
{
    public function test_shop_generates_slug(): void
    {
        $shop = Shop::factory()->make(['name' => 'Alice Store']);
        $this->assertEquals('alice-store', Str::slug($shop->name));
    }

    public function test_active_scope_filters_active_shops(): void
    {
        Shop::factory()->create(['status' => 'active']);
        Shop::factory()->create(['status' => 'suspended']);

        $activeShops = Shop::active()->get();

        $this->assertCount(1, $activeShops);
        $this->assertEquals('active', $activeShops->first()->status);
    }

    public function test_revenue_formatted_accessor(): void
    {
        $shop = Shop::factory()->make(['revenue' => 12345.67]);
        $this->assertEquals('$12,345.67', $shop->revenue_formatted);
    }
}

(3) أسلوب Pest

PHP
// tests/Unit/Models/ShopTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('generates slug from name', function () {
    $shop = Shop::factory()->make(['name' => 'Alice Store']);
    expect(Str::slug($shop->name))->toBe('alice-store');
});

it('filters active shops via scope', function () {
    Shop::factory()->create(['status' => 'active']);
    Shop::factory()->create(['status' => 'suspended']);

    $active = Shop::active()->get();

    expect($active)->toHaveCount(1);
    expect($active->first()->status)->toBe('active');
});

it('formats revenue with currency symbol', function () {
    $shop = Shop::factory()->make(['revenue' => 12345.67]);
    expect($shop->revenue_formatted)->toBe('$12,345.67');
});

(1) ▶ مثال: اختبارات وحدة نموذج الطلبات في ShopMetrics

PHP
// tests/Unit/Models/OrderTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('calculates order total from items', function () {
    $order = Order::factory()->create();
    $order->items()->createMany([
        ['product_id' => 1, 'quantity' => 2, 'price' => 10.00],
        ['product_id' => 2, 'quantity' => 1, 'price' => 25.00],
    ]);

    $order->updateTotal();

    expect($order->fresh()->total)->toBe(45.00);
});

it('rejects invalid status transition', function () {
    $order = Order::factory()->create(['status' => 'completed']);

    expect(fn () => $order->update(['status' => 'pending']))
        ->toThrow(InvalidArgumentException::class);
});

it('scope completed returns only completed orders', function () {
    Order::factory()->create(['status' => 'completed']);
    Order::factory()->create(['status' => 'pending']);
    Order::factory()->create(['status' => 'cancelled']);

    expect(Order::completed()->count())->toBe(1);
});

الناتج:

TEXT
// تم التنفيذ بنجاح

5. الاختبار الوظيفي

(1) اختبار طلبات HTTP

PHP
// tests/Feature/ShopControllerTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('displays shops list on index page', function () {
    $shops = Shop::factory()->count(3)->create();

    $response = $this->get(route('shops.index'));

    $response->assertStatus(200);
    $response->assertViewIs('shops.index');
    foreach ($shops as $shop) {
        $response->assertSee($shop->name);
    }
});

it('creates a shop with valid data', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);

    $response = $this->actingAs($user)->post(route('shops.store'), [
        'name' => 'New Shop',
        'slug' => 'new-shop',
        'description' => 'A test shop',
    ]);

    $response->assertRedirect(route('shops.index'));
    $this->assertDatabaseHas('shops', ['name' => 'New Shop']);
});

it('validates required fields on shop creation', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);

    $response = $this->actingAs($user)->post(route('shops.store'), []);

    $response->assertSessionHasErrors(['name', 'slug']);
});

it('prevents non-owners from creating shops', function () {
    $user = User::factory()->create(['role' => 'analyst']);

    $response = $this->actingAs($user)->post(route('shops.store'), [
        'name' => 'Unauthorized Shop',
        'slug' => 'unauthorized',
    ]);

    $response->assertForbidden();
});

(2) تأكيدات قاعدة البيانات

طريقة التأكيد الوصف
assertDatabaseHas() سجلات موجودة في قاعدة البيانات
assertDatabaseMissing() لا يوجد سجل في قاعدة البيانات
assertDatabaseCount() مطابقة بعدد السجلات
assertSoftDeleted() سجلات محذوفة بنعومة موجودة

(1) ▶ مثال: اختبار وظيفي CRUD لـ ShopMetrics

PHP
// tests/Feature/ProductControllerTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->owner = User::factory()->create(['role' => 'tenant_owner']);
    $this->shop = Shop::factory()->create(['tenant_id' => $this->owner->tenant_id]);
});

it('lists products for a shop', function () {
    Product::factory()->count(5)->create(['shop_id' => $this->shop->id]);

    $response = $this->actingAs($this->owner)
        ->get(route('shops.products.index', $this->shop));

    $response->assertOk();
    $response->assertViewHas('products');
});

it('stores a new product', function () {
    Storage::fake('public');

    $response = $this->actingAs($this->owner)
        ->post(route('products.store'), [
            'shop_id' => $this->shop->id,
            'name' => 'Widget Pro',
            'sku' => 'WP-001',
            'price' => 49.99,
            'stock' => 100,
        ]);

    $response->assertRedirect();
    $this->assertDatabaseHas('products', ['sku' => 'WP-001']);
});

it('rejects negative price', function () {
    $response = $this->actingAs($this->owner)
        ->post(route('products.store'), [
            'shop_id' => $this->shop->id,
            'name' => 'Bad Product',
            'sku' => 'BP-001',
            'price' => -10,
            'stock' => 50,
        ]);

    $response->assertSessionHasErrors('price');
});

it('deletes a product with soft delete', function () {
    $product = Product::factory()->create(['shop_id' => $this->shop->id]);

    $this->actingAs($this->owner)
        ->delete(route('products.destroy', $product));

    $this->assertSoftDeleted($product);
});

الناتج:

TEXT
// تم التنفيذ بنجاح

6. اختبار API

(1) اختبار مصادقة رمز Sanctum

PHP
// tests/Feature/Api/ShopApiTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('requires authentication for API access', function () {
    $this->getJson('/api/v1/shops')
        ->assertUnauthorized();
});

it('authenticates with Sanctum token', function () {
    $user = User::factory()->create();
    $token = $user->createToken('test-token', ['read'])->plainTextToken;

    $this->withHeader('Authorization', "Bearer {$token}")
        ->getJson('/api/v1/shops')
        ->assertOk();
});

it('lists shops via API', function () {
    $user = User::factory()->create();
    $shops = Shop::factory()->count(3)->create(['tenant_id' => $user->tenant_id]);

    $response = $this->actingAs($user)
        ->getJson('/api/v1/shops');

    $response->assertOk()
        ->assertJsonStructure([
            'data' => [
                '*' => ['id', 'name', 'slug', 'status', 'created_at'],
            ],
        ]);
});

it('creates shop via API', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);

    $response = $this->actingAs($user)
        ->postJson('/api/v1/shops', [
            'name' => 'API Shop',
            'slug' => 'api-shop',
        ]);

    $response->assertCreated()
        ->assertJsonPath('data.name', 'API Shop');
});

it('rejects duplicate slug', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);
    Shop::factory()->create(['tenant_id' => $user->tenant_id, 'slug' => 'existing']);

    $response = $this->actingAs($user)
        ->postJson('/api/v1/shops', [
            'name' => 'Duplicate',
            'slug' => 'existing',
        ]);

    $response->assertUnprocessable()
        ->assertJsonValidationErrors('slug');
});

(2) طرق تأكيد JSON

الطريقة الوصف
assertJson() JSON يحتوي على الجزء المحدد
assertJsonPath() مطابقة القيم في مسار محدد
assertJsonStructure() مطابقة بنية JSON
assertJsonValidationErrors() خطأ تحقق: الحقل مُضمّن
assertJsonCount() مطابقة طول المصفوفة
assertJsonFragment() JSON يحتوي على مقتطف

(1) ▶ مثال: مجموعة اختبارات API في ShopMetrics

PHP
// tests/Feature/Api/OrderApiTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

beforeEach(function () {
    $this->owner = User::factory()->create(['role' => 'tenant_owner']);
    $this->shop = Shop::factory()->create(['tenant_id' => $this->owner->tenant_id]);
});

it('lists orders with pagination', function () {
    Order::factory()->count(25)->create([
        'tenant_id' => $this->owner->tenant_id,
        'shop_id' => $this->shop->id,
    ]);

    $response = $this->actingAs($this->owner)
        ->getJson('/api/v1/orders?per_page=10');

    $response->assertOk()
        ->assertJsonCount(10, 'data')
        ->assertJsonStructure(['meta' => ['current_page', 'total']]);
});

it('shows order with items', function () {
    $order = Order::factory()->create([
        'tenant_id' => $this->owner->tenant_id,
        'shop_id' => $this->shop->id,
    ]);
    $order->items()->create(['product_id' => 1, 'quantity' => 2, 'price' => 10]);

    $response = $this->actingAs($this->owner)
        ->getJson("/api/v1/orders/{$order->id}");

    $response->assertOk()
        ->assertJsonPath('data.order_number', $order->order_number)
        ->assertJsonStructure(['data' => ['items']]);
});

it('updates order status', function () {
    $order = Order::factory()->create([
        'tenant_id' => $this->owner->tenant_id,
        'status' => 'pending',
    ]);

    Event::fake(OrderStatusChanged::class);

    $response = $this->actingAs($this->owner)
        ->patchJson("/api/v1/orders/{$order->id}/status", [
            'status' => 'completed',
        ]);

    $response->assertOk();
    expect($order->fresh()->status)->toBe('completed');
    Event::assertDispatched(OrderStatusChanged::class);
});

الناتج:

TEXT
// تم التنفيذ بنجاح

7. تغطية الاختبار و CI

(1) تقرير التغطية

BASH
# إنشاء تقرير تغطية HTML
php artisan test --coverage-html=coverage

# حد التغطية الأدنى
php artisan test --coverage --min=80

# تغطية لمجلد محدد
php artisan test --coverage-filter=app/Services

(2) GitHub Actions CI

YAML
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: shopmetrics_test
        ports:
          - 3306:3306
      redis:
        image: redis:7
        ports:
          - 6379:6379
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: 8.3
          coverage: xdebug
      - run: composer install --no-interaction
      - run: cp .env.testing .env
      - run: php artisan key:generate
      - run: php artisan test --coverage --min=80

(1) ▶ مثال: تهيئة اختبار CI في ShopMetrics

YAML
# .github/workflows/ci.yml
name: CI
on: [push]
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        php: [8.2, 8.3]
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
      - run: composer install
      - run: php artisan test --parallel
      - run: php artisan test --coverage --min=70
        if: matrix.php == '8.3'

  larastan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: 8.3
      - run: composer install
      - run: ./vendor/bin/phpstan analyse --level=5

الناتج:

TEXT
CONTAINER ID   IMAGE          STATUS         PORTS
abc123         nginx:latest   Up 2 hours     0.0.0.0:80->80/tcp

8. مثال شامل: مجموعة اختبارات ShopMetrics

PHP
// ============================================
// شامل: مجموعة اختبارات ShopMetrics
// يغطي: اختبارات الوحدة، الوظيفية، وAPI باستخدام Pest
// ============================================

// tests/Unit/Services/OrderServiceTest.php
uses(\Tests\TestCase::class, \Illuminate\Foundation\Testing\RefreshDatabase::class);

it('calculates order total correctly', function () {
    $service = app(OrderService::class);
    $order = Order::factory()->create(['subtotal' => 0, 'total' => 0]);
    $order->items()->createMany([
        ['product_id' => 1, 'quantity' => 2, 'price' => 15.00],
        ['product_id' => 2, 'quantity' => 3, 'price' => 10.00],
    ]);

    $service->calculateTotal($order);

    expect($order->fresh()->total)->toBe(60.00);
});

it('applies discount within limits', function () {
    $service = app(OrderService::class);
    $order = Order::factory()->create(['subtotal' => 100, 'discount' => 0, 'total' => 100]);

    $service->applyDiscount($order, 20);

    expect($order->fresh()->total)->toBe(80.00);
});

it('rejects discount exceeding subtotal', function () {
    $service = app(OrderService::class);
    $order = Order::factory()->create(['subtotal' => 100]);

    expect(fn () => $service->applyDiscount($order, 150))
        ->toThrow(InvalidArgumentException::class);
});

// tests/Feature/Api/CompleteOrderFlowTest.php
it('completes full order flow via API', function () {
    $user = User::factory()->create(['role' => 'tenant_owner']);
    $shop = Shop::factory()->create(['tenant_id' => $user->tenant_id]);
    $products = Product::factory()->count(3)->create(['shop_id' => $shop->id, 'stock' => 50]);

    Event::fake();
    Queue::fake();

    // إنشاء طلب
    $response = $this->actingAs($user)->postJson('/api/v1/orders', [
        'shop_id' => $shop->id,
        'items' => [
            ['product_id' => $products[0]->id, 'quantity' => 2],
            ['product_id' => $products[1]->id, 'quantity' => 1],
        ],
    ]);

    $response->assertCreated();
    $orderId = $response->json('data.id');

    // التحقق من الطلب في قاعدة البيانات
    $this->assertDatabaseHas('orders', ['id' => $orderId, 'status' => 'pending']);

    // تحديث الحالة إلى قيد المعالجة
    $this->actingAs($user)
        ->patchJson("/api/v1/orders/{$orderId}/status", ['status' => 'processing'])
        ->assertOk();

    // تحديث الحالة إلى مكتمل
    $this->actingAs($user)
        ->patchJson("/api/v1/orders/{$orderId}/status", ['status' => 'completed'])
        ->assertOk();

    expect(Order::find($orderId)->status)->toBe('completed');
});

❓ أسئلة شائعة

س أيهما أختار، PHPUnit أم Pest؟
ج نوصي بـ Pest للمشاريع الجديدة — صياغة أكثر إيجازًا وقابلية للقراءة أعلى. مبني على PHPUnit، لذا يدعم جميع ميزات PHPUnit. يمكنك استخدامهما معًا في مشاريع PHPUnit الحالية.
س ما الفرق بين RefreshDatabase و DatabaseMigrations؟
ج يعمل RefreshDatabase على تشغيل migrate أولاً ثم يلف كل اختبار في معاملة (للتراجع الأسرع)؛ بينما يعمل DatabaseMigrations على تشغيل migrate قبل كل اختبار و rollback بعد كل اختبار. RefreshDatabase أسرع ويُوصى به.
س كيف أحاكي الخدمات الخارجية أثناء الاختبار؟
ج استخدم Http::fake() لمحاكاة استجابات HTTP: Http::fake(['api.stripe.com/*' => Http::response(['status' => 'ok'])]). استخدم Mail::fake() للبريد، و Event::fake() للأحداث، و Queue::fake() للطوابير.
س ما المستوى المناسب لتغطية الاختبار؟
ج 80%+ لمنطق الأعمال الأساسي، 70%+ لطرق النماذج، و 60%+ للمتحكمات. السعي لتغطية 100% ليس فعالًا من حيث التكلفة — ركز على تغطية مسارات الأعمال الرئيسية والمنطق المعقد، بدلاً من كل getter و setter.
س كيف يمكنني تشغيل الاختبارات بشكل متوازي في CI؟
ج php artisan test --parallel=4 شغّلها بشكل متوازي باستخدام 4 عمليات. يجب أن يكون كل اختبار مستقلًا (لا يعتمد على بيانات من اختبارات أخرى)، واستخدم قاعدة بيانات SQLite في الذاكرة لتجنب تنازع قاعدة البيانات.
س كيف أتعامل مع تحميل الملفات في الاختبارات؟
ج استخدم Storage::fake('disk') لمحاكاة نظام الملفات، واستخدم UploadedFile::fake()->create('test.jpg') لإنشاء ملف وهمي. يتم تنظيف الملف الوهمي تلقائيًا بعد الاختبار.

📖 ملخص


📝 تمارين

  1. تمرين أساسي (⭐): اكتب ثلاثة اختبارات وحدة لنموذج Shop (نطاق active، ملحق revenue_formatted، وإنشاء slug) باستخدام صياغة Pest، وتأكد من نجاحها جميعًا.

  2. تمرين متقدم (⭐⭐): اكتب اختبارات وظيفية لعمليات CRUD في ShopController (index/show/store/update/destroy)، بما في ذلك تأكيدات فشل التحقق وفحوصات الصلاحيات، باستخدام سمة RefreshDatabase.

  3. تحدي (⭐⭐⭐): اكتب اختبار عملية طلب API كاملة — إنشاء طلب → تحديث الحالة → التحقق من تغييرات قاعدة البيانات → محاكاة الأحداث والطوابير → تأكيد نجاح Event::assertDispatched و Queue::assertPushed، بمعدل تغطية ≥ 80%.

Web-Tutorial.com

فريق Web-Tutorial التقني

منصة دروس برمجية يديرها عدة مطورين. كل درس يتم كتابته ومراجعته بواسطة مطورين متخصصين في المجال. نعمل على ضمان دقة وموثوقية المحتوى — إذا لاحظت أي مشكلة، فيرجى إخبارنا.

100%