lundi 6 juillet 2020

Testing BLoC that uses a reactive repository returning a Stream

I'm testing my Flutter mobile application. I'm using flutter_bloc as state management solution and bloc_test to test my BLoCs.

My application interacts with a remote Cloud Firestore database to store data. The BLoC responsible for managing all interactions with the database, uses a repository which exposes all the methods for carrying out the CRUD operations.

Here is the code for the TripsRepository interface:

abstract class TripsRepository {
  /// Deletes all the trips saved in the database.
  Future<void> clear();

  /// Deletes the given [trip] from the list of user's trips.
  Future<void> delete({Trip trip});

  /// Inserts the given [trip] into the list of user's trips.
  Future<void> insert({Trip trip});

  /// Returns a stream of containing a list of [Trip] objects sorted in descending order
  /// according to starting time of the trip.
  Stream<List<Trip>> trips();

  /// Updates the given [trip] in the list of user's trips.
  Future<void> update({Trip trip});
}

As you can see, the trips() method returns a Stream containing the list of all Trip objects stored in the Firebase database. In the TripsBloc I subscribe to this Stream and I listen for changes.

Here is the code for the TripsBloc:

import 'dart:async';

import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';

import '../../../data/trip.dart';
import '../../repositories/trips/trips_repository.dart';

part 'trips_event.dart';
part 'trips_state.dart';

class TripsBloc extends Bloc<TripsEvent, TripsState> {
  /// Trips repository used to perform CRUD operations.
  final TripsRepository tripsRepository;

  /// Subscription to trips database changes.
  StreamSubscription _tripsSubscription; // <---------------- Stream subscription.

  TripsBloc({@required this.tripsRepository})
      : assert(tripsRepository != null),
        super(TripsInitial());

  @override
  Stream<TripsState> mapEventToState(
    TripsEvent event,
  ) async* {
    yield TripsLoadInProgress();

    if (event is LoadTrips) {
      yield* _mapLoadTripsToState();
    } else if (event is AddTrip) {
      yield* _mapAddTripToState(event);
    } else if (event is UpdateTrip) {
      yield* _mapUpdateTripToState(event);
    } else if (event is DeleteTrip) {
      yield* _mapDeleteTripToState(event);
    } else if (event is TripsCleared) {
      yield* _mapTripsClearedToState();
    } else if (event is TripsUpdated) {
      yield* _mapTripsUpdatedToState(event);
    }
  }

  // Cancels the _tripsSubscription when the TripsBloc is closed.
  @override
  Future<void> close() {
    _tripsSubscription?.cancel();
    return super.close();
  }

  // This method subscribes to the Stream and listen for changes.............
  Stream<TripsState> _mapLoadTripsToState() async* {
    _tripsSubscription?.cancel();
    try {
      _tripsSubscription = tripsRepository.trips().listen(
            (List<Trip> trips) => add(TripsUpdated(trips: trips)),
          );
    } catch (_) {
      yield TripsLoadFailure();
    }
  }

  Stream<TripsState> _mapAddTripToState(AddTrip event) async* {
    try {
      tripsRepository.insert(trip: event.trip);
    } catch (_) {
      yield TripsLoadFailure();
    }
  }

  Stream<TripsState> _mapUpdateTripToState(UpdateTrip event) async* {
    try {
      tripsRepository.update(trip: event.trip);
    } catch (_) {
      yield TripsLoadFailure();
    }
  }

  Stream<TripsState> _mapDeleteTripToState(DeleteTrip event) async* {
    try {
      tripsRepository.delete(trip: event.trip);
    } catch (_) {
      yield TripsLoadFailure();
    }
  }

  Stream<TripsState> _mapTripsClearedToState() async* {
    final currentState = state;
    if (currentState is TripsLoadSuccess) {
      List<Trip> trips = currentState.trips;
      try {
        trips.forEach((trip) => tripsRepository.delete(trip: trip));
      } catch (_) {
        yield TripsLoadFailure();
      }
    }
  }

  // When we load our trips, we are subscribing to the TripsRepository
  // and every time a new trip comes in, we add a TripsUpdated event.
  // We then handle all TodosUpdates via the following method.
  Stream<TripsState> _mapTripsUpdatedToState(TripsUpdated event) async* {
    if (event.trips.isEmpty) {
      yield TripsLoadSuccessEmpty();
    } else if (event.trips.last.arrivalTime == null) {
      yield TripsLoadSuccessActive(trips: event.trips);
    } else {
      yield TripsLoadSuccessNotActive(trips: event.trips);
    }
  }
}

To test TripsBloc I use the bloc_test library, and I use the mockito library to mock the TripsRepository. I think the error could be in the setUp method when I mock the trips() method of the repository, but I don't know what I'm getting wrong.

These are the tests that I want to perform:

import 'package:covtrack/business/blocs/trips/trips_bloc.dart';
import 'package:covtrack/business/repositories/trips/trips_repository.dart';
import 'package:covtrack/data/coordinates.dart';
import 'package:covtrack/data/place.dart';
import 'package:covtrack/data/trip.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:bloc_test/bloc_test.dart';

class MockTripsRepository extends Mock implements TripsRepository {}

void main() {
  group('TripsBloc', () {
    TripsRepository tripsRepository;
    TripsBloc tripsBloc;
    final trip1 = Trip(
      tripId: 'ABCD1234',
      reason: 'Proven work needs',
      startingTime: DateTime.now(),
      arrivalTime: null,
      source: Place(
        placeId: 'FGHU8976',
        coords: Coordinates(latitude: 44.12345, longitude: 11.3456),
        formattedAddress: 'via Rossi 1, Bologna (BO) Italy',
        name: 'via Rossi 1, Bologna (BO) Italy',
      ),
      destination: Place(
        placeId: 'QWPO4566',
        coords: Coordinates(latitude: 44.8880, longitude: 11.4312),
        formattedAddress: 'via Verdi 3, Bologna (BO) Italy',
        name: 'Best Supermarket',
      ),
      stops: [],
    );

    setUp(() {
      tripsRepository = MockTripsRepository();
      when(tripsRepository.trips()).thenAnswer((_) => Stream.value(<Trip>[])); //<--------------------- I THINK THE ERROR IS HERE!!!!!!!!!!!!!!!!!
      tripsBloc = TripsBloc(tripsRepository: tripsRepository);
    });



    blocTest(
      'should emit TripsLoadSuccessEmpty when trips loaded for the first time',
      build: () async => tripsBloc,
      act: (bloc) async => bloc.add(LoadTrips()),
      expect: [
        TripsLoadInProgress(),
        TripsLoadSuccessEmpty(),
      ],
    );

    blocTest(
      'should add a trip to the list in response to an AddTrip event',
      build: () async => tripsBloc,
      act: (bloc) async => bloc..add(LoadTrips())..add(AddTrip(trip: trip1)),
      expect: [
        TripsLoadInProgress(),
        TripsLoadSuccessEmpty(),
        TripsLoadSuccessActive(trips: [trip1])
      ],
    );
  });
}

When I run the tests of the BLoC for the LoadTrips event or the sequence of events [LoadTrips, AddTrip] my tests fail with the following error, as if no status had been issued following the event.

Expected: [
            TripsLoadInProgress:TripsLoadInProgress,
            TripsLoadSuccessEmpty:TripsLoadSuccessEmpty
          ]
  Actual: [TripsLoadInProgress:TripsLoadInProgress]
   Which: shorter than expected at location [1]

package:test_api                             expect
package:bloc_test/src/bloc_test.dart 143:29  blocTest.<fn>.<fn>
===== asynchronous gap ===========================
dart:async                                   _asyncThenWrapperHelper
package:bloc_test/src/bloc_test.dart         blocTest.<fn>.<fn>
dart:async                                   runZoned
package:bloc_test/src/bloc_test.dart 135:11  blocTest.<fn>

✖ TripsBloc should emit TripsLoadSuccessEmpty when trips loaded for the first time
Expected: [
            TripsLoadInProgress:TripsLoadInProgress,
            TripsLoadSuccessEmpty:TripsLoadSuccessEmpty,
            TripsLoadSuccessActive:TripsLoadSuccessActive { trips: [    Trip {  
                tripId: ABCD1234,  
                reason: Proven work needs,  
                startingTime: 2020-07-06 14:03:05.726559,  
                arrivalTime: null,  
                source:     Place {  
                placeId: FGHU8976,  
                coords: Coordinates { latitude: 44.12345, longitude: 11.3456 },  
                formattedAddress: via Rossi 1, Bologna (BO) Italy,  
                name: via Rossi 1, Bologna (BO) Italy,  
              },  
                destination:     Place {  
                placeId: QWPO4566,  
                coords: Coordinates { latitude: 44.888, longitude: 11.4312 },  
                formattedAddress: via Verdi 3, Bologna (BO) Italy,  
                name: Best Supermarket,  
              },  
                stops: [],  
              }] }
          ]
  Actual: [TripsLoadInProgress:TripsLoadInProgress]
   Which: shorter than expected at location [1]

package:test_api                             expect
package:bloc_test/src/bloc_test.dart 143:29  blocTest.<fn>.<fn>
===== asynchronous gap ===========================
dart:async                                   _asyncThenWrapperHelper
package:bloc_test/src/bloc_test.dart         blocTest.<fn>.<fn>
dart:async                                   runZoned
package:bloc_test/src/bloc_test.dart 135:11  blocTest.<fn>

Aucun commentaire:

Enregistrer un commentaire