davidanaya.io

Unit testing in Flutter: bloc pattern

March 24, 2019 | 15 Minute Read

test-bloc

In my previous article I explained how to unit test a set of use cases in Flutter for a provider used to do http requests.

Here I’d like to show a different scenario where I implement one of the core parts in any app: the state. There are multiple ways to manage state in a Flutter application, but I specially like BLoC, so I’ll use this one for my testing example.

The BLoC

Following the example with the http provider from my previous article, here I have a BLoC which handles an Identity for a user. The requirements for our IdentityBloc are the following:

  • Expose the current Identity with a Stream.
  • Allows to set a new Identity with a Sink.
  • Every time a new Identity is set, persist the value in the internal device storage.
  • On initialization (when the app is open), load the current Identity from the internal device storage.

test-bloc

The code

These are all the classes that we are going to use in the implementation. We will test only IdentityBloc, everything else we will need to mock.

test-bloc

For the device storage solution I will use the shared_preferences library by Flutter Team with a simple wrapper on top of that to abstract some complexity from my BLoC. As I want my BLoC to depend on abstractions and not on implementations, I also created an abstract class StorageProvider which will be implemented by the real storage (out of the scope of this article).

abstract class StorageProvider {
  Future<bool> setMap(String key, Map<String, dynamic> value);

  Future<bool> setString(String key, String value);

  Future<Map<String, dynamic>> getMap(String key);

  Future<String> getString(String key);
}

An Identity is basically an id and some extra information. As I will need to save and read from a storage, I will use toJson() and fromJson() to serialize my data. Also I anticipate the need to have an empty Identity, hence the named empty() constructor.

import 'package:meta/meta.dart';

class Identity {
  final String token;
  final String refreshToken;
  final String uid;

  Identity(
      {@required this.uid, @required this.token, @required this.refreshToken});

  Identity.fromJson(Map<String, dynamic> json)
      : uid = json['uid'],
        token = json['token'],
        refreshToken = json['refreshToken'];

  Identity.empty()
      : uid = null,
        token = null,
        refreshToken = null;

  bool isEmpty() => uid == null;

  Map<String, dynamic> toJson() =>
      {'uid': uid, 'token': token, 'refreshToken': refreshToken};
}

Finally, the IdentityBloc. Please note that I only expose identity$ and setIdentity as a way to read and write an Identity. Also, I will use the BLoC to expose the api for the IdentityProvider, which I already covered in the previous article. In this case I only have one method, create(), but this could easily be increased.

import 'dart:async';

import 'package:meta/meta.dart';
import 'package:app/src/models/identity.dart';
import 'package:app/src/providers/identity_provider.dart';
import 'package:app/src/providers/storage_provider.dart';
import 'package:rxdart/rxdart.dart';

const String identityStorageId = 'identity';

class IdentityBloc {
  final StorageProvider storage;
  final IdentityProvider _provider;

  final ReplaySubject<Identity> _identity$ = ReplaySubject<Identity>();
  final StreamController<Identity> _setController =
      StreamController<Identity>();

  Stream<Identity> get identity$ => _identity$.stream;

  Sink<Identity> get setIdentity => _setController.sink;

  IdentityBloc(this._provider, {@required this.storage}) {
    _loadFromStorageAndEmit();
    _setController.stream.listen(_saveToStorageAndEmit);
  }

  Future<Identity> createUser() {
    return _provider.create();
  }

  void dispose() {
    _identity$.close();
    _setController.close();
  }

  Future<void> _saveToStorageAndEmit(Identity identity) async {
    try {
      await storage.setMap(identityStorageId, identity.toJson());
      _identity$.add(identity);
    } catch (err) {
      _handleError(err);
    }
  }

  Future<void> _loadFromStorageAndEmit() async {
    try {
      final identityMap = await storage.getMap(identityStorageId);
      // even if it's null, we need to emit the value for initial navigation
      final identity = identityMap != null
          ? Identity.fromJson(identityMap)
          : Identity.empty();
      _identity$.add(identity);
    } catch (err) {
      _handleError(err);
    }
  }

  void _handleError(dynamic err) {
    // log the error, retry or use any other strategy
  }
}

Note that on the constructor I do two important things:

  • Read from storage and emit the value read, or an empty Identity if none is found.
  • Subscribe to my own _setController, which is used by setIdentity, so that when a new Identity is set, I save it to the storage and emit a new value in identity$.

Test cases

We want to focus only in our BLoC, and in particular in the three following use cases:

  • On creation, with a valid Identity in storage, identity$ emits a new value with the data read.
  • On creation, with no Identity in storage, identity$ emits an empty Identity.
  • When a new Identity is set, this is saved to storage and it’s also emitted by identity$.

We could test more uses cases, but for now I think these three cover the initial requirements for our BLoC.

Mocking and faking

As usual with unit testing we are only interested in our sut (Subject Under Test) which in this case is IdentityBloc, but since this has a dependency on IdentityProvider and StorageProvider, we need to mock those ones.

Again we will be using mockito to easily create the mocks we need. Just extending Mock (provided by mockito) and implementing the class we want to mock we will have a new class with mocked implementations for all the methods that we might need.

With that we can create our identity_bloc_test.dart file and set our test bed.

import 'dart:convert';

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:app/src/blocs/identity_bloc.dart';
import 'package:app/src/models/identity.dart';
import 'package:app/src/providers/identity_provider.dart';
import 'package:app/src/providers/storage_provider.dart';

// buildTestEnvironment()
import '../../config.dart';

class MockIdentityProvider extends Mock implements IdentityProvider {}

class MockStorageProvider extends Mock implements StorageProvider {}

void main() {
  MockIdentityProvider provider;
  MockStorageProvider storage;
  IdentityBloc sut;

  final identity = Identity(uid: "123", token: "123abc", refreshToken: "abba");

  setUp(() {
    // used to provide environment variables for my test
    buildTestEnvironment();
    provider = MockIdentityProvider();
    storage = MockStorageProvider();
  });

  tearDown(() {
    clearInteractions(storage);
    sut = null;
    storage = null;
    provider = null;
  });

  test(
      'on creation storage is read and an identity\$ emits an event with data read',
      () {
  });

  test('on creation identity\$ emits an empty event if no data in storage',
      () {
  });

  test('setIdentity saves data in storage and emits new value in identity\$',
      () {
  });
}

Helper functions

We need some extra work for MockStorageProvider thought, because part of the functionality we want to test for IdentityBloc depends on this class returning what we need. With the default implementation that we get by mockito all the methods used (setMap() and getMap()) will be mocked, but they will return null, which is not what we want. We actually need them to return a Future with the value read for getMap() and a truthy value if it was successfully saved for setMap(). We need to stub these two methods.

void main() {
  ...
  void stubSetMap(dynamic value) {
    when(storage.setMap(identityStorageId, any))
        .thenAnswer((_) => Future.value(true));
  }

  void stubGetMap(dynamic value) {
    when(storage.getMap(identityStorageId))
        .thenAnswer((_) => Future.value(value));
  }
  ...
}

Test an existing Identity is read and emitted on initialization

  test(
      'on creation storage is read and an identity\$ emits an event with data read',
      () async {
    final expected = identity;

    stubGetMap(identity.toJson());

    sut = IdentityBloc(provider, storage: storage);
    expect(sut.identity$, emits(expected));
  });

We spy our getMap() method and make sure that when called it will return our fake Identity. Then we instantiate IdentityBloc with our mocked classes and expect sut.identity$ to emit the value returned by our mocked storage.

Test an empty Identity is emitted on initialization if there’s none stored

  test('on creation identity\$ emits an empty event if no data in storage',
      () async {
    final expected = Identity.empty();

    stubGetMap(null);

    sut = IdentityBloc(provider, storage: storage);
    expect(sut.identity$, emits(expected));
  });

Same idea, but in this case our expected Identity is an empty one.

Test an identity set is saved into storage and also emitted

  test('setIdentity saves data in storage and emits new value in identity\$',
      () async {
    final expected = identity;

    stubGetMap(null);
    stubSetMap(true);

    sut = IdentityBloc(provider, storage: storage);
    sut.setIdentity.add(identity);

    // as setIdentity is async, we need to wait until it's called to verify storage.setMap was actually called
    await untilCalled(storage.setMap(identityStorageId, any));
    verify(storage.setMap(identityStorageId, any)).called(1);

    expect(sut.identity$, emitsInOrder([Identity.empty(), expected]));
  });

This is the most complex case, but we can follow the same idea. We set our stubs and then initialize IdentityBloc and set the Identity with setIdentity.add().

We then need to verify that setMap() was called, but since setIdentity is asynchronous we need to wait for it.

Then we also need to verify that identity$ emitted two values, first an empty Identity (as our getMap() returned null) and then the proper identity that we set previously.

All working, right? Well… not really

Actually, if we run the tests like this, none of them will pass. Instead we will get an error like this:

Expected: <Instance of ‘Identity’>
Actual: <Instance of ‘Identity’>

What this means is that the instance of the Identity that we expect is not the same that we receive. This makes sense, because in all of our tests the instance that we get is created with Identity.empty() or with Identity.fromJson(), so even if the contents of the instance are the same as our faked identity, both instances are different.

In order to fix this we need to implement the operator == in our Identity model. That way, when two instances of Identity are compared for equality (as it is done in our expect() clause in all tests), this operator implementation will be used.

import 'package:meta/meta.dart';
import 'package:quiver/core.dart' show hash3;

class Identity {
  final String token;
  final String refreshToken;
  final String uid;

  Identity(
      {@required this.uid, @required this.token, @required this.refreshToken});

  Identity.fromJson(Map<String, dynamic> json)
      : uid = json['uid'],
        token = json['token'],
        refreshToken = json['refreshToken'];

  Identity.empty()
      : uid = null,
        token = null,
        refreshToken = null;

  bool isEmpty() => uid == null;

  Map<String, dynamic> toJson() =>
      {'uid': uid, 'token': token, 'refreshToken': refreshToken};

  // override operator == to allow for equal comparisons in tests
  @override
  bool operator ==(other) {
    if (other is! Identity) {
      return false;
    }

    if (uid != other.uid) {
      return false;
    }
    if (token != other.token) {
      return false;
    }
    if (refreshToken != other.refreshToken) {
      return false;
    }

    return true;
  }

  @override
  int get hashCode => hash3(uid, token, refreshToken);
}

The == operator method is where we implement the equality logic. In our case, we require that all the uid, token and refreshToken are the same for two instances to be equal.

Regarding the getter hashCode, it’s recommended for consistency that whenever you override the == operator, you also do that with hashCode, otherwise you could have two instances being considered equal but having a different hashCode, which could cause some problems if you were used some Map or indexed data structure based on hashed keys.

Now we are done

Now this will work properly. I focused only in a very limited set of functionality, but you could expand your tests using the same approach.

That’s all. Feel free to post any question in the comments. Thanks for reading!