juliansteenbakker / mobile_scanner

A universal scanner for Flutter based on MLKit. Uses CameraX on Android and AVFoundation on iOS.
BSD 3-Clause "New" or "Revised" License
888 stars 512 forks source link

MethodChannelMobileScanner.eventsStream never gets destroyed, which makes it really difficult to write tests for the plugin #1042

Open mbtodorov opened 6 months ago

mbtodorov commented 6 months ago

With the latest release of the plugin, the event stream never gets cancelled (even after the controller is stopped) https://github.com/juliansteenbakker/mobile_scanner/blob/master/lib/src/method_channel/mobile_scanner_method_channel.dart#L33

This is not a problem for running the app in production, but if you are writing tests it can be cumbersome, because it means that the listen method is called only once per _test.dart file. Let me try to show an example. Assume the following test setup:

void main() {
  const methodChannel =
      MethodChannel('dev.steenbakker.mobile_scanner/scanner/method');
  const eventChannel =
      EventChannel('dev.steenbakker.mobile_scanner/scanner/event');

  setUp(() {
    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
      ..setMockMethodCallHandler(
        methodChannel,
        (message) async {
          print('method channel method called: ${message.method}');
          final methodName = message.method;
          if (methodName == 'start') {
            return {
              'size': {
                'width': 100.0,
                'height': 100.0,
              },
              'textureId': 1,
            };
          }
          if (methodName == 'state') {
            return MobileScannerAuthorizationState.authorized.rawValue;
          }
          return null;
        },
      )
      ..setMockMethodCallHandler(
        MethodChannel(eventChannel.name, eventChannel.codec),
        (message) {
          print('event channel method called: ${message.method}');
          return null;
        },
      );
  });

  tearDown(() {
    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
      ..setMockMethodCallHandler(methodChannel, null)
      ..setMockStreamHandler(eventChannel, null);
  });

  testWidgets('test one', (tester) async {
    await tester.pumpWidget(SomeWidget()); // assume this widget renders MobileScanner and listens to barcodes stream
  });

  testWidgets('test two', (tester) async {
    await tester.pumpWidget(SomeWidget()); // assume this widget renders MobileScanner and listens to barcodes stream
  });
}

When running these tests, this is what will get printed:

// test one:
event channel method called: listen
method channel method called: state
method channel method called: start

// test two:
method channel method called: state
method channel method called: start

So then the problem is that if you want to send mock events to the event channel, to simulate that a barcode has been scanned, you cant do it in isolation per test when using the setMockStreamHandler method. This is because events are queued up from the onListen method, which means that only the first test will work.

TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
    .setMockStreamHandler(
  EventChannel('dev.steenbakker.mobile_scanner/scanner/event'),
  MockStreamHandler.inline(
    onListen: (_, events) {
      // send a barcode scanned event 1 second
      // after SomeWidget starts listening
      Future.delayed(
        const Duration(seconds: 1),
        () => events.success({
          'name': 'barcode',
          'data': [
            {
              'corners': [
                {'x': 159.0, 'y': 251.0},
                {'x': 291.0, 'y': 295.0},
                {'x': 295.0, 'y': 470.0},
                {'x': 150.0, 'y': 490.0},
              ],
              'format': 256,
              'rawBytes': Uint8List.fromList([1, 2, 3]),
              'rawValue': 'some-value',
              'type': 7,
              'displayValue': 'some-value',
            },
          ],
        }),
      );
    },
  ),
);

Does that make sense? Let me know if I didn't explain it well!

navaronbracke commented 6 months ago

It definitely makes sense. We probably need to dispose the event sink on the native side when the controller is disposed?

mbtodorov commented 6 months ago

Yeah I think that should work nicely!