在 isolate 中使用 class 的特定实例

Use a specific instance of a class inside an isolate

我正在通过 compute() 方法使用隔离来从 API(大约 10k 个条目)中获取、解析和排序数据。

我的方法 getAllCards() 是在 class YgoProRepositoryImpl 中定义的,它有一个我的远程数据源的实例 class YgoProRemoteDataSource 它在这个 class 调用我的 API 的方法已定义(这是一个简单的 GET 请求)。

代码示例

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());
  
  // ...
}

代码工作正常,但 getAllCards() 不可测试,因为我无法在我的 isolate 中注入 YgoProRemoteDataSource 的模拟 class,因为它总是从我的服务定位器获得引用.

我该怎么做才能不依赖我的服务定位器将 YgoProRemoteDataSource 注入到我的 isolate 中并使 getAllCards() 可测试?

据我所知,您有两个选择,要么通过参数注入 static Future<List<YgoCard>> _fetchCards(_) async 所需的依赖项,要么在定位器本身中模拟对象。我会选择第一个选项,并有类似的东西:

  static Future<List<YgoCard>> _fetchCards(_,YgoProRemoteDataSource remote) async {
    // No need to set up locator as you passed the needed dependencies
    // setupLocator();
    final cards = await remote
        .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;
  }

编辑

刚刚更新了答案,因为在这里比在评论中更容易编辑...

嗯,我能想到的唯一解决方法是将 setupLocator() 函数作为参数传递给 class YgoProRepositoryImpl :

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

这样您就可以传递一个模拟来设置您的模拟 classes 或 service_locator.dart 的真实 setupLocator。这可能不太优雅。但它应该使它可以测试,因为现在你可以模拟设置并且它没有在函数中硬编码

您真的需要测试 getCards() 功能吗? 你在那里真正测试什么? compute 有效,当然希望 Dart SDK 团队对此进行测试。

剩下 _fetchCards()setupLocator() 也不需要测试,它是您测试逻辑的前提条件。无论如何,您都想更改测试设置。

所以你真正想要测试的是抓取和排序。将其重组为可测试的静态函数并预先设置您的定位器。在上面加上 @visibleForTesting 注释。

附带说明一下,这取决于您在服务定位器中绑定的数量,这对于之后仅使用一个存储库来说可能是巨大的开销。

示例:

  static Future<List<YgoCard>> _fetchCards(_) async {
    // As I'm inside an isolate I need to re-setup my locator
    setupLocator();
    return reallyFetchCards();
  }

  @visibleForTesting
  static Future<List<YgoCard>> reallyFetchCards() async {
    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;
  }

测试:


// Setup SL and datasource
...

final cards = await YgoProRepositoryImpl.reallyFetchCrads();

// Expect stuff

做了更认真的尝试,请看回购:https://github.com/maxim-saplin/compute_sl_test_sample

从本质上讲,Flutter/Dart 的当前状态你既不能通过闭包,也不能通过包含跨隔离边界的闭包的 类(但是当 Dart 中的新功能登陆 Flutter 时,这可能会改变 https://github.com/dart-lang/sdk/issues/46623#issuecomment-916161528).这意味着如果您不希望任何测试代码成为发布版本的一部分,您就无法通过服务定位器(其中包含闭包)或欺骗隔离以通过闭包实例化定位器的测试版本。然而,您可以轻松地将数据源实例传递到 isolate 以在其入口点用作参数。

此外,我认为要求 isolate 重建整个服务定位器没有意义。 compute() 背后的整个想法是创建一个短暂的离开隔离,运行 计算,return 结果并终止隔离。初始化定位器是一种开销,最好避免。此外,似乎 compute() 的整个概念尽可能与应用程序的其余部分隔离。

您可以克隆存储库和 运行 测试。关于示例的几句话:

  • 基于 Flutter 计数器启动器应用程序
  • lib/classes.dart 重新创建您提供的代码片段
  • test/widget_test.dart 验证 YgoProRepositoryImpl 与隔离 运行 伪造的数据源版本
  • 一起工作正常
  • YgoProRemoteDataSourceImpl 模拟真实实现,位于 classes.dart 和 YgoProRemoteDataSourceFake 模拟测试版本
  • 运行 isolates under flutter_test 需要在 tester.runAsync() 中包装测试主体,以便实时异步执行(而不是测试默认使用假异步并依赖于抽取到进度测试时间)。 运行 这种模式下的测试可能会很慢(实际等待 0.5 秒),以一种在许多测试中未使用或未测试 compute() 的方式构建测试是合理的

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(),
  );
}