Flutter: الاختبار — اختبارات الوحدة / الويدجت / التكامل
الكود بدون اختبارات هو قنبلة موقوتة — لا تعرف متى سينفجر، فقط تعرف أنه سيفعل.
📋 المتطلبات السابقة: يجب أن تكون ملمًا بـ
- الدرس 5: ويدجت ذو حالة والتفاعل
- الدرس 12: إدارة الحالة — Riverpod
1. ما ستتعلمه
- اختبارات الوحدة: حزمة test + محاكاة mocktail؛ اختبار منطق Notifier / Repository
- اختبارات الويدجت: testWidgets + Finder + pumpWidget / pumpAndSettle
- اختبارات التكامل: حزمة integration_test + اختبار شامل على جهاز حقيقي
- اختبارات Golden: matchesGoldenFile لكشف انحدار واجهة المستخدم
- ShopApp: اختبارات وحدة CartNotifier + اختبارات ويدجت قائمة المنتجات + اختبارات تكامل الدفع
2. قصة حقيقية عن خطأ في الإنتاج
(1) المشكلة: الاختبار اليدوي لا يغطي كل شيء
أطلق بوب ميزة القسائم في ShopApp. فريق ضمان الجودة اختبر يدويًا "المسار السعيد" ونجح، لكنه فاته حالة "استخدام قسيمة منتهية الصلاحية". بعد الإطلاق، 500 مستخدم وضعوا طلبات بقسائم منتهية، مما أدى لمبالغ سالبة وأخطاء في قاعدة البيانات. استغرق الإصلاح 4 ساعات بتكلفة 10 آلاف دولار. مشاكل مشابهة تحدث مرتين على الأقل شهريًا.
(2) حل الاختبار المؤتمت
الاختبارات المؤتمتة تعمل عند كل إرسال كود، وتغطي جميع الحالات وتضمن أن الكود الجديد لا يكسر الوظائف الحالية.
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'));
});
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
(3) الفائدة: انخفاض معدل الأخطاء بنسبة 80%
بعد إنشاء بوب لنظام اختبار، أكثر من 200 اختبار تعمل تلقائيًا عند كل إرسال، وانخفض معدل أخطاء الإنتاج من 5/شهر إلى 1/شهر.
3. هرم الاختبار
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]
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
| الطبقة | النسبة | السرعة | التبعيات | نطاق الاختبار |
|---|---|---|---|---|
| اختبارات الوحدة | 70% | ملي ثانية | Mock | المنطق البحت (Notifier/Repository) |
| اختبارات الويدجت | 20% | ثوانٍ | بيئة اختبار | عرض واجهة المستخدم + التفاعل |
| اختبارات التكامل | 10% | دقائق | جهاز حقيقي/محاكي | تدفقات المستخدم الكاملة |
4. اختبارات الوحدة
(1) إعدادات المشروع
# pubspec.yaml
dev_dependencies:
flutter_test:
sdk: flutter
mocktail: ^1.0.0
build_runner: ^2.4.0
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: اختبارات وحدة CartNotifier
// 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');
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: اختبارات وحدة Repository (مع Mock)
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>()));
});
});
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
5. اختبارات الويدجت
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: اختبار ويدجت بطاقة المنتج
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);
});
});
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: اختبار ويدجت صفحة السلة
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);
});
});
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
6. اختبارات التكامل
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: اختبار شامل لتدفق الدفع
// 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);
});
});
}
> الإخراج: شغّل محليًا باستخدام 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 لاختبارات الويدجت | انتظر اكتمال جميع الحركات والعمليات غير المتزامنة |
❓ أسئلة شائعة
flutter test integration_test/ على محاكي Android. iOS يحتاج مشغل macOS.--update-goldens عند تغير واجهة المستخدم. انتبه لاختلافات العرض بين المنصات.ProviderScope(overrides: [myProvider.overrideWith(...)]) لاستبدال المزود الحقيقي بنسخة اختبار.📖 ملخص
- هرم الاختبار: 70% وحدة + 20% ويدجت + 10% تكامل
- اختبارات الوحدة تستخدم mocktail لمحاكاة التبعيات الخارجية، تُنظم بنمط AAA
- اختبارات الويدجت تستخدم testWidgets + Finder للتحقق من واجهة المستخدم والتفاعل
- اختبارات التكامل توفر تحققًا شاملًا لتدفقات المستخدم الكاملة
- تجاوزات مزود Riverpod تستبدل التبعيات في الاختبارات
📝 تمارين
- أساسي (الصعوبة ⭐): اكتب 5 اختبارات وحدة لـ CartNotifier: فارغ مبدئيًا، إضافة، إضافة مكرر، حذف، مسح.
- متوسط (الصعوبة ⭐⭐): اكتب اختبارات ويدجت لـ ProductCard: عرض اسم/سعر المنتج، النقر على إضافة للسلة يُطلق الاستدعاء، عرض شارة SALE.
- متقدم (الصعوبة ⭐⭐⭐): نفذ مجموعة اختبار كاملة: اختبارات وحدة CartNotifier (8+ حالات) + اختبارات ويدجت CartPage (5+ حالات) + اختبار تكامل الدفع (تدفق مستخدم كامل).