【Flutter】Widget テストの「あれ、これどうやるんだろう？」集

この記事は [Flutter Advent Calendar 2019]() 3日目 の記事です。

この記事では、自分が実際のアプリのコードで Widget テストをいろいろと書いていて「あれ、これどうやってテストすればいいんだろう？」とつまづいた部分を箇条書きで紹介したいと思います。

なるべく参考にしたサイトを載せているので、この記事はそれぞれの項目の入り口として、リンク先でより詳しく調べる感じで読んでいただけたらと思います。

# Widget テストの基本

Widget テストの基本については、まずは公式ドキュメントをご参照ください。

WidgetTester の基本的な使い方や find による Widget の取得方法、テキスト入力やタップのエミュレート方法が簡潔に書かれています。

[An introduction to widget testing | Flutter](https://flutter.dev/docs/cookbook/testing/widget/introduction)

# 本文

↑ の公式ドキュメントはあくまで Widget テストの導入部分のみです。実際のアプリでは様々なパッケージや Widget を利用し、様々な設計でコードが書かれていくため、上記の内容を基にテストを書いて実行しているとうまくテストできない部分が多々発生します。

その内容と対処方法（とりあえず自分が試しているもの）を以下に挙げていきます。

## MaterialApp で囲う

細分化した Widget を単品でテストする場合、普通に `pumpWidget` をすると以下のエラーが発生します。

```dart
testWidgets('SomethingPartialWidget を単品でテスト', (WidgetTester tester) async {
  await tester.pumpWidget(SomethingPartialWidget());
});
```

```
══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞═══════════════════════════════════════════════════════════
The following assertion was thrown building Text("テスト"):
No Directionality widget found.
RichText widgets require a Directionality widget ancestor.

...省略
```

エラーを読むと __"No Directionality widget found"__ とのことで、[Directionality](https://api.flutter.dev/flutter/widgets/Directionality-class.html) はテキストやそれに関連する UI の方向を決定する Widget です。

文字は英語のように常に「左から右」ではなく、アラビア語のように「右から左」の場合もあるため、このような文字の方向を Directionality で管理している、というワケです。

つまり、今回のエラーはその Directionarity Widget が `SomethingPartialWidget` の親 Widget にいないため、「UI を構築する方向がわからない」ために発生するエラーということになります。

対処方法は実はエラーの少し先の方に書いてあり、

```
...略

Typically, the Directionality widget is introduced by the MaterialApp or WidgetsApp widget at the
top of your application widget tree. 

...略
```

とのことなので、Directionality を提供してくれる `MaterialApp` （もしくは `WidgetApp` や `CupertinoApp`） にはこの `Directionality` で包んであげれば良いでしょう。

```dart
testWidgets('SomethingPartialWidget を単品でテスト', (WidgetTester tester) async {
  await tester.pumpWidget(MaterialApp(
    home: SomethingPartialWidget())
  );
});
```

## L10n 対応

Flutter のドキュメント通りに L10n 対応をしている場合、テスト時も `localizationsDelegates` を指定して Widget が L10n クラスを利用可能にしてあげる必要があります。

```dart
testWidgets('L10n対応した SomethingPartialWidget を単品でテスト', (WidgetTester tester) async {
  await tester.pumpWidget(MaterialApp(
    home: SomethingPartialWidget(),
    localizationsDelegates: [
      const L10nDelegate(),
      GlobalMaterialLocalizations.delegate,
      GlobalWidgetsLocalizations.delegate,
    ],
  );
```

先ほどの `MaterialApp` で包む対応と併せて、このあたりの対応はほとんどの Widget テストで必要になるため、簡単なヘルパーメソッドをどこかに作ってしまうのが良いと思います。

```dart:widget_test_helper.dart
Widget wrapWithMaterial(Widget widget) {
  return MaterialApp(
    home: widget,
    localizationsDelegates: [
      const L10nDelegate(),
      GlobalMaterialLocalizations.delegate,
      GlobalWidgetsLocalizations.delegate,
    ],
  );
}
```

なお、詳細は未調査ですが、私の開発しているアプリでは `pumpWidget()` をした直後に `pumpAndSettle()` も呼んであげないと、L10n の文字列を利用している Text を `find.text()` で見つけられない問題が発生していました。

ということで、実際の Widget テストのコードとしては毎回以下のようにしています。

```dart
testWidgets('"hoge" と表示する Text が存在することをチェック', (WidgetTester tester) async {
  await tester.pumpWidget(Helper.wrapWithMaterial(SomethingPartialWidget());
  await tester.pumpAndSettle();

  expect(find.text('hoge'), findsOneWidget);
});
```

## 通信クラスの Mock

自動テストでは、なるべく別のシステムに依存しない形でテストするのが問題の切り分けや安定したテスト実行環境のために大事とされています。

Dart にも HTTP 通信を Mock するための mockito パッケージが用意されており、それを使うことで通信処理を擬似的に（実際の通信なしに）再現できます。

[Mock dependencies using Mockito | Flutter](https://flutter.dev/docs/cookbook/testing/unit/mocking)

詳細は↑の公式リファレンスに記載の通りのため省略しますが、ひとつ注意点としては、通信処理を担当するクラスの `client` をテスト実行時だけ `MockClient` に差し替えられるような設計にしておくことです。

例えば

```dart:article_request.dart
class ArticleRequest {
  var client = Client();
  Future<ArticleResponse> request() async {
    final response = await client.get([リクエスト先]);
    return ... 何かレスポンスをパースする処理;
  }
}
```

という `client` が public な通信クラスを用意しておき、テスト時だけ

```dart:article_test.dart
testWidget((WidgetTester tester) async {
  model.request.client = MockClient();
  ... テストコード
});
```

という感じに `client` を `MockClient` に差し替える、というのが単純な方法かと思います。

## Image.network で statusCode: 400

`Image.network()` を含む Widget を `pumpWidget()` しようとすると、

```
══╡ EXCEPTION CAUGHT BY IMAGE RESOURCE SERVICE ╞════════════════════════════════════════════════════
The following NetworkImageLoadException was thrown resolving an image codec:
HTTP request failed, statusCode: 400, https://placehold.jp/150x150.png
```

と、ネットワークエラーでテストが失敗します。

Widget テストはデフォルトでは全ての HTTP 通信が `400 - Bad Request` になる仕様になっているらしく、 そのため `Image.network()` での画像の取得が失敗する、ということです。

回避方法としては、 [image_test_utils](https://pub.dev/packages/image_test_utils) パッケージを使う方法があるのですが、メンテがされていないのか `mockito` の `3.0.0` に依存している（この記事の執筆時点での最新は `4.1.1` ）ため、このためだけに mockito のバージョンを 3.0.0 に下げるのは微妙です。

別の方法として、

```
setUpAll(() => HttpOverrides.global = null);
```

というように、全ての HTTP 通信を 400 にする仕組みそのものを無効化する方法もあります。

「テストコードは外部のリソースに依存しない」という原則に則るのであれば、そもそも画像を HTTP で取得しないようにする（もしくはその部分を Mock にするというのが考え方として良さそうですが、ちょっと良い方法が思い浮かんでいないので、何か良い案があればコメントください。

### 参考

[Testing tidbit #2 - Why does using Image.network crash widget tests? | iirokrankka.com](https://iirokrankka.com/2018/09/16/image-network-widget-tests/)

[Flutter Widget Tests with NetworkImage | Stackoverflow](https://stackoverflow.com/questions/49166234/flutter-widget-tests-with-networkimage)


## SharedPreference の Mock

Widget テストは実際の端末でテストする訳ではないため、端末に保存される `SharedPreference` を利用することができません。

[shared_preferences パッケージ](https://pub.dev/packages/shared_preferences) には、保存されている値を Mock するための `setMockInitialValues()` が用意されているので、これを使ってテスト対象が利用する値をあらかじめ設定してあげます。

```
SharedPreferences.setMockInitialValues({"flutter.hoge": false});
```

注意点としては、 `shapred_preferences` パッケージのバージョン `0.5.4+3` 以前を使っている場合、アプリ側のコードで `preferences.getBool('hoge')` というように、キーが `hoge` のデータを取得できるようにするために Mock では `flutter.` プレフィクスをつけて `flutter.hoge` としなければならないということです。

### 参考

[Flutter: testing shared preferences | Stackoverflow](https://stackoverflow.com/a/56043497)

[Make setMockInitialValues handle non-prefixed keys | GitHub](https://github.com/flutter/plugins/commit/da580fceb91d196bba07c1145e3ed9eca1e86d64#diff-2feed463e16d29fe244c64cf7b92a093)

## PackageInfo の Mock

[PackageInfo パッケージ](https://pub.dev/packages/package_info) を利用してバージョンやビルド番号を取得する場合、 Widget テストでは以下のようなエラーになります。

```
ERROR: MissingPluginException(No implementation found for method getAll on channel plugins.flutter.io/package_info)
package:flutter/src/services/platform_channel.dart 314:7  MethodChannel.invokeMethod
```

これも Widget テストが実際にアプリをビルドした上でテストしているワケではないことが原因です。

対応としては、 `getAll()` メソッドが呼ばれたときの返却値をあらかじめ MethodChannel で指定してあげる、という方法があります。

```dart
MethodChannel('plugins.flutter.io/package_info').setMockMethodCallHandler((MethodCall methodCall) async {
  if (methodCall.method == 'getAll') {
    return <String, dynamic>{
      'appName': 'My App Name', 
      'packageName': 'com.example.myapp',
      'version': '1.0.0',
      'buildNumber': 1,
    };
  }
  return null;
});
```

ただし、この方法は「テストごとに値を変更したい」というような場合にうまくいかず、対処方法は調査中です。
自分が MethodChannel をよく理解していないだけな気もしているため、もしなにか良いアイデアがありましたらコメントいただけるととても嬉しいです。

### 参考

[How to test a method that uses package_info in Flutter?](https://stackoverflow.com/questions/54162467/how-to-test-a-method-that-uses-package-info-in-flutter)

## Radio ボタンの状態

`find` を使って取得できるのは `Finder` オブジェクトであり、これは Widget の有無や数などを保持するためのもののため、 Widget そのものの状態を取得することはできません。

「`Radio` ボタンがチェックされているかどうか」などを調べる場合は、 `tester.widget()` を使って Finder オブジェクトから Widget を取得します。

```dart
final radio = tester.widget<Radio>(find.byType(Radio).at(0)));
```

[Radio](https://api.flutter.dev/flutter/material/Radio-class.html) は `value` と `groupValue` が等しいときにチェック状態になるため、「チェックされていること」をテストしたい場合は以下のようになります。

```dart
final radio = tester.widget<Radio>(find.byType(Radio).at(0)));
expect(radio.value == radio.groupValue, true);
```


# まとめ

以上、私が Widget テストを書いていて「あれ、これどうやればいいんだろう？」と思って調べたことをまとめてみました。

Flutter はアプリの実行がとても素早く、とりあえずアプリを実行しながらコードの動作確認をする、という作戦が使いやすいのですが、それでもテストコードを書くことに慣れてくるとテストコードで動作確認してしまってからアプリを作り込んだ方が効率的なことがよくあります。

Flutter に限らずアプリのテストコードを書こうとすると「端末でアプリを実行しないと確認できないこと」に悩まされることがあると思いますが、この記事で紹介した内容が少しでもその悩みを解決できれば嬉しいです。