Flutter: الاختبار — اختبارات الوحدة / الويدجت / التكامل

الكود بدون اختبارات هو قنبلة موقوتة — لا تعرف متى سينفجر، فقط تعرف أنه سيفعل.

📋 المتطلبات السابقة: يجب أن تكون ملمًا بـ

1. ما ستتعلمه


2. قصة حقيقية عن خطأ في الإنتاج

(1) المشكلة: الاختبار اليدوي لا يغطي كل شيء

أطلق بوب ميزة القسائم في ShopApp. فريق ضمان الجودة اختبر يدويًا "المسار السعيد" ونجح، لكنه فاته حالة "استخدام قسيمة منتهية الصلاحية". بعد الإطلاق، 500 مستخدم وضعوا طلبات بقسائم منتهية، مما أدى لمبالغ سالبة وأخطاء في قاعدة البيانات. استغرق الإصلاح 4 ساعات بتكلفة 10 آلاف دولار. مشاكل مشابهة تحدث مرتين على الأقل شهريًا.

(2) حل الاختبار المؤتمت

الاختبارات المؤتمتة تعمل عند كل إرسال كود، وتغطي جميع الحالات وتضمن أن الكود الجديد لا يكسر الوظائف الحالية.

DART
import 'package:flutter_test/flutter_test.dart';

// ⚙️ Dependency: mocktail: ^1.0.0 (add to dev_dependencies)
// CartNotifier / Coupon / Product from ShopApp project, full definition in Lesson 27

class Coupon {
  final String code;
  final DateTime expiresAt;
  const Coupon({required this.code, required this.expiresAt});
}

test('coupon expired should show error', () {
  final cart = CartNotifier();
  cart.addItem(product);
  final result = cart.applyCoupon(Coupon(code: 'SAVE20', expiresAt: DateTime(2020)));
  expect(result, isFalse);
  expect(cart.error, contains('expired'));
});
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

(3) الفائدة: انخفاض معدل الأخطاء بنسبة 80%

بعد إنشاء بوب لنظام اختبار، أكثر من 200 اختبار تعمل تلقائيًا عند كل إرسال، وانخفض معدل أخطاء الإنتاج من 5/شهر إلى 1/شهر.


3. هرم الاختبار

100%
graph TD
    TP[Testing Pyramid] --> UT[Unit Tests: 70%]
    TP --> WT[Widget Tests: 20%]
    TP --> IT[Integration Tests: 10%]
    UT --> |mocktail| CN3[CartNotifier]
    WT --> |testWidgets| PV3[ProductListView]
    IT --> |integration_test| CK2[Checkout Flow]
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
الطبقة النسبة السرعة التبعيات نطاق الاختبار
اختبارات الوحدة 70% ملي ثانية Mock المنطق البحت (Notifier/Repository)
اختبارات الويدجت 20% ثوانٍ بيئة اختبار عرض واجهة المستخدم + التفاعل
اختبارات التكامل 10% دقائق جهاز حقيقي/محاكي تدفقات المستخدم الكاملة

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

(1) إعدادات المشروع

YAML
# pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter
  mocktail: ^1.0.0
  build_runner: ^2.4.0

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: اختبارات وحدة CartNotifier

DART
// test/notifiers/cart_notifier_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

// ⚙️ Dependency: mocktail: ^1.0.0 (add to dev_dependencies)
// ProductRepository / CartNotifier from ShopApp project, full definition in Lesson 27

// Simplified Product (full definition in Lesson 26 domain/entities/product.dart)
class Product {
  final int id;
  final String name;
  final double price;
  final String imageUrl;
  final String category;
  const Product({required this.id, required this.name, required this.price,
    required this.imageUrl, this.category = 'General'});
}

// Simplified CartNotifier (full definition in Lesson 27)
class CartNotifier {
  List<_CartItem> state = [];
  String? error;

  void addItem(Product product) {
    final idx = state.indexWhere((i) => i.product.id == product.id);
    if (idx >= 0) {
      state = [...state]..[idx] = _CartItem(product: state[idx].product, quantity: state[idx].quantity + 1);
    } else {
      state = [...state, _CartItem(product: product, quantity: 1)];
    }
  }

  void updateQuantity(int productId, int quantity) {
    if (quantity <= 0) { removeItem(productId); return; }
    state = [for (final i in state) i.product.id == productId ? _CartItem(product: i.product, quantity: quantity) : i];
  }

  void removeItem(int productId) {
    state = state.where((i) => i.product.id != productId).toList();
  }

  void clear() { state = []; }

  double get total => state.fold(0.0, (sum, i) => sum + i.product.price * i.quantity);
}

class _CartItem {
  final Product product;
  final int quantity;
  _CartItem({required this.product, required this.quantity});
}

class MockProductRepository extends Mock implements ProductRepository {}

void main() {
  group('CartNotifier', () {
    late CartNotifier cart;

    setUp(() {
      cart = CartNotifier();
    });

    test('initial state is empty', () {
      expect(cart.state, isEmpty);
    });

    test('addItem adds product to cart', () {
      cart.addItem(testProduct);
      expect(cart.state.length, 1);
      expect(cart.state.first.product, testProduct);
      expect(cart.state.first.quantity, 1);
    });

    test('addItem increments quantity if product exists', () {
      cart.addItem(testProduct);
      cart.addItem(testProduct);
      expect(cart.state.length, 1);
      expect(cart.state.first.quantity, 2);
    });

    test('updateQuantity updates item quantity', () {
      cart.addItem(testProduct);
      cart.updateQuantity(testProduct.id, 5);
      expect(cart.state.first.quantity, 5);
    });

    test('updateQuantity removes item when quantity is 0', () {
      cart.addItem(testProduct);
      cart.updateQuantity(testProduct.id, 0);
      expect(cart.state, isEmpty);
    });

    test('removeItem removes product from cart', () {
      cart.addItem(testProduct);
      cart.removeItem(testProduct.id);
      expect(cart.state, isEmpty);
    });

    test('clear empties the cart', () {
      cart.addItem(testProduct);
      cart.addItem(anotherProduct);
      cart.clear();
      expect(cart.state, isEmpty);
    });

    test('total calculates correct sum', () {
      cart.addItem(testProduct);  // price: 99.99
      cart.addItem(anotherProduct);  // price: 49.99
      expect(cart.total, closeTo(149.98, 0.01));
    });
  });
}

const testProduct = Product(id: 1, name: 'Laptop', price: 99.99, imageUrl: '', category: 'Electronics');
const anotherProduct = Product(id: 2, name: 'Mouse', price: 49.99, imageUrl: '', category: 'Electronics');
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: اختبارات وحدة Repository (مع Mock)

DART
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:dio/dio.dart';

// ⚙️ Dependencies: mocktail: ^1.0.0, dio: ^5.0.0 (add to dev_dependencies / dependencies)
// ProductRepository / DioClient from ShopApp project, full definition in Lesson 27

class MockDioClient extends Mock implements DioClient {}

void main() {
  group('ProductRepository', () {
    late ProductRepository repo;
    late MockDioClient mockClient;

    setUp(() {
      mockClient = MockDioClient();
      repo = ProductRepository(mockClient);
    });

    test('getProducts returns list of products', () async {
      // Arrange
      when(() => mockClient.get('/products', queryParams: any(named: 'queryParams')))
          .thenAnswer((_) async => Response(
            data: {'items': [{'id': 1, 'name': 'Laptop', 'price': 1299.99, 'category': 'Electronics'}]},
            statusCode: 200, requestOptions: RequestOptions(path: '/products'),
          ));

      // Act
      final products = await repo.getProducts();

      // Assert
      expect(products.length, 1);
      expect(products.first.name, 'Laptop');
      verify(() => mockClient.get('/products', queryParams: any(named: 'queryParams'))).called(1);
    });

    test('getProducts throws on network error', () async {
      when(() => mockClient.get('/products', queryParams: any(named: 'queryParams')))
          .thenThrow(DioException(requestOptions: RequestOptions(path: '/products')));

      expect(() => repo.getProducts(), throwsA(isA<DioException>()));
    });
  });
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

5. اختبارات الويدجت

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: اختبار ويدجت بطاقة المنتج

DART
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

// Product / ProductCard from ShopApp project, full definition in Lessons 26/27

// Simplified Product
class Product {
  final int id;
  final String name;
  final double price;
  final String imageUrl;
  final String category;
  const Product({required this.id, required this.name, required this.price,
    required this.imageUrl, this.category = 'General'});
}

// Simplified ProductCard
class ProductCard extends StatelessWidget {
  final Product product;
  final VoidCallback onAddToCart;
  const ProductCard({super.key, required this.product, required this.onAddToCart});

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Column(children: [
        Text(product.name),
        Text('\$${product.price.toStringAsFixed(2)}'),
        if (product.price < 50) const Text('SALE'),
        IconButton(icon: const Icon(Icons.add_shopping_cart), onPressed: onAddToCart),
      ]),
    );
  }
}

const testProduct = Product(id: 1, name: 'Laptop', price: 99.99, imageUrl: '', category: 'Electronics');

void main() {
  group('ProductCard', () {
    testWidgets('displays product name and price', (tester) async {
      await tester.pumpWidget(MaterialApp(
        home: Scaffold(body: ProductCard(
          product: testProduct,
          onAddToCart: () {},
        )),
      ));

      expect(find.text('Laptop'), findsOneWidget);
      expect(find.text('\$99.99'), findsOneWidget);
    });

    testWidgets('calls onAddToCart when button tapped', (tester) async {
      var tapped = false;
      await tester.pumpWidget(MaterialApp(
        home: Scaffold(body: ProductCard(
          product: testProduct,
          onAddToCart: () => tapped = true,
        )),
      ));

      await tester.tap(find.byIcon(Icons.add_shopping_cart));
      expect(tapped, isTrue);
    });

    testWidgets('shows SALE badge when price < 50', (tester) async {
      await tester.pumpWidget(MaterialApp(
        home: Scaffold(body: ProductCard(
          product: const Product(id: 1, name: 'Cheap Item', price: 29.99, imageUrl: '', category: 'All'),
          onAddToCart: () {},
        )),
      ));

      expect(find.text('SALE'), findsOneWidget);
    });
  });
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: اختبار ويدجت صفحة السلة

DART
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// ⚙️ Dependency: flutter_riverpod: ^2.0.0 (add to dependencies)
// CartPage / cartProvider / EmptyCartNotifier / PrefilledCartNotifier from ShopApp project

void main() {
  group('CartPage', () {
    testWidgets('shows empty state when cart is empty', (tester) async {
      await tester.pumpWidget(ProviderScope(
        overrides: [cartProvider.overrideWith(() => EmptyCartNotifier())],
        child: const MaterialApp(home: CartPage()),
      ));

      expect(find.text('Cart is empty'), findsOneWidget);
    });

    testWidgets('shows items and total when cart has items', (tester) async {
      await tester.pumpWidget(ProviderScope(
        overrides: [cartProvider.overrideWith(() => PrefilledCartNotifier())],
        child: const MaterialApp(home: CartPage()),
      ));

      await tester.pumpAndSettle();

      expect(find.text('Laptop'), findsOneWidget);
      expect(find.text('Mouse'), findsOneWidget);
      expect(find.textContaining('\$149.98'), findsOneWidget);
    });

    testWidgets('increments quantity on plus tap', (tester) async {
      await tester.pumpWidget(ProviderScope(
        overrides: [cartProvider.overrideWith(() => PrefilledCartNotifier())],
        child: const MaterialApp(home: CartPage()),
      ));

      await tester.pumpAndSettle();

      // Find first increment button and tap
      await tester.tap(find.byIcon(Icons.add).first);
      await tester.pumpAndSettle();

      // Verify quantity changed
      expect(find.text('2'), findsOneWidget);
    });
  });
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

6. اختبارات التكامل

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: اختبار شامل لتدفق الدفع

DART
// integration_test/checkout_test.dart
import 'package:flutter/material.dart';
import 'package:integration_test/integration_test.dart';

// ⚙️ Dependency: integration_test SDK (add to dev_dependencies)
// app (ShopApp main entry) from project lib/main.dart

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('Checkout Flow', () {
    testWidgets('complete purchase end-to-end', (tester) async {
      app.main();
      await tester.pumpAndSettle();

      // 1. Browse products
      expect(find.text('ShopApp'), findsOneWidget);

      // 2. Add product to cart
      await tester.tap(find.byIcon(Icons.add_shopping_cart).first);
      await tester.pumpAndSettle();

      // 3. Navigate to cart
      await tester.tap(find.byIcon(Icons.shopping_cart));
      await tester.pumpAndSettle();

      // 4. Verify cart has item
      expect(find.textContaining('\$'), findsWidgets);

      // 5. Tap checkout
      await tester.tap(find.text('Checkout'));
      await tester.pumpAndSettle();

      // 6. Fill checkout form
      await tester.enterText(find.byType(TextFormField).at(0), 'Alice Smith');
      await tester.enterText(find.byType(TextFormField).at(1), '123 Main St');
      await tester.pumpAndSettle();

      // 7. Submit order
      await tester.tap(find.textContaining('Pay'));
      await tester.pumpAndSettle();

      // 8. Verify order confirmation
      expect(find.text('Order Confirmed'), findsOneWidget);
    });
  });
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

7. أفضل ممارسات الاختبار

الممارسة الوصف
نمط AAA رتّب ← نفّذ ← تحقق
اختبارات مستقلة setUp/setUpEach للتهيئة، لا تبعيات بين الاختبارات
محاكاة التبعيات الخارجية استبدل الشبكة/قاعدة البيانات/واجهات برمجة المنصة بـ Mocks
أسماء اختبارات وصفية test('addItem increments quantity when product exists')
استخدم pumpAndSettle لاختبارات الويدجت انتظر اكتمال جميع الحركات والعمليات غير المتزامنة

❓ أسئلة شائعة

س كيف أفرق بين اختبارات الوحدة واختبارات الويدجت؟
ج اختبارات الوحدة لا تعرض واجهة المستخدم — تختبر المنطق فقط؛ اختبارات الويدجت تعرض الويدجتات في بيئة اختبار وتتحقق من واجهة المستخدم والتفاعل.
س ما الفرق بين mocktail وmockito؟
ج mocktail هو النسخة الأصلية لـ Dart من mockito — لا يحتاج توليد كود وله واجهة برمجة أبسط. يُنصح باستخدام mocktail.
س ما الفرق بين pump وpumpAndSettle؟
ج pump ينفذ إطارًا واحدًا؛ pumpAndSettle ينفذ جميع الإطارات حتى لا توجد إطارات جديدة (اكتمال الحركات/انتهاء غير المتزامن).
س كيف أشغل اختبارات التكامل في CI؟
ج استخدم flutter test integration_test/ على محاكي Android. iOS يحتاج مشغل macOS.
س كيف أحافظ على اختبارات Golden؟
ج التشغيل الأول يُولد ملفات golden؛ التشغيلات اللاحقة تقارن بها. حدّث بـ --update-goldens عند تغير واجهة المستخدم. انتبه لاختلافات العرض بين المنصات.
س كيف أتجاوز مزودات Riverpod في الاختبارات؟
ج استخدم ProviderScope(overrides: [myProvider.overrideWith(...)]) لاستبدال المزود الحقيقي بنسخة اختبار.

📖 ملخص


📝 تمارين

  1. أساسي (الصعوبة ⭐): اكتب 5 اختبارات وحدة لـ CartNotifier: فارغ مبدئيًا، إضافة، إضافة مكرر، حذف، مسح.
  2. متوسط (الصعوبة ⭐⭐): اكتب اختبارات ويدجت لـ ProductCard: عرض اسم/سعر المنتج، النقر على إضافة للسلة يُطلق الاستدعاء، عرض شارة SALE.
  3. متقدم (الصعوبة ⭐⭐⭐): نفذ مجموعة اختبار كاملة: اختبارات وحدة CartNotifier (8+ حالات) + اختبارات ويدجت CartPage (5+ حالات) + اختبار تكامل الدفع (تدفق مستخدم كامل).

← الدرس السابق | الدرس التالي →

Web-Tutorial.com

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

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

100%