Dart: اختبار Dart — من اختبارات الوحدة إلى اختبارات التكامل
آخر تحديث: 2026-08-26
الكود بدون اختبارات هو قنبلة موقوتة — الاختبار هو بوليصة تأمين لمستقبلك.
1. ما ستتعلمه
- حزمة test: test() / group() / expect() مع Matchers
- اختبار الوحدة: اختبار معزول للدوال والفئات
- الاختبار التكامل: التحقق من التعاون بين وحدات متعددة
- Mock و fake: mockito / fake_async
- سيناريو بوب: تغطية اختبار المنطق الأساسي لخط أنابيب البيانات
2. قصة مطور حقيقية
(1) نقطة الألم: كسر صامت بعد إعادة الهيكلة
أعاد تشارلي هيكلة منطق حساب المبلغ في خط أنابيب البيانات، مغيراً معدل الضريبة من قيمة ثابتة إلى استعلام يعتمد على المنطقة. بعد اجتياز الاختبار المحلي والنشر، أثرت إعادة الهيكلة بشكل غير متوقع على حساب تراكم الخصم — أصبح مبلغ الخصم سالباً، مما تسبب في حسابات مبلغ استرداد غير صحيحة لـ 5,000 طلب، وأدى إلى خسارة مباشرة قدرها 25,000 دولار أمريكي. لو كانت هناك اختبارات آلية، لكان قد تم اكتشاف هذا الخطأ قبل النشر.
(2) الحل: نظام الاختبار
توفر حزمة test في Dart إطار اختبار شامل. ينظم group بنية الاختبار، ويوفر expect + Matchers تأكيدات غنية، ويدير setUp/tearDown بيئة الاختبار.
group('Order calculation', () {
test('applies tax correctly', () {
final order = Order(id: 'T1', amount: 1000.0);
expect(order.calcTax(0.08), equals(80.0));
});
test('discount does not make total negative', () {
final order = Order(id: 'T2', amount: 100.0, discountRate: 0.5);
expect(order.total, greaterThan(0));
});
});
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.
(3) الفوائد
- تشغيل الاختبارات تلقائياً بعد إعادة الهيكلة، تغطية أكثر من 200 حالة اختبار في 5 دقائق.
- حالات الحافة (المبالغ السالبة، القوائم الفارغة، القيم الفارغة) لم تعد تُفقد.
- زيادة تغطية الاختبار من 0% إلى 85%، مع تقليل بنسبة 90% في أخطاء الارتداد.
3. أساسيات حزمة test
(1) الصياغة الأساسية
▶ مثال
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.
: اختبار أساسي
// test/order_test.dart
import 'package:test/test.dart';
void main() {
test('simple addition', () {
expect(2 + 3, equals(5));
});
test('string contains', () {
expect('DataPipeline v1.0', contains('Pipeline'));
});
test('list is not empty', () {
expect([1, 2, 3], isNotEmpty);
});
}
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.
▶ مثال
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.
: تنظيم الاختبارات باستخدام group
import 'package:test/test.dart';
double calculateTax(double amount, double rate) => amount * rate;
double calculateTotal(double amount, double taxRate, double discountRate) {
final discounted = amount * (1 - discountRate);
return discounted * (1 + taxRate);
}
void main() {
group('Tax calculation', () {
test('standard rate', () {
expect(calculateTax(1000.0, 0.08), equals(80.0));
});
test('zero rate', () {
expect(calculateTax(1000.0, 0.0), equals(0.0));
});
test('high rate', () {
expect(calculateTax(1000.0, 0.25), equals(250.0));
});
});
group('Total calculation', () {
test('no discount', () {
expect(calculateTotal(1000.0, 0.08, 0.0), equals(1080.0));
});
test('with discount', () {
expect(calculateTotal(1000.0, 0.08, 0.1), closeTo(972.0, 0.01));
});
test('full discount', () {
expect(calculateTotal(1000.0, 0.08, 1.0), equals(0.0));
});
});
}
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.
(2) Matchers الشائعة
| Matcher | المعنى | مثال |
|---|---|---|
equals(value) |
يساوي | equals(5) |
greaterThan(n) |
أكبر من | greaterThan(0) |
lessThan(n) |
أصغر من | lessThan(100) |
closeTo(num, delta) |
يساوي تقريباً | closeTo(3.14, 0.01) |
contains(item) |
يحتوي على | `contains('key') |
isEmpty |
فارغ | isEmpty |
isNotEmpty |
غير فارغ | isNotEmpty |
isNull |
فارغ (null) | isNull |
isNotNull |
غير فارغ (non-null) | isNotNull |
isTrue / isFalse |
فحص منطقي | isTrue |
throwsException |
يُلقي استثناءً | throwsException |
isA<Type>() |
فحص النوع | isA<String>() |
4. setUp / tearDown
▶ مثال
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.
: إدارة بيئة الاختبار
import 'package:test/test.dart';
class OrderBook {
final List<double> orders = [];
void add(double amount) {
if (amount <= 0) throw ArgumentError('Amount must be positive');
orders.add(amount);
}
double get total => orders.fold(0.0, (a, b) => a + b);
double get average => orders.isEmpty ? 0 : total / orders.length;
int get count => orders.length;
}
void main() {
late OrderBook book;
setUp(() {
// يتم التشغيل قبل كل اختبار
book = OrderBook();
});
tearDown(() {
// يتم التشغيل بعد كل اختبار
// التنظيف إذا لزم الأمر
});
group('OrderBook', () {
test('starts empty', () {
expect(book.count, equals(0));
expect(book.total, equals(0.0));
});
test('add increases count', () {
book.add(100.0);
expect(book.count, equals(1));
book.add(200.0);
expect(book.count, equals(2));
});
test('total sums amounts', () {
book.add(1500.0);
book.add(3200.0);
expect(book.total, equals(4700.0));
});
test('rejects negative amount', () {
expect(() => book.add(-50.0), throwsArgumentError);
});
});
}
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.
5. اختبار الوحدة
(1) مبادئ الاختبار المعزول
▶ مثال
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.
: اختبار وظائف الوحدة
import 'package:test/test.dart';
// دوال للاختبار
double parseAmount(String input) {
final value = double.tryParse(input);
if (value == null) throw FormatException('Invalid amount: $input');
if (value <= 0) throw ArgumentError('Amount must be positive');
return value;
}
String formatUSD(double amount) => '\$${amount.toStringAsFixed(2)} USD';
String classifyAmount(double amount) => switch (amount) {
>= 10000 => 'Enterprise',
>= 1000 => 'Premium',
> 0 => 'Standard',
_ => throw ArgumentError('Invalid amount'),
};
void main() {
group('parseAmount', () {
test('parses valid integer', () {
expect(parseAmount('1500'), equals(1500.0));
});
test('parses valid decimal', () {
expect(parseAmount('99.99'), closeTo(99.99, 0.001));
});
test('throws on invalid input', () {
expect(() => parseAmount('abc'), throwsFormatException);
});
test('throws on negative', () {
expect(() => parseAmount('-50'), throwsArgumentError);
});
test('throws on zero', () {
expect(() => parseAmount('0'), throwsArgumentError);
});
});
group('formatUSD', () {
test('formats whole number', () {
expect(formatUSD(1500), equals('\$1500.00 USD'));
});
test('formats decimal', () {
expect(formatUSD(99.99), equals('\$99.99 USD'));
});
});
group('classifyAmount', () {
test('Enterprise tier', () {
expect(classifyAmount(15000), equals('Enterprise'));
});
test('Premium tier', () {
expect(classifyAmount(1500), equals('Premium'));
});
test('Standard tier', () {
expect(classifyAmount(50), equals('Standard'));
});
});
}
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.
6. Mock و fake
▶ مثال
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.
: Fake يدوي
import 'package:test/test.dart';
// واجهة للمحاكاة
abstract class DataSource {
Future<List<String>> fetchIds();
Future<double> fetchAmount(String id);
}
// تنفيذ fake يدوي
class FakeDataSource implements DataSource {
final List<String> _ids;
final Map<String, double> _amounts;
FakeDataSource({List<String>? ids, Map<String, double>? amounts})
: _ids = ids ?? ['ORD-001', 'ORD-002'],
_amounts = amounts ?? {'ORD-001': 1500.0, 'ORD-002': 3200.0};
@override
Future<List<String>> fetchIds() async => _ids;
@override
Future<double> fetchAmount(String id) async =>
_amounts[id] ?? throw Exception('Not found: $id');
}
// الخدمة قيد الاختبار
class OrderService {
final DataSource _source;
OrderService(this._source);
Future<double> getTotalRevenue() async {
final ids = await _source.fetchIds();
double total = 0;
for (final id in ids) {
total += await _source.fetchAmount(id);
}
return total;
}
}
void main() {
group('OrderService', () {
test('calculates total revenue', () async {
final fakeSource = FakeDataSource(
ids: ['ORD-001', 'ORD-002'],
amounts: {'ORD-001': 1500.0, 'ORD-002': 3200.0},
);
final service = OrderService(fakeSource);
expect(await service.getTotalRevenue(), equals(4700.0));
});
test('handles empty data source', () async {
final fakeSource = FakeDataSource(ids: [], amounts: {});
final service = OrderService(fakeSource);
expect(await service.getTotalRevenue(), equals(0.0));
});
});
}
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.
7. سيناريو بوب: تغطية اختبار خط أنابيب البيانات
▶ مثال
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.
: اختبار المنطق الأساسي
import 'package:test/test.dart';
class Order {
final String id;
final double amount;
final String status;
final String category;
final double? discountRate;
Order({
required this.id,
required this.amount,
required this.status,
required this.category,
this.discountRate,
}) {
if (amount <= 0) throw ArgumentError('Amount must be positive: $amount');
if (id.isEmpty) throw ArgumentError('ID cannot be empty');
}
double get effectiveDiscount => discountRate ?? 0;
double get discountedAmount => amount * (1 - effectiveDiscount);
double get tax => discountedAmount * 0.08;
double get total => discountedAmount + tax;
}
class OrderAnalyzer {
static double totalRevenue(List<Order> orders) =>
orders.fold(0.0, (sum, o) => sum + o.total);
static Map<String, double> revenueByCategory(List<Order> orders) {
final result = <String, double>{};
for (final o in orders) {
result.update(o.category, (v) => v + o.total, ifAbsent: () => o.total);
}
return result;
}
static List<Order> filterCompleted(List<Order> orders) =>
orders.where((o) => o.status == 'completed').toList();
}
void main() {
group('Order', () {
test('calculates tax correctly', () {
final order = Order(id: 'T1', amount: 1000.0, status: 'completed', category: 'E');
expect(order.tax, closeTo(80.0, 0.01));
});
test('calculates total with discount', () {
final order = Order(id: 'T2', amount: 1000.0, status: 'completed', category: 'E', discountRate: 0.1);
expect(order.total, closeTo(972.0, 0.01));
});
test('rejects negative amount', () {
expect(() => Order(id: 'T3', amount: -100, status: 'completed', category: 'E'),
throwsArgumentError);
});
test('rejects empty ID', () {
expect(() => Order(id: '', amount: 100, status: 'completed', category: 'E'),
throwsArgumentError);
});
});
group('OrderAnalyzer', () {
late List<Order> testOrders;
setUp(() {
testOrders = [
Order(id: 'ORD-001', amount: 1500.0, status: 'completed', category: 'Electronics'),
Order(id: 'ORD-002', amount: 3200.0, status: 'pending', category: 'Electronics'),
Order(id: 'ORD-003', amount: 890.0, status: 'completed', category: 'Clothing'),
];
});
test('filters completed orders', () {
final completed = OrderAnalyzer.filterCompleted(testOrders);
expect(completed.length, equals(2));
expect(completed.every((o) => o.status == 'completed'), isTrue);
});
test('calculates total revenue', () {
final revenue = OrderAnalyzer.totalRevenue(testOrders);
expect(revenue, greaterThan(0));
});
test('groups revenue by category', () {
final byCategory = OrderAnalyzer.revenueByCategory(testOrders);
expect(byCategory.containsKey('Electronics'), isTrue);
expect(byCategory.containsKey('Clothing'), isTrue);
});
test('handles empty list', () {
expect(OrderAnalyzer.totalRevenue([]), equals(0.0));
expect(OrderAnalyzer.filterCompleted([]), isEmpty);
expect(OrderAnalyzer.revenueByCategory([]), isEmpty);
});
});
}
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.
8. المثال الكامل: مجموعة اختبار خط أنابيب البيانات
// ============================================
// مجموعة اختبار خط أنابيب البيانات
// اختبار شامل مع المجموعات والإعداد و fakes
// ============================================
import 'package:test/test.dart';
// كود الإنتاج
class PipelineConfig {
final int batchSize;
final double taxRate;
final String outputFormat;
PipelineConfig({
this.batchSize = 10000,
this.taxRate = 0.08,
this.outputFormat = 'json',
});
PipelineConfig copyWith({int? batchSize, double? taxRate, String? outputFormat}) =>
PipelineConfig(
batchSize: batchSize ?? this.batchSize,
taxRate: taxRate ?? this.taxRate,
outputFormat: outputFormat ?? this.outputFormat,
);
}
class Order {
final String id;
final double amount;
final String status;
final String category;
Order({
required this.id,
required this.amount,
required this.status,
required this.category,
}) {
if (id.isEmpty) throw ArgumentError('Empty order ID');
if (amount <= 0) throw ArgumentError('Amount must be positive');
}
}
class ReportGenerator {
final PipelineConfig config;
ReportGenerator(this.config);
Map<String, dynamic> generate(List<Order> orders) {
final completed = orders.where((o) => o.status == 'completed').toList();
final revenue = completed.fold<double>(0, (s, o) => s + o.amount);
final tax = revenue * config.taxRate;
final byCategory = <String, int>{};
for (final o in completed) {
byCategory.update(o.category, (v) => v + 1, ifAbsent: () => 1);
}
return {
'totalOrders': orders.length,
'completedOrders': completed.length,
'revenue': revenue,
'tax': tax,
'totalWithTax': revenue + tax,
'byCategory': byCategory,
'format': config.outputFormat,
};
}
}
// الاختبارات
void main() {
group('PipelineConfig', () {
test('has default values', () {
final config = PipelineConfig();
expect(config.batchSize, equals(10000));
expect(config.taxRate, equals(0.08));
expect(config.outputFormat, equals('json'));
});
test('copyWith preserves unspecified fields', () {
final original = PipelineConfig(batchSize: 50000);
final modified = original.copyWith(taxRate: 0.10);
expect(modified.batchSize, equals(50000));
expect(modified.taxRate, equals(0.10));
});
});
group('Order', () {
test('creates valid order', () {
final order = Order(id: 'ORD-001', amount: 1500.0, status: 'completed', category: 'E');
expect(order.id, equals('ORD-001'));
expect(order.amount, equals(1500.0));
});
test('rejects empty ID', () {
expect(() => Order(id: '', amount: 100, status: 'ok', category: 'E'),
throwsArgumentError);
});
test('rejects non-positive amount', () {
expect(() => Order(id: 'X', amount: 0, status: 'ok', category: 'E'),
throwsArgumentError);
expect(() => Order(id: 'X', amount: -1, status: 'ok', category: 'E'),
throwsArgumentError);
});
});
group('ReportGenerator', () {
late PipelineConfig config;
late ReportGenerator generator;
late List<Order> testOrders;
setUp(() {
config = PipelineConfig(taxRate: 0.08);
generator = ReportGenerator(config);
testOrders = [
Order(id: 'ORD-001', amount: 1500.0, status: 'completed', category: 'Electronics'),
Order(id: 'ORD-002', amount: 50.0, status: 'completed', category: 'Books'),
Order(id: 'ORD-003', amount: 3200.0, status: 'pending', category: 'Electronics'),
];
});
test('counts total and completed orders', () {
final report = generator.generate(testOrders);
expect(report['totalOrders'], equals(3));
expect(report['completedOrders'], equals(2));
});
test('calculates revenue from completed only', () {
final report = generator.generate(testOrders);
expect(report['revenue'], equals(1550.0));
});
test('applies tax rate from config', () {
final report = generator.generate(testOrders);
expect(report['tax'], closeTo(124.0, 0.01));
});
test('groups by category', () {
final report = generator.generate(testOrders);
final byCategory = report['byCategory'] as Map<String, int>;
expect(byCategory['Electronics'], equals(1));
expect(byCategory['Books'], equals(1));
});
test('handles empty order list', () {
final report = generator.generate([]);
expect(report['totalOrders'], equals(0));
expect(report['revenue'], equals(0.0));
});
test('respects output format config', () {
final jsonGen = ReportGenerator(PipelineConfig(outputFormat: 'json'));
final csvGen = ReportGenerator(PipelineConfig(outputFormat: 'csv'));
expect(jsonGen.generate(testOrders)['format'], equals('json'));
expect(csvGen.generate(testOrders)['format'], equals('csv'));
});
});
}
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.
❓ أسئلة شائعة
س: أين يجب وضع ملفات الاختبار؟ ج: في مجلد
test/في جذر المشروع، بأسماء ملفات تنتهي بـ_test.dart. تشغيلdart testيكتشف تلقائياً جميع الاختبارات.
س: ما الفرق بين اختبارات الوحدة واختبارات التكامل؟ ج: اختبارات الوحدة تعزل وتختبر دالة/فئة واحدة دون الاعتماد على موارد خارجية. اختبارات التكامل تتحقق من أن وحدات متعددة تتعاون بشكل صحيح وقد تتضمن ملفات أو قواعد بيانات أو شبكات.
س: هل يمكن أن يكون
setUpمتداخلاً داخل المجموعات؟ ج: نعم. يتم تشغيلsetUpللمجموعة الخارجية أولاً، ثم الداخلية. مناسب لمشاركة الإعدادات الأساسية + التكوين المخصص لكل مجموعة.
س: هل يجب أن أختار mockito أو fake يدوي؟ ج: للواجهات البسيطة، استخدم fake يدوي (كود أقل، آمن النوع). للواجهات المعقدة أو عندما تحتاج إلى التحقق من عدد الاستدعاءات، استخدم mockito. توصي Dart بإعطاء الأولوية لـ fakes اليدوية.
س: كيف أختبر الكود غير المتزامن؟ ج: استدعاء الاختبار يدعم
async، لذا استخدمtest('...', () async { ... })مباشرة.expectيدعم أيضاً Futures. لا تنسَawait.
س: كيف أتحقق من تغطية الاختبار؟ ج: شغّل
dart test --coverage=coverage، ثم استخدم أداةformat_coverageمن حزمةcoverageلإنشاء تقرير.
س: كيف أختبر الكود الذي يُلقي استثناءات؟ ج: استخدم
expect(() => someFunction(), throwsException)أو أنواع أكثر تحديداً مثلthrowsFormatExceptionوthrowsArgumentError، إلخ.
📖 ملخص
- توفر حزمة test ثلاث واجهات برمجة أساسية: test/group/expect، مقترنة بـ Matchers غنية.
- ينظم
groupبنية الاختبار؛ يديرsetUp/tearDownبيئة الاختبار. - اختبارات الوحدة تعزل دالة/فئة واحدة دون الاعتماد على موارد خارجية.
- تستبدل تنفيذات Fake التبعيات الحقيقية، مما يجعل الاختبارات سريعة وقابلة للتكرار.
- تغطية اختبار خط أنابيب البيانات: التحقق من Order، حسابات ReportGenerator، حالات الحافة.
📝 تمارين
- أساسي (صعوبة ⭐): اكتب 5 اختبارات وحدة لدالة
formatUSD(double amount): المبلغ العادي، الصفر، السالب، العدد الكبير جداً، والدقة العشرية. - متوسط (صعوبة ⭐⭐): أنشئ
FakeApiServiceينفذ واجهةDataSource. استخدمه لاختبار طريقتيgetTotalRevenue()وgetOrderById()لـOrderService. تتضمن سيناريوهات النجاح والفشل. - متقدم (صعوبة ⭐⭐⭐): اكتب مجموعة اختبار كاملة لـ
ReportGeneratorفي خط أنابيب البيانات: استخدمsetUpلمشاركة بيانات الاختبار، وgroupلتنظيم الاختبارات حسب الوظيفة، واختبر حالات الحافة (قائمة فارغة، عنصر واحد، كل الحالات قيد الانتظار)، بهدف تغطية ≥ 90%.