الاختبار الآلي في Laravel
الاختبارات هي "شبكة الأمان" في Laravel — تشغيلها مع كل تغيير في الكود يضمن اكتشاف أي أخطاء فورًا وعدم وصولها إلى بيئة الإنتاج.
1. ما ستتعلمه
- تهيئة بيئة الاختبار: phpunit.xml و .env.testing
- اختبار الوحدة: اختبار معزول لطرق النماذج وفئات الخدمة
- الاختبار الوظيفي: اختبار طلبات HTTP والسمة Database Transactions
- اختبار API: مصادقة رمز Sanctum + تأكيدات JSON
- تغطية الاختبار: تقارير التغطية مدمجة مع CI
2. قصة حقيقية لمطور في نوبة ليلية
(1) المشكلة: أخطاء تظهر في كل مرة ننشر فيها
نشر Bob نسخة جديدة من ShopMetrics ليلة الجمعة — غيّر منطق خصم الطلبات، واختبر يدويًا بعض السيناريوهات للتأكد من أن كل شيء يعمل، ثم أطلقه. وتبين أنه صباح الإثنين، أبلغت Alice أن الخصومات السالبة تسببت في إجمالي الطلبات السالب، مما أدى إلى حصول أكثر من 100 مستخدم على منتجاتهم مجانًا. قال Charlie: "لو كانت لديك اختبارات آلية، لكان هذا الخطأ قد اكتُشف عند إرسال الكود."
(2) حلول الاختبار الآلي
الاختبارات الآلية تعمل تلقائيًا مع كل تغيير في الكود — منطق الخصم مغطى بالكامل بالاختبارات، وحالات اختبار الخصومات السالبة تفشل فورًا، مما يضمن عدم وصول الأخطاء إلى الإنتاج.
// اختبار أن الخصم السالب مرفوض
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
<!-- 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
# .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) هرم الاختبار
graph TD
A["اختبارات الوحدة<br/>(سريعة، كثيرة)<br/>طرق النماذج، منطق الخدمة"] --> B["اختبارات وظيفية<br/>(متوسطة، بعضها)<br/>طلبات HTTP، استعلامات DB"]
B --> C["اختبارات API/E2E<br/>(بطيئة، قليلة)<br/>دورة حياة الطلب الكاملة"]
| المستوى | الكمية | السرعة | محتوى الاختبار |
|---|---|---|---|
| الوحدة | كثيرة | سريعة (مللي ثانية) | المنطق البحت، طرق النماذج |
| الوظيفية | متوسطة | متوسطة (100 مللي ثانية) | طلبات HTTP، عمليات DB |
| API/E2E | قليلة | بطيئة (ثوانٍ) | سلسلة الطلب الكاملة |
(1) ▶ مثال: تهيئة بيئة اختبار ShopMetrics
# إنشاء .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 # مع تقرير التغطية
الناتج:
# تم تنفيذ الأمر بنجاح
4. اختبار الوحدة
(1) PHPUnit مقابل Pest
| البُعد | PHPUnit | Pest |
|---|---|---|
| الصياغة | فئة + طريقة | وظيفية |
| نموذج الكود | أكثر | أقل |
| قابلية القراءة | متوسطة | ✅ عالية |
| التوافق | 100% | مبني على PHPUnit |
| مناسب لـ | الاختبار المعقد | بسيط وسريع |
(2) أسلوب PHPUnit
// 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
// 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
// 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);
});
الناتج:
// تم التنفيذ بنجاح
5. الاختبار الوظيفي
(1) اختبار طلبات HTTP
// 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
// 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);
});
الناتج:
// تم التنفيذ بنجاح
6. اختبار API
(1) اختبار مصادقة رمز Sanctum
// 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
// 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);
});
الناتج:
// تم التنفيذ بنجاح
7. تغطية الاختبار و CI
(1) تقرير التغطية
# إنشاء تقرير تغطية HTML
php artisan test --coverage-html=coverage
# حد التغطية الأدنى
php artisan test --coverage --min=80
# تغطية لمجلد محدد
php artisan test --coverage-filter=app/Services
(2) GitHub Actions CI
# .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
# .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
الناتج:
CONTAINER ID IMAGE STATUS PORTS
abc123 nginx:latest Up 2 hours 0.0.0.0:80->80/tcp
8. مثال شامل: مجموعة اختبارات ShopMetrics
// ============================================
// شامل: مجموعة اختبارات 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');
});
❓ أسئلة شائعة
migrate أولاً ثم يلف كل اختبار في معاملة (للتراجع الأسرع)؛ بينما يعمل DatabaseMigrations على تشغيل migrate قبل كل اختبار و rollback بعد كل اختبار. RefreshDatabase أسرع ويُوصى به.Http::fake() لمحاكاة استجابات HTTP: Http::fake(['api.stripe.com/*' => Http::response(['status' => 'ok'])]). استخدم Mail::fake() للبريد، و Event::fake() للأحداث، و Queue::fake() للطوابير.php artisan test --parallel=4 شغّلها بشكل متوازي باستخدام 4 عمليات. يجب أن يكون كل اختبار مستقلًا (لا يعتمد على بيانات من اختبارات أخرى)، واستخدم قاعدة بيانات SQLite في الذاكرة لتجنب تنازع قاعدة البيانات.Storage::fake('disk') لمحاكاة نظام الملفات، واستخدم UploadedFile::fake()->create('test.jpg') لإنشاء ملف وهمي. يتم تنظيف الملف الوهمي تلقائيًا بعد الاختبار.📖 ملخص
- بيئة الاختبار تستخدم قاعدة بيانات SQLite في الذاكرة، والتي تقدم أسرع أداء
- اختبارات الوحدة تغطي المنطق البحت (النموذج/الخدمة)، بينما الاختبارات الوظيفية تغطي طلبات HTTP
- Pest صياغة موجزة ومتوافق مع PHPUnit في الجوهر
- اختبار API باستخدام
actingAs()/ رمز Bearer + تأكيدات JSON - Event::fake()/Queue::fake() يعزل التأثيرات الجانبية
- CI يشغل الاختبارات تلقائيًا، مع حد تغطية الكود 70-80%
📝 تمارين
-
تمرين أساسي (⭐): اكتب ثلاثة اختبارات وحدة لنموذج Shop (نطاق active، ملحق revenue_formatted، وإنشاء slug) باستخدام صياغة Pest، وتأكد من نجاحها جميعًا.
-
تمرين متقدم (⭐⭐): اكتب اختبارات وظيفية لعمليات CRUD في ShopController (index/show/store/update/destroy)، بما في ذلك تأكيدات فشل التحقق وفحوصات الصلاحيات، باستخدام سمة RefreshDatabase.
-
تحدي (⭐⭐⭐): اكتب اختبار عملية طلب API كاملة — إنشاء طلب → تحديث الحالة → التحقق من تغييرات قاعدة البيانات → محاكاة الأحداث والطوابير → تأكيد نجاح Event::assertDispatched و Queue::assertPushed، بمعدل تغطية ≥ 80%.



