Dart: اختبار Dart — من اختبارات الوحدة إلى اختبارات التكامل

آخر تحديث: 2026-08-26

الكود بدون اختبارات هو قنبلة موقوتة — الاختبار هو بوليصة تأمين لمستقبلك.

1. ما ستتعلمه


2. قصة مطور حقيقية

(1) نقطة الألم: كسر صامت بعد إعادة الهيكلة

أعاد تشارلي هيكلة منطق حساب المبلغ في خط أنابيب البيانات، مغيراً معدل الضريبة من قيمة ثابتة إلى استعلام يعتمد على المنطقة. بعد اجتياز الاختبار المحلي والنشر، أثرت إعادة الهيكلة بشكل غير متوقع على حساب تراكم الخصم — أصبح مبلغ الخصم سالباً، مما تسبب في حسابات مبلغ استرداد غير صحيحة لـ 5,000 طلب، وأدى إلى خسارة مباشرة قدرها 25,000 دولار أمريكي. لو كانت هناك اختبارات آلية، لكان قد تم اكتشاف هذا الخطأ قبل النشر.

(2) الحل: نظام الاختبار

توفر حزمة test في Dart إطار اختبار شامل. ينظم group بنية الاختبار، ويوفر expect + Matchers تأكيدات غنية، ويدير setUp/tearDown بيئة الاختبار.

DART
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));
  });
});
TEXT 📖 للعرض فقط
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.

(3) الفوائد


3. أساسيات حزمة test

(1) الصياغة الأساسية

▶ مثال

TEXT 📖 للعرض فقط
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.

: اختبار أساسي

DART
// 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);
  });
}
TEXT 📖 للعرض فقط
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.

▶ مثال

TEXT 📖 للعرض فقط
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.

: تنظيم الاختبارات باستخدام group

DART
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));
    });
  });
}
TEXT 📖 للعرض فقط
> **الإخراج:** شغّل محلياً في 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

▶ مثال

TEXT 📖 للعرض فقط
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.

: إدارة بيئة الاختبار

DART
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);
    });
  });
}
TEXT 📖 للعرض فقط
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.

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

(1) مبادئ الاختبار المعزول

▶ مثال

TEXT 📖 للعرض فقط
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.

: اختبار وظائف الوحدة

DART
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'));
    });
  });
}
TEXT 📖 للعرض فقط
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.

6. Mock و fake

▶ مثال

TEXT 📖 للعرض فقط
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.

: Fake يدوي

DART
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));
    });
  });
}
TEXT 📖 للعرض فقط
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.

7. سيناريو بوب: تغطية اختبار خط أنابيب البيانات

▶ مثال

TEXT 📖 للعرض فقط
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.

: اختبار المنطق الأساسي

DART
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);
    });
  });
}
TEXT 📖 للعرض فقط
> **الإخراج:** شغّل محلياً في DartPad أو عبر `dart run`. جميع الأمثلة في دورة Dart مبنية على Dart 3.x / Flutter 3.x، وقد تختلف النتائج قليلاً مع إصدارات SDK.

8. المثال الكامل: مجموعة اختبار خط أنابيب البيانات

DART
// ============================================
// مجموعة اختبار خط أنابيب البيانات
// اختبار شامل مع المجموعات والإعداد و 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'));
    });
  });
}
TEXT 📖 للعرض فقط
> **الإخراج:** شغّل محلياً في 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، إلخ.


📖 ملخص


📝 تمارين

  1. أساسي (صعوبة ⭐): اكتب 5 اختبارات وحدة لدالة formatUSD(double amount): المبلغ العادي، الصفر، السالب، العدد الكبير جداً، والدقة العشرية.
  2. متوسط (صعوبة ⭐⭐): أنشئ FakeApiService ينفذ واجهة DataSource. استخدمه لاختبار طريقتي getTotalRevenue() و getOrderById() لـ OrderService. تتضمن سيناريوهات النجاح والفشل.
  3. متقدم (صعوبة ⭐⭐⭐): اكتب مجموعة اختبار كاملة لـ ReportGenerator في خط أنابيب البيانات: استخدم setUp لمشاركة بيانات الاختبار، و group لتنظيم الاختبارات حسب الوظيفة، واختبر حالات الحافة (قائمة فارغة، عنصر واحد، كل الحالات قيد الانتظار)، بهدف تغطية ≥ 90%.

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

Web-Tutorial.com

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

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

100%