【Flutter】flutter_app_badgerをテストするためにメソッドチャンネルをmockした話

## はじめに

テストを行う際に特定のパッケージをmockすることは多いです。
代表的な手法は**mockito**を使って対象のパッケージ、クラスをmockすることです。

https://pub.dev/packages/mockito

しかし、今回表題にある**flutter_app_badger**をmockする際に少々手こずったので記事に残したいと思います。


### 記事の対象者

- flutter_app_badgerのmock化にお困りの方
- Method Chanelのmock方法を知りたい方

### 記事を執筆時点での筆者の環境

```

[✓] Flutter (Channel stable, 3.22.1, on macOS 14.3.1 23D60 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 15.2)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2023.3)
[✓] VS Code (version 1.90.1)

```

### flutter_app_badgerとは？

デバイスのホーム画面に表示されるアプリアイコンの通知バッジに対して、簡易的な操作を行うパッケージです。

https://pub.dev/packages/flutter_app_badger

細かいインストール方法や使い方の説明は割愛します。
今回はあくまでテストに関してお話ししますので、パッケージの使用方法などについて詳しくは以下の記事をご覧ください。

https://qiita.com/Riscait/items/fc8797ea144c3598e8a4

## 1. 実装内容

一旦テストコードを見る前に実装内容を簡単にご紹介します。

### 1-1. ディレクトリ構成
今回はレイヤードアーキテクチャにそって実装しています。
riverpodでDIを行っています。
riverpodは自動生成のコードを使用しています。
本来はパッケージのインスタンスを分けて実装しますが、flutter_app_badgerは静的メソッドのみでインスタンスを持っていません。
よって今回はlocal_sourcesディレクトリを使わずリポジトリ層で直接メソッドを呼び出しています。

:::note
この「静的メソッド」という部分が曲者です。
:::

```

├── local_sources
│   └── // <===== 💡 本来であればここにapp_badger.dartでインスタンスを管理するが今回はなし
└── repositories
    └── app_badger_repository
        ├── provider.dart
        ├── provider.g.dart
        └── repository.dart

```

### 1-2. repositoryとprovider

```provider.dart

@Riverpod(keepAlive: true)
AppBadgerRepositoryBase appBadgerRepository(AppBadgerRepositoryRef ref) {
  return AppBadgerRepository(ref);
}

```

```repository.dart

abstract interface class AppBadgerRepositoryBase {
  Future<void> updateBadge({required int count});
}

class AppBadgerRepository implements AppBadgerRepositoryBase {
  AppBadgerRepository(this.ref);
  final ProviderRef<dynamic> ref;

  @override
  Future<void> updateBadge({required int count}) async {
    // FlutterAppBadgerがサポートできるかどうか確認
    final isSupported = await FlutterAppBadger.isAppBadgeSupported();
    logger.d('isAppBadgeSupported: $isSupported');
    if (isSupported) {
      await FlutterAppBadger.updateBadgeCount(count);
    } else {
      logger.d('サポート対象外のデバイスです');
    }
  }
}

```
### 1-3. 使用例

```life_cycle.dart

class LifeCycle extends HookConsumerWidget {
  const LifeCycle({required this.child, super.key});

  final Widget child;
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    useOnAppLifecycleStateChange((_, AppLifecycleState currentState) {
      // アプリがバックグラウンドからフォアグラウンドに移動した時の処理
      if (currentState == AppLifecycleState.resumed) {
        // 通知のバッジをリセット
        ref.read(appBadgerRepositoryProvider).updateBadge(count: 0);
      }
    });

    return child;
  }
}

```


## 2. テストコードの解説

先ほども述べましたが、flutter_app_badgerの機能、メソッドは全て静的メソッドです。
つまり、インスタンスがないのでmockitoが使えません。
他のパッケージでも静的メソッドで定義しているケースはあるものの、テスト用のインスタンスを取得する方法が用意されていたりします。
しかしこのパッケージに関しては完全に手段がありません。
そこで、大元の`MethodChanel`をmockする方法をとります。 

### 2-1. テストコード全体

```repository_test.dart

void main() {
  late ProviderContainer container;
  const channel = MethodChannel('g123k/flutter_app_badger');
  final log = <MethodCall>[];
  const upDateBadgeCount = 'updateBadgeCount';
  const removeBadge = 'removeBadge';
  const isAppBadgeSupported = 'isAppBadgeSupported';
  
  TestWidgetsFlutterBinding.ensureInitialized();
  
  setUp(() {
    container = ProviderContainer();

    // メソッドチャネルのレスポンスを設定
    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
        .setMockMethodCallHandler(channel, (MethodCall methodCall) async {
      switch (methodCall.method) {
        case upDateBadgeCount:
          final args = methodCall.arguments as Map<dynamic, dynamic>;
          final count = args['count'] as int;
          expect(count, 0);
          log.add(methodCall);
          return null;
        case removeBadge:
          log.add(methodCall);
          return null;
        case isAppBadgeSupported:
          log.add(methodCall);
          return true;
        default:
          return null;
      }
    });
  });

  tearDown(() {
    container.dispose();
    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
        .setMockMethodCallHandler(channel, null);
    log.clear();
  });

  test('サポートされているデバイスであれば通知のバッジをアップデートできる', () async {
    await container.read(appBadgerRepositoryProvider).updateBadge(count: 0);

    expect(log, hasLength(2));
    expect(log[0].method, isAppBadgeSupported);
    expect(log[1].method, upDateBadgeCount);
    expect(log[1].arguments, {'count': 0});
  });
}


```

### 2-2. 部分解説

#### 内部実装の確認
まずはflutter_app_badgerの内部実装を見て必要な情報を控えます。
必要なのは`MethodChannel`に設定されている文字列と、各メソッドのキーになっている文字列です。

```.pub-cache/hosted/pub.dev/flutter_app_badger-1.5.0/lib/flutter_app_badger.dart
import 'dart:async';

import 'package:flutter/services.dart';

class FlutterAppBadger {
  static const MethodChannel _channel =
                            // 👇 これ！
      const MethodChannel('g123k/flutter_app_badger');

  static Future<void> updateBadgeCount(int count) {
                                    // 👇 これ！
    return _channel.invokeMethod('updateBadgeCount', {"count": count});
  }

  static Future<void> removeBadge() {
                                    // 👇 これ！
    return _channel.invokeMethod('removeBadge');
  }

  static Future<bool> isAppBadgeSupported() async {
                                                            // 👇 これ！
    bool? appBadgeSupported = await _channel.invokeMethod('isAppBadgeSupported');
    return appBadgeSupported ?? false;
  }
}


```

#### 定数を準備
`MethodChannel`とそれぞれのメソッドを呼び出すキーとなる文字列を定数化しておきます。
更にメソッドが呼ばれたら値を格納する配列を`final log = <MethodCall>[];`で定義します。

```dart

  const channel = MethodChannel('g123k/flutter_app_badger');
  final log = <MethodCall>[];
  const upDateBadgeCount = 'updateBadgeCount';
  const removeBadge = 'removeBadge';
  const isAppBadgeSupported = 'isAppBadgeSupported';
  
```

`setUp`内でMethodChanelのスタブを作成します。これはmockitoでいうところの`when()`です。
`TestDefaultBinaryMessengerBinding`を使ってレスポンスを設定していきます。
各ケース内でそれぞれのメソッドが実行された時に`log.add(methodCall);`で配列内に格納していっています。

```dart

  setUp(() {
    container = ProviderContainer();

    // メソッドチャネルのレスポンスを設定
    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
        .setMockMethodCallHandler(channel, (MethodCall methodCall) async {
      switch (methodCall.method) {
        case upDateBadgeCount:
          final args = methodCall.arguments as Map<dynamic, dynamic>;
          final count = args['count'] as int;
          expect(count, 0);
          log.add(methodCall);
          return null;
        case removeBadge:
          log.add(methodCall);
          return null;
        case isAppBadgeSupported:
          log.add(methodCall);
          return true;
        default:
          return null;
      }
    });
  });

```

あとはメソッドが呼ばれたかどうかを`log`を使って検証すれば完了です。


```dart
 test('サポートされているデバイスであれば通知のバッジをアップデートできる', () async {
    await container.read(appBadgerRepositoryProvider).updateBadge(count: 0);

    expect(log, hasLength(2));
    expect(log[0].method, isAppBadgeSupported);
    expect(log[1].method, upDateBadgeCount);
    expect(log[1].arguments, {'count': 0});
  });
  
```

## 終わりに

いかがだったでしょうか？
正直、このようにMethodChanelをmockすることはかなりのレアケースだと思います。
MethodChanelをmockできることを知っていればいざという時に対応できるので、
頭の片隅にでも留めておきましょう。

この記事が誰かのお役に立てれば幸いです。

## 参考記事

https://stackoverflow.com/questions/43897734/how-can-i-mock-stub-out-a-flutter-platform-channel-plugin