Flutter TDD Clean Architecture Course [8] – Local Data Source

23  comments

The next dependency of the Repository is the local Data Source used for caching data gotten from the remote API. We're going to implement it using shared_preferences.

TDD Clean Architecture Course
This post is just one part of a tutorial series. See all of the other parts here and learn to architect your Flutter apps!

There are plenty of options to choose from when it comes to local data persistence. We're ​using shared_preferences because we don't store a lot of data - just a single NumberTrivia which will be converted to JSON. As usual, let's set up the test first under a mirrored test location.

number_trivia_local_data_source_test.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:matcher/matcher.dart';
import 'package:shared_preferences/shared_preferences.dart';

import '../../../../fixtures/fixture_reader.dart';

class MockSharedPreferences extends Mock implements SharedPreferences {}

void main() {
  NumberTriviaLocalDataSourceImpl dataSource;
  MockSharedPreferences mockSharedPreferences;

  setUp(() {
    mockSharedPreferences = MockSharedPreferences();
    dataSource = NumberTriviaLocalDataSourceImpl(
      sharedPreferences: mockSharedPreferences,
    );
  });
}

Following what we just prescribed in the test, we're going to create the implementation class below the abstract one.

number_trivia_local_data_source.dart

...
class NumberTriviaLocalDataSourceImpl implements NumberTriviaLocalDataSource {
  final SharedPreferences sharedPreferences;

  NumberTriviaLocalDataSourceImpl({@required this.sharedPreferences});

  @override
  Future<NumberTriviaModel> getLastNumberTrivia() {
    // TODO: implement getLastNumberTrivia
    return null;
  }

  @override
  Future<void> cacheNumberTrivia(NumberTriviaModel triviaToCache) {
    // TODO: implement cacheNumberTrivia
    return null;
  }
}

Let's focus on the getLastNumberTrivia method first. Calling it should return the cached NumberTriviaModel from the shared preferences, of course, given that it has previously been cached. How is the model going to be stored inside the preferences though? And how can we test all of this?

Objects and Shared Preferences

Storing complex objects such as NumberTrivia inside shared preferences is possible only in a string format. The value returned from the mocked SharedPreferences object will therefore be a JSON string, much like the API response is a JSON string. From the previous parts, you already know the best way of working with JSON in tests - fixtures!

Can we use the fixtures we already created in the previous parts? Well, kind of. Using them certainly wouldn't break anything, but that doesn't necessarily apply to more complex apps. Why? The trivia.json and also trivia_double.json contain some fields and values which are only in the API response.

trivia.json

{
  "text": "Test Text",
  "number": 1,
  "found": true,
  "type": "trivia"
}

Whenever the NumberTriviaModel gets converted to JSON (this will happen in the local Data Source too), the toJson method is run outputting the following Map which contains only the text and number fields. This JSON-encoded Map will be saved in the preferences. 

number_trivia_model.dart

Map<String, dynamic> toJson() {
  return {
    'text': text,
    'number': number,
  };
}

Let's, therefore, create another fixture to mimic the persisted NumberTriviaModel. The new file will go under test/fixtures called trivia_cached.json.

trivia_cached.json

{
  "text": "Test Text",
  "number": 1
}

getLastNumberTrivia

Starting with the test, we define the first functionality of this method. It should return NumberTrivia from SharedPreferences when there is one in the cache.

number_trivia_local_data_source_test.dart

group('getLastNumberTrivia', () {
  final tNumberTriviaModel =
      NumberTriviaModel.fromJson(json.decode(fixture('trivia_cached.json')));

  test(
    'should return NumberTrivia from SharedPreferences when there is one in the cache',
    () async {
      // arrange
      when(mockSharedPreferences.getString(any))
          .thenReturn(fixture('trivia_cached.json'));
      // act
      final result = await dataSource.getLastNumberTrivia();
      // assert
      verify(mockSharedPreferences.getString('CACHED_NUMBER_TRIVIA'));
      expect(result, equals(tNumberTriviaModel));
    },
  );
});

Doing as prescribed by the test, we implement the method to make it pass. Since the return type of the method is a Future and SharedPreferences is probably the only synchronous local persistence library out there, we will use a Future.value factory to return an already completed Future.

number_trivia_local_data_source.dart

@override
Future<NumberTriviaModel> getLastNumberTrivia() {
  final jsonString = sharedPreferences.getString('CACHED_NUMBER_TRIVIA');
  // Future which is immediately completed
  return Future.value(NumberTriviaModel.fromJson(json.decode(jsonString)));
}

The test passes now and let's jump right into the refactoring phase. I certainly don't like passing around magical strings, like 'CACHED_NUMBER_TRIVIA', so let's create a constant with the same name and use it throughout the production and the test file.

number_trivia_local_data_source.dart

const CACHED_NUMBER_TRIVIA = 'CACHED_NUMBER_TRIVIA';

class NumberTriviaLocalDataSourceImpl implements NumberTriviaLocalDataSource {
...
}

We cannot just rely that there will always be a cached version of the last NumberTrivia. What if the user launches the app for the first time while NOT having an Internet connection? In such a case, the Repository will immediately turn to SharedPreferences and they will return null.

Greeting a first user with an app crash would certainly not be a good user experience! To prevent any crashes from happening, we will throw a controlled CacheException. If you remember from the previous part, this exception is caught in the Repository returning a Left(CacheFailure()).

test.dart

test('should throw a CacheException when there is not a cached value', () {
  // arrange
  when(mockSharedPreferences.getString(any)).thenReturn(null);
  // act
  // Not calling the method here, just storing it inside a call variable
  final call = dataSource.getLastNumberTrivia;
  // assert
  // Calling the method happens from a higher-order function passed.
  // This is needed to test if calling a method throws an exception.
  expect(() => call(), throwsA(TypeMatcher<CacheException>()));
});

Implementing the test is as simple as adding an if statement.

implementation.dart

@override
Future<NumberTriviaModel> getLastNumberTrivia() {
  final jsonString = sharedPreferences.getString('CACHED_NUMBER_TRIVIA');
  if (jsonString != null) {
    return Future.value(NumberTriviaModel.fromJson(json.decode(jsonString)));
  } else {
    throw CacheException();
  }
}

cacheNumberTrivia

The method for putting data into preferences should only call SharedPreferences to cache the data. We cannot really test if the data is present inside the preferences (not in a unit test, at least). The next best thing is to use the power of mocks to verify if the mocked instance has been called with the proper arguments.

After all, the JSON string generated by the model's toJson method and the string being stored inside the preferences should be exactly the same.

test.dart

group('cacheNumberTrivia', () {
  final tNumberTriviaModel =
      NumberTriviaModel(number: 1, text: 'test trivia');

  test('should call SharedPreferences to cache the data', () {
    // act
    dataSource.cacheNumberTrivia(tNumberTriviaModel);
    // assert
    final expectedJsonString = json.encode(tNumberTriviaModel.toJson());
    verify(mockSharedPreferences.setString(
      CACHED_NUMBER_TRIVIA,
      expectedJsonString,
    ));
  });
});

implementation.dart

@override
Future<void> cacheNumberTrivia(NumberTriviaModel triviaToCache) {
  return sharedPreferences.setString(
    CACHED_NUMBER_TRIVIA,
    json.encode(triviaToCache.toJson()),
  );
}

What's next

In this part, we implemented the NumberTriviaLocalDataSource class doing TDD. The last remaining part of the data layer is the remote Data Source which we're going to implement next. This means we're going to do test-driven development with the http package. Subscribe below to grow your Flutter coding skills by getting important Flutter news sent right into your inbox on a weekly basis.

Icons and other attribution GOES HERE

About the author 

Matt Rešetár

Matt is an app developer with a knack for teaching others. Working as a Flutter Developer at LeanCode and a developer educator, he is set on helping other people succeed in their Flutter app development career.

You may also like

Flutter UI Testing with Patrol

Flutter UI Testing with Patrol
  • Hello,
    First of all, thanks for the amazing tutorials.

    I got this error when I was running the test ‘should throw a CacheException when there is not a cached value’

    ERROR: Expected: throws <Instance of 'TypeMatcher’>
    Actual: Future>
    Which: threw

    Can you please how to fix this error?

    • Pls check the imports in tests. By default CacheException is imported from Cupertino by VSC and Android Studio. Pls use import ‘package:matcher/matcher.dart’;

    • Pls check the imports in tests. By default CacheException is imported from Cupertino by VSC and Android Studio. Pls use import ‘package:matcher/matcher.dart’;

  • Hello Reso, how could you mock a hivedb in this scenario? if I try this:

    class MockHive extends Mock implements Hive{}

    Hive gives me an error: Classes and mixins can only implement other classes and mixins.

    Thanks for your time!

  • What is the difference between TypeMatcher, from matcher package, and isA, that doesn’t need any import?

  • test exception updated for Dart 2.7:
    // act
    final call = dataSource.getLastNumberTrivia;

    // assert
    expect(call, throwsA(isA()));

  • My debugger was pausing on the throw CacheException() statement. To avoid this you can uncheck the “Uncaught Exceptions” at Breakpoints debugger’s section (bottom left on vscode).

  • I am getting an error “NoSuchMethodError: The getter ‘length’ was called on null.” when testing “getLastNumberTrivia” . I have run through all of the code over and over and checked it against your repo. All looks good but still I get the error. This error is in relation to “final jsonString = sharedPreferences.getString(CACHED_NUMBER_TRIVIA);”. Any ideas on why this is happening?

  • For some reason, dart doesn’t allow exceptions to be left hanging even if it is being handled by the repository, just throwing the exception exits the program.
    Also, the TypeMatcher() is deprecated… so what do I use for 2021??

  • Goodmornig,

    how can I implement for test, the argument any, with mockito and nullsafety?
    If I pass in mockSharedPreferences.getString the value any, I get error for type ‘Null’.

    Any idea?

    • This might help

      class MockSharedPreferences extends Mock implements SharedPreferences {}

      void main() {
      NumberTriviaLocalDataSourceImpl? dataSource;
      MockSharedPreferences? mockSharedPreferences;

      setUp(() {
      mockSharedPreferences = MockSharedPreferences();
      dataSource = NumberTriviaLocalDataSourceImpl(
      sharedPreferences: mockSharedPreferences!);
      });

      group(‘getLastNumberTrivia’, () {

      final tNumberTriviaModel =
      NumberTriviaModel.fromJson(json.decode(fixture(‘trivia_cached.json’)));

      test(
      ‘should return numberTrivia from SharedPreferences when there is one in a cache’,
      () async {
      //arrange
      when(() => mockSharedPreferences!.getString(any()))
      .thenReturn(fixture(‘trivia_cached.json’));
      //act

      final result = await dataSource!.getLastNumberTrivia();
      //assert
      verify(() => mockSharedPreferences!.getString(“CACHED_NUMBER_TRIVIA”));
      expect(result, equals(tNumberTriviaModel));
      });
      });
      }

  • In case you’re having issues with Null is not a type of Future..

    you need to stub the function like so.

    group(‘cacheNumberTrivia’, () {
    final tNumberTriviaModel = NumberTriviaModel(text: ‘test trivia’, number: 1);
    final expectedJsonString = json.encode(tNumberTriviaModel.toJson());

    test(‘should call sharedPreferences to cache the data’, ()
    async{
    //arrange
    when(() => mockSharedPreferences!.setString(cachedNumberTrivia, any())).thenAnswer((_) async => true);
    //act
    dataSource!.cacheNumberTrivia(tNumberTriviaModel);
    //assert
    verify(() => mockSharedPreferences!.setString(cachedNumberTrivia, expectedJsonString));
    });

    });

  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
    >