اختبار تطبيقات Flutter

محاكاة التبعيات باستخدام Mockito وMocktail

16 دقيقة الدرس 4 من 12

محاكاة التبعيات باستخدام Mockito وMocktail

الكائن الوهمي (Mock) هو بديل اختباري يحل محل تبعية حقيقية بنظير خاضع للتحكم الكامل. بدلاً من إجراء طلبات HTTP حقيقية، أو الكتابة إلى قاعدة بيانات فعلية، أو القراءة من نظام الملفات، يتفاعل اختبارك الوحدي مع كائن وهمي تتحكم فيه بالكامل. هذا هو أساس اختبار الوحدة المعزول: الكلاس الخاضع للاختبار لا يلمس العالم الخارجي أبداً.

أبرز مكتبتين للمحاكاة في Dart هما Mockito (تعتمد على توليد الكود، المكتبة الرسمية من Google) وMocktail (لا تحتاج إلى توليد كود، بديل مباشر). كلتاهما تتيحان تحديد القيم المُعادة (stubbing) والتحقق من التفاعلات (verification)، لكنهما تختلفان في تكلفة الإعداد وأسلوب الواجهة البرمجية.

ملاحظة: المحاكاة مخصصة لاختبارات الوحدة فحسب. اختبارات التكامل واختبارات الودجات التي تشغّل شجرة الودجات الحقيقية ينبغي أن تستخدم تطبيقات حقيقية أو بدائل خفيفة، لا كائنات وهمية. الإفراط في استخدام المحاكاة يجعل الاختبارات هشة وصعبة الصيانة.

لماذا تهم المحاكاة؟

تخيل WeatherService يستدعي واجهة برمجية بعيدة. اختبار الوحدة الخاص بالودجت أو نموذج العرض المعتمد عليه لا ينبغي أن يُجري طلب شبكة حقيقي لأن:

  • طلبات الشبكة بطيئة وغير موثوقة — قد تفشل حتى لو كان كودك صحيحاً.
  • لا يمكنك التحكم بما يُعيده الخادم، مما يجعل التأكيدات غير موثوقة.
  • تشغيل آلاف الاختبارات في CI سيُثقل واجهة برمجية خارجية.
  • بعض مسارات الكود (مثل استجابة خطأ 503) يصعب إعادة إنتاجها مع خادم حقيقي.

الكائن الوهمي يتيح لك حقن WeatherService مزيف يُعيد بالضبط البيانات — أو يرمي بالضبط الاستثناء — الذي تحتاجه في كل سيناريو اختبار.

Mockito: الكائنات الوهمية المولّدة بالكود

أضف التبعيات إلى pubspec.yaml:

dev_dependencies:
  mockito: ^5.4.0
  build_runner: ^2.4.0
  flutter_test:
    sdk: flutter

قم بتوسيم ملف الاختبار بـ @GenerateMocks، ثم شغّل dart run build_runner build مرة واحدة لتوليد ملف .mocks.dart. بعد ذلك استخدم الكلاس المولَّد في اختباراتك.

// weather_repository.dart
abstract class WeatherRepository {
  Future<String> fetchCondition(String city);
}

// weather_cubit_test.dart
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:flutter_test/flutter_test.dart';
import 'weather_cubit_test.mocks.dart'; // الملف المولَّد

@GenerateMocks([WeatherRepository])
void main() {
  late MockWeatherRepository mockRepo;

  setUp(() {
    mockRepo = MockWeatherRepository();
  });

  test('يُعيد الحالة الجوية من المستودع', () async {
    // التحضير — تحديد قيمة مُعادة
    when(mockRepo.fetchCondition('London'))
        .thenAnswer((_) async => 'Cloudy');

    // التنفيذ
    final result = await mockRepo.fetchCondition('London');

    // التأكيد
    expect(result, equals('Cloudy'));
    verify(mockRepo.fetchCondition('London')).called(1);
  });

  test('يرمي استثناءً عندما يرمي المستودع استثناءً', () async {
    when(mockRepo.fetchCondition(any))
        .thenThrow(Exception('Network error'));

    expect(
      () => mockRepo.fetchCondition('Paris'),
      throwsA(isA<Exception>()),
    );
  });
}
نصيحة: شغّل dart run build_runner build --delete-conflicting-outputs في كل مرة تضيف فيها كلاساً جديداً إلى @GenerateMocks. يجب الالتزام بملف .mocks.dart المولَّد في نظام إدارة الإصدارات حتى لا يحتاج CI إلى تشغيل build_runner.

Mocktail: بدون توليد كود

Mocktail بديل شائع يزيل خطوة build_runner كلياً. تُنشئ كائناً وهمياً بتوسيع Mock وتطبيق الواجهة — سطر واحد لكل كلاس.

// dev_dependencies في pubspec.yaml:
//   mocktail: ^1.0.0

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

// تعريف كائن وهمي في سطر واحد — لا توسيمات مطلوبة
class MockWeatherRepository extends Mock implements WeatherRepository {}

void main() {
  late MockWeatherRepository mockRepo;

  setUp(() {
    mockRepo = MockWeatherRepository();
    // تسجيل القيم الاحتياطية للأنواع المستخدمة مع any()
    registerFallbackValue('');
  });

  test('يُعيد الحالة الجوية المحددة مسبقاً', () async {
    when(() => mockRepo.fetchCondition('Tokyo'))
        .thenAnswer((_) async => 'Sunny');

    final result = await mockRepo.fetchCondition('Tokyo');

    expect(result, equals('Sunny'));
    verify(() => mockRepo.fetchCondition('Tokyo')).called(1);
  });

  test('يتحقق من أن الدالة لم تُستدعَ قط', () async {
    verifyNever(() => mockRepo.fetchCondition(any()));
  });
}
تحذير: تستخدم Mocktail صيغة دالة السهم في when وverifywhen(() => mock.method()) — لا أسلوب الاستدعاء المباشر في Mockito. خلط أسلوبَي الكتابة في المشروع نفسه مصدر شائع للارتباك. التزم بمكتبة واحدة في كل مشروع.

تحديد القيم المُعادة (Stubbing)

تدعم المكتبتان نفس عمليات التحديد الأساسية:

  • thenReturn(value) — تُعيد قيمة عادية (غير Future) بشكل متزامن.
  • thenAnswer((_) async => value) — تُعيد Future أو تستخدم وسائط الاستدعاء.
  • thenThrow(exception) — ترمي استثناءً عند استدعاء الدالة.
  • thenAnswer((_) async => throw exception) — ترمي استثناءً بشكل غير متزامن من Future.

التحقق من التفاعلات

بعد مرحلة التنفيذ في الاختبار يمكنك التأكيد ليس فقط على ما أُعيد بل على كيفية استدعاء الكائن الوهمي:

  • verify(mock.method(arg)).called(n) — يؤكد أن الدالة استُدعيت بالضبط n مرة.
  • verifyNever(mock.method()) — يؤكد أن الدالة لم تُستدعَ قط.
  • verifyInOrder([...]) (Mockito) — يؤكد أن الاستدعاءات جرت بتسلسل محدد.
  • verifyNoMoreInteractions(mock) — يؤكد عدم وجود استدعاءات غير متوقعة إضافية.

الخلاصة

المحاكاة لا غنى عنها لاختبارات الوحدة السريعة والموثوقة. Mockito تتطلب خطوة توليد كود لمرة واحدة لكنها توفر أماناً قوياً للأنواع. Mocktail تُزيل تلك الخطوة بواجهة برمجية خالية من التوسيمات على حساب أمان وقت الترجمة بشكل طفيف. كلتاهما تتبعان نمط التحضير–التنفيذ–التأكيد: حدّد التبعيات في التحضير، مارس الكود المختبَر في التنفيذ، وأكّد القيم المُعادة وتفاعلات الكائن الوهمي في التأكيد.

النقطة الرئيسية: افضّل Mocktail للمشاريع الجديدة حيث تهم سرعة التكرار؛ افضّل Mockito عندما تحتاج ضمانات صارمة في وقت الترجمة أو تعمل في قاعدة كود كبيرة تستخدم build_runner أصلاً. في كلتا الحالتين، احرص على محاكاة المتعاونين الذين تمتلكهم فحسب — تجنب محاكاة كلاسات الجهات الخارجية مباشرة.