4

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

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
Matej Rešetár
 

Matej is an app developer with a knack for teaching others. If he's not programming, making tutorials or doing other business, he's mostly working out, listening to audiobooks and taking cold showers.

  • Rinzin says:

    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?

    • Valentin Sobolev says:

      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’;

  • Bawa says:

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

    • Valentin Sobolev says:

      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’;

  • >