flutterdartunit-testingtestingdart-isolates

Isolates & Testing in Dart/Flutter : how to verify a call inside an Isolate?


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:

  1. Is my assumption right that the verify calls are not caught because the isolate copies the mapper object for mapping?
  2. How would you change the repository code or the testing code to make sure the mapping is called?

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.


Solution

  • 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.