الاختبارات و TDD

بناء مجموعة اختبار - الجزء الأول

15 دقيقة الدرس 33 من 35

بناء مجموعة اختبار - الجزء الأول

في هذا الدرس المكون من جزأين، سنبني مجموعة اختبار شاملة لتطبيق تجارة إلكترونية واقعي. يغطي الجزء الأول التخطيط وإعداد المشروع واختبارات الوحدة واختبارات التكامل.

نظرة عامة على المشروع: منصة التجارة الإلكترونية

نحن نبني اختبارات لمتجر عبر الإنترنت مع هذه الميزات:

  • مصادقة المستخدم والتفويض
  • كتالوج المنتجات مع الفئات
  • وظيفة عربة التسوق
  • معالجة الطلبات والدفع
  • إشعارات البريد الإلكتروني
  • لوحة إدارة لإدارة المخزون

تخطيط استراتيجية الاختبار

هرم الاختبار

/\ / \ اختبارات شاملة (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 ]); } }
تمرين تطبيقي:

أنشئ اختبارات شاملة لنظام مصادقة المستخدم:

  1. اختبارات الوحدة: نموذج المستخدم (تجزئة كلمة المرور، التحقق من البريد الإلكتروني)
  2. اختبارات التكامل: تدفق التسجيل (ينشئ المستخدم، يرسل البريد الإلكتروني)
  3. اختبارات الميزات: نقاط نهاية تسجيل الدخول/الخروج
  4. استخدم المصانع لبيانات الاختبار
  5. اختبر كلاً من المسارات السعيدة وحالات الخطأ

الخلاصة

غطى الجزء الأول:

  • استراتيجية الاختبار: هرم الاختبار وتخطيط الأولويات
  • إعداد المشروع: تكوين PHPUnit والتبعيات
  • اختبارات الوحدة: النماذج (المنتج، الطلب) والخدمات (العربة، الدفع)
  • اختبارات التكامل: تدفقات العمل متعددة المكونات
  • بيانات الاختبار: المصانع وإدارة بيانات الاختبار

في الجزء الثاني، سنضيف اختبارات شاملة، وتكامل CI/CD، وتقارير التغطية، والتوثيق.