【Flutter】ウィジェットテストを行う方法まとめ

Flutterでウィジェットテストをする方法についてまとめました。

Flutter 3.19.4

準備

本記事ではFlutterでウィジェットテスト(ボタンをクリックした時の挙動などウィジェットの動作を絡めたテスト)をする方法についてまとめます。
Flutterにおける単体テストの知識が前提となりますので、必要に応じて以下の記事を参照してください。

light11.hatenadiary.com

まず準備として、テストするための簡単なアプリケーションを作っておきます。

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Example',
      home: CounterPage(),
    );
  }
}

class CounterPage extends StatefulWidget {
  const CounterPage({super.key});

  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        child: const Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

これを実行すると、下図のようにボタン押下で値をインクリメントするアプリケーションが動作することを確認できます。

アプリケーション

簡単なテストを作成する

それでは前節で作成したウィジェットをテストしてみます。
ボタンを押下して、その結果としてテキストとして表示される数値がインクリメントされることを確認します。

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

import 'package:test_flutter_2/main.dart';

void main() {
  // testWidgets関数内にテストケースを書く
  testWidgets('Counter increments', (WidgetTester tester) async {

    // まずpumpWidgetにテストしたいWidgetを渡す
    await tester.pumpWidget(const MyApp());

    // 事前条件をテスト
    expect(find.text('0'), findsOne); // 0が表示されているテキストが1つあることを確認
    expect(find.text('1'), findsNothing); // 1が表示されているテキストが1つもないことを確認

    // addアイコンを探してタップする
    await tester.tap(find.byIcon(Icons.add));
    await tester.pump(); // pumpでフレームを進めて再描画する(タップの影響を反映する)

    // 事後条件をテスト
    expect(find.text('0'), findsNothing); // 0が表示されているテキストが1つもないことを確認
    expect(find.text('1'), findsOne); // 1が表示されているテキストが1つあることを確認
  });
}

説明はコメントに書いたとおりです。
実行方法は単体テストと同様なので割愛します。

さて、ウィジェットテストにおける重要な概念として以下が挙げられます。

  • ウィジェットを見つけるFinder(上述のfind.text()など)
  • 条件を作るMatcher(上述のfindsOneなど)
  • フレームを進めるpump

以下、これらについてまとめていきます。

いろいろなFinder

前節の例のfind.text()のように、目的のウィジェットを探すためにはFinderクラスを使います。
find.textの他にも、上の例で使用しているアイコンからウィジェットを探すfind.byIconや、画像を探すfind.imageなど複数のFinderがあります。

ちなみにfindCommonFindersクラスのインスタンスなので、以下のドキュメントを見ると何ができるか大体わかると思います。

api.flutter.dev

ここでは一つ一つまとめることはしませんが、個人的にはこういう系は、概念や用途だけざっと把握しておいて、あとはやりたいことをGPTに聞くのが早いと思います。
そういうのがお嫌いでなければおすすめです。

いろいろなMatcher

前述の例のfindsOneのように、ウィジェットを絡めたテストの条件を作るのにはMatcherクラスのインスタンスが使えます。
findsOnefindNothingの他に以下のようなものがあります。

  • findsAny: 一つでもあればOK
  • findsExactly: 指定した個数あればOK
  • findAtLeast: 指定した個数以上あればOK
  • その他いろいろ

これらはflutter_testmatchers.dartに定義してあるので、そちらを参照すると何ができるか大体わかると思います。

こちらも前節同様細かくはまとめません。

pumpメソッドとpumpAndSettleメソッド

アプリケーション実行時と違って、テスト中はウィジェットの再構築・再描画が自動的に行われません。
tester.pump()メソッドを呼び出すと、フレームが進んでウィジェットがレイアウトし直され、描画し直されます。
上述の例でタップをした後にtest.pump()を呼び出しているのは、テスト対象のテキストを再描画させるためです。

ここで、数フレームにわたるアニメーションが発生するケースを考えます。
以下はボタンを押すと背景色がアニメーションしながら切り替わる例です。

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Example',
      home: ExamplePage(),
    );
  }
}

class ExamplePage extends StatefulWidget {
  const ExamplePage({Key? key}) : super(key: key);

  @override
  State<ExamplePage> createState() => _ExamplePageState();
}

class _ExamplePageState extends State<ExamplePage> {
  var _backgroundColor = Colors.white;

  void _changeBackgroundColor() {
    setState(() {
      if (_backgroundColor == Colors.black) {
        _backgroundColor = Colors.white;
      } else {
        _backgroundColor = Colors.black;
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedContainer(
      key: const Key('animated_container'),
      duration: const Duration(milliseconds: 500),
      color: _backgroundColor,
      child: Scaffold(
        backgroundColor: Colors.transparent,
        floatingActionButton: FloatingActionButton(
          onPressed: _changeBackgroundColor,
          child: const Icon(Icons.color_lens),
        ),
      ),
    );
  }
}

実行結果は下図のとおりです。

実行結果

さて、テストにおいてこのような数フレームにわたるアニメーションを待機する場合には、一度再描画するだけのtester.pump()は使えません。
このようなケースでは以下のようにtester.pumpAndSettle()メソッドを使う必要があります。

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

import 'package:test_flutter_2/main.dart';

void main() {
  // testWidgets関数内にテストケースを書く
  testWidgets('Counter increments', (WidgetTester tester) async {

    // まずpumpWidgetにテストしたいWidgetを渡す
    await tester.pumpWidget(const MyApp());

    var beforeContainer = tester.widget<AnimatedContainer>(find.byKey(const Key('animated_container')));
    var beforeDecoration = beforeContainer.decoration as BoxDecoration;

    // 色が白であることを確認
    expect(beforeDecoration.color, Colors.white);

    // ボタンをタップ
    await tester.tap(find.byType(FloatingActionButton));

    // アニメーションが完了するまで待つ
    await tester.pumpAndSettle();

    // 色が黒に変わっていることを確認
    var afterContainer = tester.widget<AnimatedContainer>(find.byKey(const Key('animated_container')));
    var afterDecoration = afterContainer.decoration as BoxDecoration;
    expect(afterDecoration.color, Colors.black);
  });
}

参考

api.flutter.dev

docs.flutter.dev

api.flutter.dev