Situation: I have a class Repository that is responsible for retrieving raw data from the DB and mapping it. The mapping happens in an isolate because, being a synchronous operation but, because of the amount of data, it should be async as recommended there. (Note: I use Isolate.run but it's the same as compute).
Issue: I am using Mocktail package for testing (it would be the same also with Mockito), and apparently the mapping functions happening inside the isolate are called but they are not caught by the Mockito verify method. After some research, I think it happens because of the fact that isolate don't use the same input mapper object but copy it (source): in this case, it copies the mapper object and uses it's method.
Questions:
Repository code:
class Repository{
final Source source;
final Mapper mapper;
Repository({required this.source, required this.mapper});
Future<List<MappedData>> getMappedData() async {
final rawData = source.getRawData();
final mappedData = await Isolate.run(()=>
_mapRawData(rawData, mapper);
);
return mappedData;
}
static List<MappedData> _mapRawData(List<RawData> rawData, Mapper mapper){
return rawData.map((e)=> mapper(e)).toList();
}
}
Testing code
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockSource extends Mock implements Source{}
class MockMapper extends Mock implements Mapper{}
void main() {
late MockSource source;
late MockMapper mapper;
late Repository repository;
final rawData = [
RawData(1),
RawData(2),
};
final mappedData = [
MappedData(1),
MappedData(2),
];
setup(){
source= MockSource();
mapper = MockMapper();
repository = Repository (source:source, mapper: mapper);
}
test(
'Repository getMappedData should call source and mapper',
() async {
//assign
when(()=> source.getRawData).thenReturn(rawData);
when(()=> mapper(rawData[0])).thenReturn(mappedData[0]);
when(()=> mapper(rawData[1])).thenReturn(mappedData[1]);
//act
final result = await source.getMappedData();
//assert
expect(result, mappedData);
verify(()=> source.getRawData).called(1);
verify(()=> mapper(rawData[0])).called(1);
verify(()=> mapper(rawData[1])).called(1);
}
The expect
works fine, only the verify
methods trigger an error as they are never called.
Is my assumption right that the verify calls are not caught because the isolate copies the mapper object for mapping?
Yes, the Mapper
object is copied into the new isolate so you are not working on the same instance of Mapper
.
How would you change the repository code or the testing code to make sure the mapping is called?
You can send the copied instance back to the main isolate and observe the changes on the copied instance.
I have put together a few tests that should demonstrate how this works:
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
import 'dart:isolate';
class Demo {
void method() {}
}
class MockDemo extends Mock implements Demo {}
const limit = 5;
void main() {
test('Demo.method called $limit times sync', () {
Demo demo = MockDemo();
for (int i = 0; i < limit; i++) {
demo.method();
}
verify(() => demo.method()).called(limit);
});
test('Demo.method never called due to copy in isolate', () async {
Demo demo = MockDemo();
await Isolate.run(() {
// demo implicitly copied into isolate
for (int i = 0; i < limit; i++) {
demo.method();
}
});
// original demo remains unchanged due to copying
verifyNever(() => demo.method());
});
test('Demo.method called $limit times in another isolate', () async {
Demo demo = MockDemo();
Demo result = await Isolate.run(() {
// demo implicitly copied into isolate
for (int i = 0; i < limit; i++) {
demo.method();
}
return demo; // Send copy back to main isolate
});
// original demo remains unchanged due to copying
verifyNever(() => demo.method());
// observe change on the copied demo sent back from the other isolate
verify(() => result.method()).called(limit);
});
}
The main thing you would need to change is to return from _mapRawData
a type that contains both your List<MappedData>
and your Mapper
object.
I would recommend defining a record to do this:
typedef Result = ({List<MappedData> mappedData, Mapper mapper});
And then updating _mapRawData
as follows:
static Result _mapRawData(List<RawData> rawData, Mapper mapper) {
return (
mappedData: rawData.map((e) => mapper(e)).toList(),
mapper: mapper,
);
}
Then update the return type of getMappedData
to Future<Result>
.
Then in your tests, you want to use the instance of Mapper
you get back from the Result
object.