🦁

Flutterのテストで得た知見

2023/06/05に公開

始めに

flutter+riverpodでタスク管理アプリを作ったのですが、そのテストを書いて得た知見を書いていきます。

簡単に動く

基本的にテストコードを書けば動きます。単体テスト、Widgetテスト、e2eテスト、全て簡単に動きます。
変に依存性の問題が起きたりはしませんでした。エミュレータ上でのテストも簡単にできたのは素晴らしい。

Isarの問題

ローカルDBとしてIsarを使ったのですが、これがテストでは大きな問題になりました。
具体的にはこの記事に書かれている事には全てはまりました。
https://zenn.dev/flutteruniv_dev/articles/20220613-055442-flutter-isar-test
onTap()とかonPressed()の中身はrunAsync()を使ってもWidgetテストではちゃんと動かないようで困りました。

また、上で挙げた記事にもあるのですがIsarはテスト時にライブラリをダウンロードさせる必要があります。Bitbucketのパイプラインでテストを自動実行しようとしたのですがライブラリのダウンロードが上手く行かず断念しました。ローカルでのテストでも苦労したのでDBの部分はインターフェイス化してテスト用に簡単なDBを作るべきでしたね。

UIとロジックの分離が難しい

riverpodを使うとbuild()内でWidgetRefを使うことが多く、どうしてもUIとロジックの分離が難しくなります。

class SampleWidget extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    return FilledButton(
      onPressed: () {
        // 本当はWidgetからは分離したい
        ref.read(fooProvider.notifier).state = true;
        ref.read(barProvider.notifier).state++;
      },
      child: Text('Button'),
    );
  }
}

可能な限りStateNotifierを使ってロジックは分離したのですが、上の例のように状態を弄る必要がある場合のテストのやり方に悩みました。WidgetテストはWidgetの動きだけ見たいんですが副作用が多すぎるんですよね...
むしろStatefulWidgetを使った方が状態管理とWidget描画を分離しやすかったかもしれません。

  1. StateNotifierや純粋にロジックのみにできた部分は単体テストを実施する。
  2. Widgetの副作用は下記の「状態の見方」の方法で確認する。
  3. e2eテストに任せる。
    という形になりました。

状態の見方

ProviderScopeにProviderContainerのオブジェクトを渡すとproviderの状態を見ることができます。ProviderContainerだとwatch()は使えず、read()のみ使えます。ProviderContainerとWidgetRefには継承関係が無いのでこれを使ってロジックを作るとかはできません。継承関係があればUIとロジックを分ける手段として使えたと思うのですが...

var ref = ProviderContainer();
ProviderScope(
  parent: ref,
  child: SomeWidget(),
);
debugPrint(ref.read(fooProvider).state);
ref.read(fooProvider.notifier).state = 'a';

find.text()に注意

find.text()はサンプルコードにはよく出てくるのですが挙動が少し怪しいです。ダイアログ表示時に後ろのテキストを読んでいたり読んでいなかったり(?)、TextFieldのTextEditingControllerの中身まで読んでいたり読んでいなかったり(?)するようです(要検証)。

特定のWidgetのテキストを確認したい場合はWidgetに一意なkeyを設定し、.evaluate().single.widgetで対象Widgetを取得して直接確認するのが確実です。

// Textの場合
var text = (find.byKey(const Key("Text-1")).evaluate().single.widget as Text).data;
// TextFieldの場合(controllerが設定されている場合)
var text = (find.byKey(const Key("TextField-1")).evaluate().single.widget as TextField).controller?.text;

pump()pumpAndSettle()

pump()はWidgetの再描画を行います。pumpAndSettle()は描画が完了するまでpump()を呼び出し続けます。
基本的にはpump()を使い、アニメーションがある場合にpumpAndSettle()を使います。
経験上、メニューの表示/非表示、ダイアログの非表示[1]の場合にはpumpAndSettle()を使った方が良さそうです。

改良

上記の経験を踏まえ、アプリケーションを作り直しました。

Isarは使わない

Isarはテストで不便だったのでIsarはやめ、tomlというテキストベースのフォーマットで保存するように変更しました。タスク表をgitで管理できるようにする狙いもあります。

パイプラインを設定する

パイプラインに関しても障害となっていたのはIsarだったため、テストの一部はpush時に自動で実行できるようになりました。
Docker Imageとしてfischerscode/flutterを使わせていただいたきました。CMakeやその他必要なパッケージをインストールしてみたのですがe2eテストが実施できませんでした。
flutter doctor でも特に問題が無いのに、 TestDeviceException(Unable to start the app on the device.) とか言われて実行できないとなるとお手上げです。原因くらい教えてくれ。

Bitriseで自動テスト&ビルドを行う

Bitriseで自動テスト&ビルドを構築しました。もともと開発環境がMacだったのと、Flutter関連のワークフローはそれなりに揃っていて割と簡単に構築できました。
ついでにテストのカバレッジの算出もさせることができました。
でも今回みたいな小規模アプリだとわざわざ自動ビルドを動かすより手元の環境でビルドした方が圧倒的に早い...

その他

思いついたら加筆します。

脚注
  1. ダイアログを表示する場合はpump()で良いみたいです。 ↩︎

GitHubで編集を提案

Discussion