【Flutter】url_launcher 最新版推奨APIのモック化・呼び出し検証（verify）


## 概要

url_launcher を mockito でモック化し、指定のURLが開かれたことをウィジェットテストで検証しようと思い方法を探したところ下記の記事を見つけましたが、

https://qiita.com/greendrop/items/f1f1ca9bb10528f8b221

これで対象としている `canLaunch()`, `launch()` は記事作成時点の最新版[^1] では[非推奨](https://github.com/flutter/packages/blob/url_launcher-v6.3.1/packages/url_launcher/url_launcher/lib/src/legacy_api.dart#L65)[^2] なので
推奨の `canLaunchUrl()`, `launchUrl()` （以下「推奨API」と記します）で同様に検証する方法がないか探ったところ突き止めることができたので以下に紹介します。


## 環境

```console:$ flutter doctor
[√] Flutter (Channel stable, 3.24.4, on Microsoft Windows [Version 10.0.19045.5487], locale ja-JP)
[√] Windows Version (Installed version of Windows is version 10 or higher)
[√] Android toolchain - develop for Android devices (Android SDK version 33.0.1)
[√] Chrome - develop for the web
[√] Visual Studio - develop Windows apps (Visual Studio Community 2022 17.3.6)
[!] Android Studio (version 2021.3)
    X Unable to determine bundled Java version.
[√] Android Studio (version 2024.1)
[√] VS Code (version 1.97.0)
[√] VS Code, 64-bit edition (version 1.64.2)
[√] Connected device (3 available)
[√] Network resources
```

```yaml:pubspec.yaml（抜粋）
  url_launcher: ^6.3.1

dev_dependencies:
  build_runner: ^2.4.13
  mockito: ^5.4.4
  test: ^1.25.7
```


## （先に）結論

下記のコードで推奨APIが呼び出されたことの検証が可能でした。

```dart
class FakeUrlLauncherPlatform extends Fake
  with MockPlatformInterfaceMixin
  implements UrlLauncherPlatform {}  // ここは冒頭参考記事と同じ

const linkUrl = 'https://www.hogehoge.com/';

// モックインスタンスの生成と挙動設定  setUp() 等で実行しても勿論OK
var mockUrlLauncherPlatform = MockFakeUrlLauncherPlatform();
UrlLauncherPlatform.instance = mockUrlLauncherPlatform;
when(mockUrlLauncherPlatform.canLaunch(linkUrl))
  .thenAnswer((_) async => true);  // <= ポイント1（後述）
when(mockUrlLauncherPlatform.launchUrl(linkUrl, any))
  .thenAnswer((_) async => true);  // <= ポイント2（後述）

// url_launcher が実行されるはずのコードをこの間に記載

// url_launcher（モック）が実行されたことの確認
verify(mockUrlLauncherPlatform.canLaunch(linkUrl));  // <= ポイント1（後述）
verify(mockUrlLauncherPlatform.launchUrl(linkUrl, any));  // <= ポイント2（後述）
```


## 解説
### url_launcher （とそのモック）の裏側

build_runner で url_launcher_screen_test.mocks.dart を生成すると `MockFakeUrlLauncherPlatform` クラスは下記メソッドを持っていることが分かります。[^3]
<details><summary>メソッドのリスト（クリックで展開）</summary>

```dart:MockFakeUrlLauncherPlatform のメソッドリスト
canLaunch(String? url)
launch(String? url, {
  required bool? useSafariVC,
  required bool? useWebView,
  required bool? enableJavaScript,
  required bool? enableDomStorage,
  required bool? universalLinksOnly,
  required Map<String, String>? headers,
  String? webOnlyWindowName,
})
launchUrl(String? url, LaunchOptions? options)
closeWebView()
supportsMode(PreferredLaunchMode? mode)
supportsCloseForMode(PreferredLaunchMode? mode)
```
</details>

ただ、よく見てみると推奨APIとは引数 `url` の型が違ったり（`Uri`⇔`String?`）、canLaunch __Url__ () に至っては存在すらしません。
そのため `MockFakeUrlLauncherPlatform` に対し推奨APIと同じ名前・引数で `when` や `verify` を設定してみてもコンパイルに失敗します。

そこで今度は推奨API自体を url_launcher のソースから探してみると
[url_launcher_uri.dart](https://github.com/flutter/packages/blob/url_launcher-v6.3.1/packages/url_launcher/url_launcher/lib/src/url_launcher_uri.dart) に定義があり
内側では `UrlLauncherPlatform.instance` の `canLaunch()`[^4] `launchUrl()` を実行していることが見て取れます。
テストでは `UrlLauncherPlatform.instance` == `MockFakeUrlLauncherPlatform` なので、これがどう実行されているか読み解くことでモック化・呼出検証の方法が分かりました。


### テスト実装時のポイント

以上を踏まえ、テストコード例中のポイントを補記すると

1. `canLaunchUrl()` は内部では非推奨のAPIと変わらず `UrlLauncherPlatform.instance.canLaunch()` を実行しているので、推奨API `canLaunchUrl()` でプロダクトを実装していても
テストでのモック呼出検証は冒頭の参考記事と全く同様に `canLaunch()` に対し `when`・`verify` を行えばOKでした。
2. 内部で実際に叩かれる `UrlLauncherPlatform.instance.launchUrl()` の第2引数 `options` は
推奨APIの `launchUrl()` にはない上に nullable なのでうっかり省略したくなりますが、略すと `when`・`verify` ではコンパイルが通らないので何らかの指定が必要です。
とは言え単純に url_launcher で指定のURLが開かれたことを検証したいだけなら特定の値を指定する必要がないので `any` としておけば問題ありません[^5]。


## テストコード全文

```dart:url_launcher_screen_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';

import 'url_launcher_screen.dart';  // テスト対象画面（詳細コードは割愛）
import 'url_launcher_screen_test.mocks.dart';

class FakeUrlLauncherPlatform extends Fake
  with MockPlatformInterfaceMixin
  implements UrlLauncherPlatform {}

@GenerateNiceMocks([MockSpec<FakeUrlLauncherPlatform>()])  //★（下方に補足あり）
void main() {
  const linkUrl = 'https://www.hogehoge.com/';
  late MockFakeUrlLauncherPlatform mockUrlLauncherPlatform;

  setUp(() {
    // モックインスタンスの生成と挙動設定
    mockUrlLauncherPlatform = MockFakeUrlLauncherPlatform();
    UrlLauncherPlatform.instance = mockUrlLauncherPlatform;
    when(mockUrlLauncherPlatform.canLaunch(linkUrl))
      .thenAnswer((_) async => true);  // <= ポイント1
    when(mockUrlLauncherPlatform.launchUrl(linkUrl, any))
      .thenAnswer((_) async => true);  // <= ポイント2
  });

  testWidgets('Testing Open In Browser', (tester) async {
    // URLをurl_launcherで開くボタン/リンクを持つテスト対象画面のロード
    await tester.pumpWidget(const MaterialApp(home: UrlLauncherScreen(url: linkUrl)));

    // url_launcherを実行するアクションを実行（この場合アイコンボタンをタップ）
    await tester.tap(find.byIcon(Icons.open_in_browser));
    await tester.pump();

    // url_launcher が実行されたことの確認
    verify(mockUrlLauncherPlatform.canLaunch(linkUrl));  // <= ポイント1
    verify(mockUrlLauncherPlatform.launchUrl(linkUrl, any));  // <= ポイント2
  });
}
```

★：冒頭参考記事では `@GenerateMocks` annotation でモックを generate しているところ
現行 mockito の推奨 annotation である `@GenerateNiceMocks` を用いても問題なかったのでその旨も付記しておきます。


[^1]: v6.3.1
[^2]: かと言ってテストのためにプロダクトコードを非推奨メソッドに差替えるのはあまりに下策…
[^3]: [UrlLauncherPlatform](https://github.com/flutter/packages/blob/url_launcher-v6.3.1/packages/url_launcher/url_launcher_platform_interface/lib/src/url_launcher_platform.dart) クラスのメソッドとほぼ（コンストラクタと getter/setter を除けば引数等のI/Fまで全く）同じで、これを元にモックが生成されていることが分かる
[^4]: canLaunch **Url** () ではないことに注意
[^5]: 細かく追ってないですが `options` はプラットフォーム等の条件によって変化するように見えるので寧ろ `any` にしておかないとテストが不安定になるかもしれません