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.
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.
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’;
Expected: throws <Instance of 'TypeMatcher’>
Actual: Future>
Which: threw
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!
I found this in the official repo: https://github.com/hivedb/hive/blob/master/test/tests/hive_impl_test.dart
Hive is not a class but an instance. You should probably implement HiveInterface.
What is the difference between TypeMatcher, from matcher package, and isA, that doesn’t need any import?
TypeMatcher is deprecated. Use isA instead
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).
Didn’t work, still stops there
How to initialize SQLite in get_it dependency.
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?
Got it figured out!
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??
expect(() => call(), throwsA(isA()));
Issue: The library ‘package:data_connection_checker/data_connection_checker.dart’ is legacy, and should not be imported into a null safe library.
Use instead the internet_connection_checker package (https://pub.dev/packages/internet_connection_checker)
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));
});
});
await dataSource.cacheNumberTrivia(tNumberTriviaModel);
and the top add “late” for dataSource type.
I don’t think the title of your article matches the content lol. Just kidding, mainly because I had some doubts after reading the article.