Search code examples
flutterdartservice-locator

Use a specific instance of a class inside an isolate


I am using an isolate through the compute() method to fetch, parse and sort datas from an API (around 10k entries).

My method getAllCards() is defined inside a class YgoProRepositoryImpl which has an instance of my remote datasource class YgoProRemoteDataSource it is in this class that the method to call my API is defined (it is a simple GET request).

Code Sample

ygopro_repository_impl.dart:

class YgoProRepositoryImpl implements YgoProRepository {
  final YgoProRemoteDataSource remoteDataSource;

  // ...

  YgoProRepositoryImpl({
    required this.remoteDataSource,
    // ...
  });

  // ...

  static Future<List<YgoCard>> _fetchCards(_) async {
    // As I'm inside an isolate I need to re-setup my locator
    setupLocator();
    final cards = await sl<YgoProRemoteDataSource>()
        .getCardInfo(GetCardInfoRequest(misc: true));
    cards.sort((a, b) => a.name.compareTo(b.name));
    return cards;
  }

  @override
  Future<List<YgoCard>> getAllCards() async {
    final cards = await compute(_fetchCards, null);
    return cards;
  }

  // ...
}

service_locator.dart:

import 'package:get_it/get_it.dart';

import 'data/api/api.dart';
import 'data/datasources/remote/ygopro_remote_data_source.dart';
import 'data/repository/ygopro_repository_impl.dart';
import 'domain/repository/ygopro_repository.dart';

final sl = GetIt.instance;

void setupLocator() {
  // ...

  _configDomain();
  _configData();

  // ...

  _configExternal();
}

void _configDomain() {
  //! Domain
  
  // ...

  // Repository
  sl.registerLazySingleton<YgoProRepository>(
    () => YgoProRepositoryImpl(
      remoteDataSource: sl(),
      // ...
    ),
  );
}

void _configData() {
  //! Data
  // Data sources
  sl.registerLazySingleton<YgoProRemoteDataSource>(
    () => YgoProRemoteDataSourceImpl(sl<RemoteClient>()),
  );

  // ...
}

void _configExternal() {
  //! External
  sl.registerLazySingleton<RemoteClient>(() => DioClient());
  
  // ...
}

The code is working properly but getAllCards() is not testable as I cannot inject a mocked class of YgoProRemoteDataSource inside my isolate because it will always get a reference from my service locator.

How can I do to not rely on my service locator to inject YgoProRemoteDataSource inside my isolate and make getAllCards() testable?


Solution

  • Did a more serious attempt, please see the repo: https://github.com/maxim-saplin/compute_sl_test_sample

    Essentially with the current state of affairs with Flutter/Dart you can't pass neither closures nor classes containing closures across isolates boundaries (yet that might change when newer features in Dart land Flutter https://github.com/dart-lang/sdk/issues/46623#issuecomment-916161528). That means there's no way you can pass service locator (which contains closures) or trick the isolate to instantiate a test version of locator via closure IF you don't want any test code to be part of the release build. Yet you can easily pass data source instance to isolate to be used at its entry point as a param.

    Beside, I don't think asking isolate to rebuild the entire service locator makes sense. The whole idea behind compute() is to create a short leaving isolate, run the computation, return the result and terminate the isolate. Initialising the locator is an overhead which is better to be avoided. Besides it seems the whole concept of compute() is being as isolated from the rest of the app as possible.

    You can clone the repo and run the tests. Few words about the sample:

    • Based on Flutter counter starter app
    • lib/classes.dart recreates the code snippet you provided
    • test/widget_test.dart verifies that YgoProRepositoryImpl is working fine with isolate running fake version of data source
    • YgoProRemoteDataSourceImpl mimics real implementation and is located at classes.dart and YgoProRemoteDataSourceFake mimics test version
    • Running isolates under flutter_test requires wrapping test body in tester.runAsync() in order to have real time async execution (rather than fake async used by default by tests and relying on pumping to progress test time). Running tests in this mode can be slow (there's actual 0.5 second wait), structuring the tests in a way when compute() is not used or tested not in many tests is reasonable

    classes.dart:

    import 'package:flutter/foundation.dart';
    import 'package:get_it/get_it.dart';
    
    final sl = GetIt.instance;
    
    class YgoCard {
      YgoCard(this.name);
    
      final String name;
    }
    
    abstract class YgoProRemoteDataSource {
      Future<List<YgoCard>> getCardInfo();
    }
    
    class YgoProRemoteDataSourceImpl extends YgoProRemoteDataSource {
      @override
      Future<List<YgoCard>> getCardInfo() {
        return Future.delayed(Duration.zero,
            () => List.generate(5, (index) => YgoCard("Impl $index")));
      }
    }
    
    abstract class YgoProRepository {
      Future<List<YgoCard>> getAllCards();
    }
    
    class YgoProRepositoryImpl implements YgoProRepository {
      final YgoProRemoteDataSource remoteDataSource;
    
      YgoProRepositoryImpl({
        required this.remoteDataSource,
      });
    
      static Future<List<YgoCard>> _fetchCards(
          YgoProRemoteDataSource dataSource) async {
        final cards = await dataSource.getCardInfo();
        cards.sort((a, b) => a.name.compareTo(b.name));
        return cards;
      }
    
      @override
      Future<List<YgoCard>> getAllCards() async {
        final cards = await compute(_fetchCards, remoteDataSource);
        return cards;
      }
    }
    
    void setupLocator() {
      sl.registerLazySingleton<YgoProRepository>(
        () => YgoProRepositoryImpl(
          remoteDataSource: sl(),
        ),
      );
    
      sl.registerLazySingleton<YgoProRemoteDataSource>(
        () => YgoProRemoteDataSourceImpl(),
      );
    }
    

    widget_test.dart:

    import 'package:flutter_test/flutter_test.dart';
    import 'package:test_sample/classes.dart';
    
    import 'package:test_sample/main.dart';
    
    void main() {
      setUpAll(() async {
        setupFakeLocator();
      });
    
      testWidgets('Test mocked data source', (WidgetTester tester) async {
        // Wrapping with runAync() is required to have real async in place
        await tester.runAsync(() async {
          await tester.pumpWidget(const MyApp());
          // Let the isolate spawned by compute() complete, Debug run might require longer wait
          await Future.delayed(const Duration(milliseconds: 500));
          await tester.pumpAndSettle();
          expect(find.text('Fake 9'), findsOneWidget);
        });
      });
    }
    
    class YgoProRemoteDataSourceFake extends YgoProRemoteDataSource {
      @override
      Future<List<YgoCard>> getCardInfo() {
        return Future.delayed(Duration.zero,
            () => List.generate(10, (index) => YgoCard("Fake $index")));
      }
    }
    
    void setupFakeLocator() {
      sl.registerLazySingleton<YgoProRepository>(
        () => YgoProRepositoryImpl(
          remoteDataSource: sl(),
        ),
      );
    
      sl.registerLazySingleton<YgoProRemoteDataSource>(
        () => YgoProRemoteDataSourceFake(),
      );
    }