إدارة الحالة المتقدمة (Bloc و Riverpod)

اختبار BLoC و Cubit

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

اختبار BLoC و Cubit

من أبرز مزايا نمط BLoC هو قابليته للاختبار. بما أن BLoCs و Cubits هي فئات Dart بسيطة لا تحتوي على أي بنية تحتية لودجات Flutter، يمكنك اختبار كل انتقال للحالة بشكل معزول — دون WidgetTester، دون pumpWidget، مجرد اختبارات Dart سريعة ومركزة. تُقدّم حزمة bloc_test مُساعدَ blocTest المصمم خصيصًا لجعل التحقق من تسلسلات الحالة المُصدَرة أمرًا موجزًا وتعبيريًا.

لماذا اختبارات الوحدة الخالصة لـ BLoC؟

تقبل BLoCs الأحداث (أو استدعاءات الأساليب في حالة Cubits) وتُصدر الحالات كمخرجات. هذا العقد بين المدخلات والمخرجات مثالي للاختبار لأن:

  • لا تحتاج إلى شجرة ودجات — تعمل الاختبارات في ميلي ثانية
  • كل اختبار يتحقق من سلوك واحد محدد بدقة
  • المستودعات المحاكاة تُبقي الاختبارات معزولة تمامًا
  • الإخفاقات قابلة للتتبع فورًا في منطق الأعمال وليس في كود الواجهة
إعداد الحزمة: أضف bloc_test: ^9.1.0 و mocktail: ^1.0.0 إلى dev_dependencies في pubspec.yaml، ثم شغّل flutter pub get. تُعيد bloc_test تصدير test و stream_matchers لذا نادرًا ما تحتاج إلى استيرادهما بشكل منفصل.

مُساعد blocTest — التشريح

تُعيّن دالة blocTest<B, S> مباشرةً على نمط arrange-act-assert:

  • build — مصنع ينشئ نسخة جديدة من BLoC/Cubit قبل كل اختبار
  • seed — إغلاق اختياري يُرجع حالة بداية (يتجاوز الحالة الأولية)
  • act — إغلاق يُضيف أحداثًا أو يستدعي أساليب Cubit
  • expect — إغلاق يُرجع قائمة مطابقات للحالات المُصدَرة بالترتيب
  • errors — يُتيح اختياريًا التحقق من رمي استثناءات محددة
  • verify — فحوصات اختيارية للآثار الجانبية (مثل عدد استدعاءات المستودع)

اختبار Cubit — CounterCubit

// counter_cubit.dart
class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);

  void increment() => emit(state + 1);
  void decrement() => emit(state - 1);
  void reset()     => emit(0);
}

// counter_cubit_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:test/test.dart';
import 'counter_cubit.dart';

void main() {
  group('CounterCubit', () {
    blocTest<CounterCubit, int>(
      'emits [1] when increment is called once',
      build: () => CounterCubit(),
      act:   (cubit) => cubit.increment(),
      expect: () => [1],
    );

    blocTest<CounterCubit, int>(
      'emits [1, 2] when increment is called twice',
      build: () => CounterCubit(),
      act:   (cubit) {
        cubit.increment();
        cubit.increment();
      },
      expect: () => [1, 2],
    );

    blocTest<CounterCubit, int>(
      'emits [0] when reset is called from seeded state 5',
      build: () => CounterCubit(),
      seed:  () => 5,
      act:   (cubit) => cubit.reset(),
      expect: () => [0],
    );

    blocTest<CounterCubit, int>(
      'emits nothing when no act is provided',
      build: () => CounterCubit(),
      expect: () => <int>[],
    );
  });
}

اختبار BLoC مع الأحداث

في BLoC الكامل (الذي يستخدم الأحداث)، يستدعي إغلاق act الدالة bloc.add(SomeEvent()). ينتظر مُساعد blocTest حتى يستقر التدفق قبل مقارنة التسلسل المُصدَر. يمكنك استخدام مطابقات على نمط Hamcrest مثل isA<SomeState>() عندما تكون قيمة الحالة الدقيقة أقل أهمية من نوعها.

اختبار BLoC غير متزامن — WeatherBloc

// weather_state.dart
abstract class WeatherState {}
class WeatherInitial   extends WeatherState {}
class WeatherLoading   extends WeatherState {}
class WeatherLoaded    extends WeatherState {
  final String city;
  final double tempC;
  WeatherLoaded(this.city, this.tempC);
}
class WeatherError     extends WeatherState {
  final String message;
  WeatherError(this.message);
}

// weather_event.dart
abstract class WeatherEvent {}
class FetchWeather extends WeatherEvent {
  final String city;
  FetchWeather(this.city);
}

// weather_bloc.dart
class WeatherBloc extends Bloc<WeatherEvent, WeatherState> {
  final WeatherRepository repository;
  WeatherBloc(this.repository) : super(WeatherInitial()) {
    on<FetchWeather>(_onFetch);
  }

  Future<void> _onFetch(
    FetchWeather event,
    Emitter<WeatherState> emit,
  ) async {
    emit(WeatherLoading());
    try {
      final data = await repository.getWeather(event.city);
      emit(WeatherLoaded(data.city, data.tempC));
    } catch (e) {
      emit(WeatherError(e.toString()));
    }
  }
}

// weather_bloc_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';

class MockWeatherRepository extends Mock implements WeatherRepository {}

void main() {
  late MockWeatherRepository mockRepo;

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

  group('WeatherBloc', () {
    blocTest<WeatherBloc, WeatherState>(
      'emits [Loading, Loaded] on successful fetch',
      build: () {
        when(() => mockRepo.getWeather('London'))
          .thenAnswer((_) async => WeatherData('London', 18.5));
        return WeatherBloc(mockRepo);
      },
      act: (bloc) => bloc.add(FetchWeather('London')),
      expect: () => [
        isA<WeatherLoading>(),
        isA<WeatherLoaded>(),
      ],
    );

    blocTest<WeatherBloc, WeatherState>(
      'emits [Loading, Error] when repository throws',
      build: () {
        when(() => mockRepo.getWeather(any()))
          .thenThrow(Exception('Network failure'));
        return WeatherBloc(mockRepo);
      },
      act: (bloc) => bloc.add(FetchWeather('Paris')),
      expect: () => [
        isA<WeatherLoading>(),
        isA<WeatherError>(),
      ],
    );
  });
}

استخدام seed لاختبار الانتقالات في منتصف التدفق

يُتيح لك معامل seed نقل BLoC إلى أي حالة قبل تشغيل act. هذا لا يُقدَّر بثمن لاختبار الانتقالات التي تعتمد على حالة سابقة دون إعادة تشغيل سلسلة الأحداث بالكامل.

نصيحة: عند استخدام مطابقات isA<T>()، يمكنك ربط .having() للتحقق من حقل محدد: isA<WeatherLoaded>().having((s) => s.city, 'city', 'London'). هذا يُبقي الاختبارات مقروءة مع التحقق من قيم الحمولة.

التحقق من الآثار الجانبية

يعمل رد النداء الاختياري verify بعد إغلاق التدفق وهو مثالي للتحقق من أن المستودع استُدعي العدد المتوقع من المرات:

verify — تأكيد تفاعلات المستودع

blocTest<WeatherBloc, WeatherState>(
  'calls repository exactly once',
  build: () {
    when(() => mockRepo.getWeather('Berlin'))
      .thenAnswer((_) async => WeatherData('Berlin', 12.0));
    return WeatherBloc(mockRepo);
  },
  act: (bloc) => bloc.add(FetchWeather('Berlin')),
  expect: () => [isA<WeatherLoading>(), isA<WeatherLoaded>()],
  verify: (_) {
    verify(() => mockRepo.getWeather('Berlin')).called(1);
  },
);
مشكلة شائعة: نسيان await داخل act عندما يكون أسلوب Cubit غير متزامن. استخدم دائمًا lambda من نوع asyncact: (cubit) async { await cubit.loadData(); } — وإلا قد يُغلق الاختبار التدفق قبل اكتمال العمل غير المتزامن، مما يؤدي إلى إخفاقات متقطعة.

اختبار الحالة الأولية مباشرةً

يمكنك أيضًا اختبار الحالة الأولية خارج blocTest باستخدام كتلة test بسيطة:

  • expect(CounterCubit().state, equals(0));
  • expect(WeatherBloc(mockRepo).state, isA<WeatherInitial>());

الخلاصة

اختبار فئات BLoC و Cubit بحزمة bloc_test أمر بسيط: ابنِ نسخة جديدة، تصرّف بإضافة أحداث أو استدعاء أساليب، وتوقّع قائمة مرتبة من مطابقات الحالة. استخدم seed للبدء من حالة محددة، وverify للتحقق من استدعاءات المستودع، وisA<T>().having() للتحقق المعبّر من قيم الحمولة. بما أن BLoCs لا تحتوي على كود ودجات، فإن هذه الاختبارات سريعة وحتمية وتُشكّل ركيزة مجموعة اختبارات Flutter القوية.