الاختبارات و TDD
بناء مجموعة اختبار - الجزء الأول
بناء مجموعة اختبار - الجزء الأول
في هذا الدرس المكون من جزأين، سنبني مجموعة اختبار شاملة لتطبيق تجارة إلكترونية واقعي. يغطي الجزء الأول التخطيط وإعداد المشروع واختبارات الوحدة واختبارات التكامل.
نظرة عامة على المشروع: منصة التجارة الإلكترونية
نحن نبني اختبارات لمتجر عبر الإنترنت مع هذه الميزات:
- مصادقة المستخدم والتفويض
- كتالوج المنتجات مع الفئات
- وظيفة عربة التسوق
- معالجة الطلبات والدفع
- إشعارات البريد الإلكتروني
- لوحة إدارة لإدارة المخزون
تخطيط استراتيجية الاختبار
هرم الاختبار
/\
/ \ اختبارات شاملة (10%)
/ \ - رحلات المستخدم الحرجة
/------\ - تدفق الدفع الكامل
/ \
/ تكامل \ اختبارات التكامل (20%)
/ \ - نقاط نهاية API
/--------------\ - خدمات خارجية
/ \
/ اختبارات وحدة \ اختبارات الوحدة (70%)
/ \ - النماذج، الخدمات
/______________________\ - المساعدين، الأدوات
استراتيجية توزيع الاختبار:
- 70% اختبارات الوحدة: سريعة، معزولة، تختبر المكونات الفردية
- 20% اختبارات التكامل: تختبر تفاعلات المكونات
- 10% اختبارات شاملة: تختبر رحلات المستخدم الحرجة من البداية إلى النهاية
ما يجب اختباره
أولوية عالية (يجب الاختبار):
✓ مصادقة المستخدم والتفويض
✓ معالجة الدفع
✓ إنشاء الطلب والتنفيذ
✓ حسابات العربة (المجموع، الضرائب، الخصومات)
✓ إدارة المخزون
✓ الأمان (XSS، CSRF، حقن SQL)
أولوية متوسطة (يجب الاختبار):
✓ بحث المنتجات والتصفية
✓ إشعارات البريد الإلكتروني
✓ إدارة ملف المستخدم
✓ عمليات CRUD للمسؤول
أولوية منخفضة (جيد للاختبار):
✓ مكونات واجهة المستخدم
✓ مساعدي التنسيق
✓ صفحات المحتوى الثابت
إعداد المشروع
تثبيت أدوات الاختبار
# الاختبار الأساسي
composer require --dev phpunit/phpunit
# مساعدات اختبار Laravel
composer require --dev laravel/dusk
# التقليد
composer require --dev mockery/mockery
# اختبار API
composer require --dev pestphp/pest pestphp/pest-plugin-laravel
# قاعدة البيانات
composer require --dev doctrine/dbal
# تغطية الكود
composer require --dev phpunit/php-code-coverage
تكوين PHPUnit
<!-- phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
bootstrap="vendor/autoload.php"
colors="true"
failOnRisky="true"
failOnWarning="true"
stopOnFailure="false">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./app</directory>
</include>
<exclude>
<directory>./app/Console</directory>
<directory>./app/Exceptions</directory>
</exclude>
</coverage>
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="MAIL_MAILER" value="array"/>
</php>
</phpunit>
اختبارات الوحدة: النماذج
اختبارات نموذج المنتج
<?php
namespace Tests\Unit\Models;
use Tests\TestCase;
use App\Models\Product;
use App\Models\Category;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ProductTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function product_has_name_and_price()
{
$product = Product::factory()->create([
'name' => 'Laptop',
'price' => 999.99
]);
$this->assertEquals('Laptop', $product->name);
$this->assertEquals(999.99, $product->price);
}
/** @test */
public function product_belongs_to_category()
{
$category = Category::factory()->create();
$product = Product::factory()->create([
'category_id' => $category->id
]);
$this->assertInstanceOf(Category::class, $product->category);
$this->assertEquals($category->id, $product->category->id);
}
/** @test */
public function product_has_formatted_price_attribute()
{
$product = Product::factory()->create([
'price' => 1234.56
]);
$this->assertEquals('$1,234.56', $product->formatted_price);
}
/** @test */
public function product_can_be_marked_as_out_of_stock()
{
$product = Product::factory()->create([
'stock' => 10
]);
$this->assertTrue($product->isInStock());
$product->update(['stock' => 0]);
$this->assertFalse($product->fresh()->isInStock());
}
/** @test */
public function product_can_apply_discount()
{
$product = Product::factory()->create([
'price' => 100
]);
$discountedPrice = $product->applyDiscount(0.20); // خصم 20%
$this->assertEquals(80, $discountedPrice);
$this->assertEquals(100, $product->price); // السعر الأصلي دون تغيير
}
/** @test */
public function product_scope_filters_active_products()
{
Product::factory()->count(3)->create(['is_active' => true]);
Product::factory()->count(2)->create(['is_active' => false]);
$activeProducts = Product::active()->get();
$this->assertCount(3, $activeProducts);
}
}
اختبارات نموذج الطلب
<?php
namespace Tests\Unit\Models;
use Tests\TestCase;
use App\Models\Order;
use App\Models\User;
use App\Models\OrderItem;
use Illuminate\Foundation\Testing\RefreshDatabase;
class OrderTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function order_belongs_to_user()
{
$user = User::factory()->create();
$order = Order::factory()->create([
'user_id' => $user->id
]);
$this->assertInstanceOf(User::class, $order->user);
$this->assertEquals($user->id, $order->user->id);
}
/** @test */
public function order_has_many_items()
{
$order = Order::factory()->create();
OrderItem::factory()->count(3)->create([
'order_id' => $order->id
]);
$this->assertCount(3, $order->items);
$this->assertInstanceOf(OrderItem::class, $order->items->first());
}
/** @test */
public function order_calculates_total_correctly()
{
$order = Order::factory()->create();
OrderItem::factory()->create([
'order_id' => $order->id,
'price' => 50,
'quantity' => 2 // $100
]);
OrderItem::factory()->create([
'order_id' => $order->id,
'price' => 30,
'quantity' => 1 // $30
]);
$this->assertEquals(130, $order->calculateTotal());
}
/** @test */
public function order_applies_tax_correctly()
{
$order = Order::factory()->create([
'subtotal' => 100,
'tax_rate' => 0.15 // 15%
]);
$this->assertEquals(15, $order->calculateTax());
$this->assertEquals(115, $order->calculateTotalWithTax());
}
/** @test */
public function order_can_be_marked_as_paid()
{
$order = Order::factory()->create([
'status' => 'pending'
]);
$order->markAsPaid('stripe_charge_123');
$this->assertEquals('paid', $order->status);
$this->assertEquals('stripe_charge_123', $order->payment_id);
$this->assertNotNull($order->paid_at);
}
/** @test */
public function order_number_is_generated_on_creation()
{
$order = Order::factory()->create();
$this->assertNotNull($order->order_number);
$this->assertMatchesRegularExpression(
'/^ORD-[0-9]{6}$/',
$order->order_number
);
}
}
اختبارات الوحدة: الخدمات
اختبارات خدمة العربة
<?php
namespace Tests\Unit\Services;
use Tests\TestCase;
use App\Models\Product;
use App\Services\CartService;
use Illuminate\Support\Facades\Session;
class CartServiceTest extends TestCase
{
protected CartService $cartService;
protected function setUp(): void
{
parent::setUp();
$this->cartService = new CartService();
}
/** @test */
public function can_add_product_to_cart()
{
$product = Product::factory()->make([
'id' => 1,
'price' => 100
]);
$this->cartService->add($product, 2);
$cart = $this->cartService->getCart();
$this->assertCount(1, $cart);
$this->assertEquals(1, $cart[0]['product_id']);
$this->assertEquals(2, $cart[0]['quantity']);
}
/** @test */
public function adding_same_product_increases_quantity()
{
$product = Product::factory()->make(['id' => 1]);
$this->cartService->add($product, 1);
$this->cartService->add($product, 2);
$cart = $this->cartService->getCart();
$this->assertCount(1, $cart);
$this->assertEquals(3, $cart[0]['quantity']);
}
/** @test */
public function can_remove_product_from_cart()
{
$product = Product::factory()->make(['id' => 1]);
$this->cartService->add($product, 1);
$this->cartService->remove(1);
$cart = $this->cartService->getCart();
$this->assertCount(0, $cart);
}
/** @test */
public function calculates_cart_total()
{
$product1 = Product::factory()->make([
'id' => 1,
'price' => 50
]);
$product2 = Product::factory()->make([
'id' => 2,
'price' => 30
]);
$this->cartService->add($product1, 2); // $100
$this->cartService->add($product2, 1); // $30
$total = $this->cartService->getTotal();
$this->assertEquals(130, $total);
}
/** @test */
public function can_clear_cart()
{
$product = Product::factory()->make(['id' => 1]);
$this->cartService->add($product, 3);
$this->cartService->clear();
$cart = $this->cartService->getCart();
$this->assertCount(0, $cart);
}
/** @test */
public function throws_exception_when_adding_out_of_stock_product()
{
$this->expectException(\App\Exceptions\OutOfStockException::class);
$product = Product::factory()->make([
'id' => 1,
'stock' => 0
]);
$this->cartService->add($product, 1);
}
}
اختبارات خدمة الدفع
<?php
namespace Tests\Unit\Services;
use Tests\TestCase;
use App\Models\Order;
use App\Services\PaymentService;
use App\Exceptions\PaymentFailedException;
use Mockery;
class PaymentServiceTest extends TestCase
{
protected PaymentService $paymentService;
protected function setUp(): void
{
parent::setUp();
$this->paymentService = new PaymentService();
}
/** @test */
public function processes_successful_payment()
{
$order = Order::factory()->make([
'id' => 1,
'total' => 150.00
]);
$result = $this->paymentService->charge(
$order,
'tok_visa' // رمز اختبار Stripe
);
$this->assertTrue($result['success']);
$this->assertNotNull($result['charge_id']);
}
/** @test */
public function handles_declined_card()
{
$this->expectException(PaymentFailedException::class);
$this->expectExceptionMessage('Card was declined');
$order = Order::factory()->make(['total' => 150.00]);
$this->paymentService->charge(
$order,
'tok_chargeDeclined'
);
}
/** @test */
public function validates_minimum_charge_amount()
{
$this->expectException(\InvalidArgumentException::class);
$order = Order::factory()->make(['total' => 0.25]); // أقل من الحد الأدنى 0.50$
$this->paymentService->charge($order, 'tok_visa');
}
/** @test */
public function can_refund_payment()
{
$order = Order::factory()->create([
'total' => 100,
'payment_id' => 'ch_123456'
]);
$result = $this->paymentService->refund($order);
$this->assertTrue($result['success']);
$this->assertEquals(100, $result['refunded_amount']);
}
}
اختبارات التكامل
اختبار تكامل معالجة الطلب
<?php
namespace Tests\Integration;
use Tests\TestCase;
use App\Models\User;
use App\Models\Product;
use App\Services\CartService;
use App\Services\OrderService;
use App\Services\PaymentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
class OrderProcessingTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function complete_order_flow_works()
{
// ترتيب
$user = User::factory()->create();
$product = Product::factory()->create([
'price' => 100,
'stock' => 10
]);
$cartService = app(CartService::class);
$orderService = app(OrderService::class);
// فعل - إضافة إلى العربة
$cartService->add($product, 2);
// فعل - إنشاء الطلب
$order = $orderService->createFromCart($user, $cartService);
// تأكيد - تم إنشاء الطلب بشكل صحيح
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'user_id' => $user->id,
'subtotal' => 200,
'status' => 'pending'
]);
// تأكيد - تم إنشاء عناصر الطلب
$this->assertCount(2, $order->items);
// تأكيد - انخفض المخزون
$this->assertEquals(8, $product->fresh()->stock);
// تأكيد - تم مسح العربة
$this->assertCount(0, $cartService->getCart());
}
/** @test */
public function order_sends_confirmation_email()
{
$user = User::factory()->create();
$product = Product::factory()->create(['price' => 50]);
$cartService = app(CartService::class);
$orderService = app(OrderService::class);
$cartService->add($product, 1);
$order = $orderService->createFromCart($user, $cartService);
// معالجة الدفع
$order->markAsPaid('ch_test123');
// تأكيد تم وضع البريد الإلكتروني في قائمة الانتظار
$this->assertDatabaseHas('jobs', [
'queue' => 'emails'
]);
}
}
اختبارات الوحدة مقابل التكامل:
- الوحدة: اختبار فئة واحدة بشكل منفصل، تقليد التبعيات
- التكامل: اختبار مكونات متعددة تعمل معًا، استخدام تبعيات حقيقية
إدارة بيانات الاختبار
إنشاء مصانع الاختبار
<?php
namespace Database\Factories;
use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;
class ProductFactory extends Factory
{
protected $model = Product::class;
public function definition(): array
{
return [
'name' => $this->faker->words(3, true),
'description' => $this->faker->paragraph,
'price' => $this->faker->randomFloat(2, 10, 1000),
'stock' => $this->faker->numberBetween(0, 100),
'is_active' => true,
];
}
public function outOfStock(): self
{
return $this->state([
'stock' => 0
]);
}
public function inactive(): self
{
return $this->state([
'is_active' => false
]);
}
}
تمرين تطبيقي:
أنشئ اختبارات شاملة لنظام مصادقة المستخدم:
- اختبارات الوحدة: نموذج المستخدم (تجزئة كلمة المرور، التحقق من البريد الإلكتروني)
- اختبارات التكامل: تدفق التسجيل (ينشئ المستخدم، يرسل البريد الإلكتروني)
- اختبارات الميزات: نقاط نهاية تسجيل الدخول/الخروج
- استخدم المصانع لبيانات الاختبار
- اختبر كلاً من المسارات السعيدة وحالات الخطأ
الخلاصة
غطى الجزء الأول:
- استراتيجية الاختبار: هرم الاختبار وتخطيط الأولويات
- إعداد المشروع: تكوين PHPUnit والتبعيات
- اختبارات الوحدة: النماذج (المنتج، الطلب) والخدمات (العربة، الدفع)
- اختبارات التكامل: تدفقات العمل متعددة المكونات
- بيانات الاختبار: المصانع وإدارة بيانات الاختبار
في الجزء الثاني، سنضيف اختبارات شاملة، وتكامل CI/CD، وتقارير التغطية، والتوثيق.