Mocking cross-platform plugins like url_launcher

Today I wanted to mock url_launcher, but it was not as easy as the docs said.

Here's the code I wanted to test:

Future launchSearch(String word, SearchType searchType) async {
  final url = getSearchUrl(word, searchType);
  Uri uri = Uri.parse(url);
  if (await canLaunchUrl(uri)) {
    await launchUrl(uri);
  } else {
    throw 'Could not launch $url';
  }
}

I wanted to check that it launched the correct URL.

canLaunchUrl and launchUrl use Platform dependent code:

Future<bool> canLaunchUrl(Uri url) async {
  return UrlLauncherPlatform.instance.canLaunch(url.toString());
}

...

Future<bool> launchUrl(
  Uri url, {
  LaunchMode mode = LaunchMode.platformDefault,
  WebViewConfiguration webViewConfiguration = const WebViewConfiguration(),
  BrowserConfiguration browserConfiguration = const BrowserConfiguration(),
  String? webOnlyWindowName,
}) async {
  if ((mode == LaunchMode.inAppWebView ||
          mode == LaunchMode.inAppBrowserView) &&
      !(url.scheme == 'https' || url.scheme == 'http')) {
    throw ArgumentError.value(url, 'url',
        'To use an in-app web view, you must provide an http(s) URL.');
  }
  return UrlLauncherPlatform.instance.launchUrl(
    url.toString(),
    LaunchOptions(...),
  );
}
url_launcher_uri.dart

And the package lets you set the instance:

abstract class UrlLauncherPlatform extends PlatformInterface {
  /// Constructs a UrlLauncherPlatform.
  UrlLauncherPlatform() : super(token: _token);

  static final Object _token = Object();

  static UrlLauncherPlatform _instance = MethodChannelUrlLauncher();

  /// The default instance of [UrlLauncherPlatform] to use.
  ///
  /// Defaults to [MethodChannelUrlLauncher].
  static UrlLauncherPlatform get instance => _instance;

  /// Platform-specific plugins should set this with their own platform-specific
  /// class that extends [UrlLauncherPlatform] when they register themselves.
  // TODO(amirh): Extract common platform interface logic.
  // https://github.com/flutter/flutter/issues/43368
  static set instance(UrlLauncherPlatform instance) {
    PlatformInterface.verify(instance, _token);
    _instance = instance;
  }
  ...
}
url_launcher_platform_interface's url_launcher_platform.dart

And plugin_platform_interface's code explains how to override it in tests:

  /// Ensures that the platform instance was constructed with a non-`const` token
  /// that matches the provided token and throws [AssertionError] if not.
  ///
  /// This is used to ensure that implementers are using `extends` rather than
  /// `implements`.
  ///
  /// Subclasses of [MockPlatformInterfaceMixin] are assumed to be valid in debug
  /// builds.
  ///
  /// This is implemented as a static method so that it cannot be overridden
  /// with `noSuchMethod`.
  static void verify(PlatformInterface instance, Object token) {
    _verify(instance, token, preventConstObject: true);
  }
plugin_platform_interface.dart
/// A [PlatformInterface] mixin that can be combined with fake or mock objects,
/// such as test's `Fake` or mockito's `Mock`.
///
/// It passes the [PlatformInterface.verify] check even though it isn't
/// using `extends`.
///
/// This class is intended for use in tests only.
///
/// Sample usage (assuming `UrlLauncherPlatform` extends [PlatformInterface]):
///
/// ```dart
/// class UrlLauncherPlatformMock extends Mock
///    with MockPlatformInterfaceMixin
///    implements UrlLauncherPlatform {}
/// ```
@visibleForTesting
abstract mixin class MockPlatformInterfaceMixin implements PlatformInterface {}
plugin_platform_interface.dart

Interesting that plugin_platform_interface shows the sample code for the exact same package I am trying to mock!

So I copy/paste this code, and try it in my tests:

main() {
  testWidgets('Search menu icon', (WidgetTester tester) async {
    // Set up UrlLauncher mocks.
    final urlLauncherPlatformMock = UrlLauncherPlatformMock();
    UrlLauncherPlatform.instance = urlLauncherPlatformMock;
    when(urlLauncherPlatformMock.canLaunch(any))
        .thenAnswer((_) async => Future.value(true));
    ...
  });
}

class UrlLauncherPlatformMock extends Mock
    with MockPlatformInterfaceMixin
    implements UrlLauncherPlatform {}

Unfortunately, any throws a compilation error:

The argument type 'Null' can't be assigned to the parameter type 'String'.

This is because Mockito requires the method to accept nullable parameters for any to work. The problem is, the only way to generate methods that accept nullable parameters is to use Mockito's GenerateNiceMocks automated mock generation feature (https://pub.dev/packages/mockito#lets-create-mocks, https://github.com/dart-lang/mockito/blob/master/NULL_SAFETY_README.md).

The problem is, if I use GenerateNiceMocks, then the generated mock will indeed let me use any to capture parameters, but it won't implement the right mixin, and verify will fail.

The solution is to first generate a mock with nullable parameters using GenerateNiceMocks, then subclass that with the right mixin.

import 'package:flashcards/search_list_tile.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:language_picker/languages.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';

@GenerateNiceMocks([MockSpec<UrlLauncherPlatform>()])
import 'search_test.mocks.dart';

main() {
  testWidgets('Search menu icon', (WidgetTester tester) async {
    // Set up UrlLauncher mocks.
    final urlLauncherPlatformMock = MockUrlLauncherPlatformWithMixin();
    UrlLauncherPlatform.instance = urlLauncherPlatformMock;
    when(urlLauncherPlatformMock.canLaunch(any))
        .thenAnswer((_) async => Future.value(true));

    // Render.
    await tester.pumpWidget(MaterialApp(
        home: Scaffold(
            body: Center(
      child: SearchListTile.getSearchMenuIcon(
          Languages.korean, Languages.english, '콧구멍'),
    ))));
    await tester.pump();

    ...

    // Verify.
    verify(urlLauncherPlatformMock.launchUrl(
        'https://dict.naver.com/search.nhn?query=%EC%BD%A7%EA%B5%AC%EB%A9%8D',
        any));
  });
}

class MockUrlLauncherPlatformWithMixin extends MockUrlLauncherPlatform
    with MockPlatformInterfaceMixin {}