🧪

【Flutter】Widget Test に触れてみる

2024/12/18に公開

初めに

今回は Widget Test について簡単に触れてみたいと思います。
筆者自身 Widget Test を実装する機会が少なかったため、基本的な実装方法等をまとめておきたいと思います。
今回は公式のドキュメントをもとに実装を進めていきます。ユーザーの複雑な操作のシミュレーション等は行わないので、ご了承いただければと思います。

記事の対象者

  • Flutter 学習者
  • Widget Test に触れてみたい方

目的

今回の目的は先述の通り、 Widget Test の基礎的な実装方法を知ることです。
公式ドキュメントとして公開されている An introduction to widget testing を導入として、簡単なTodoアプリの Widget Test ができるまで実装を進めていきます。

Widget Test とは

そもそも Widget Test とは、Flutter 固有のテストであり、各 Widget を個別にテストすることでそれぞれ正常に動作しているか、表示されているかを検証することができます。

https://codelabs.developers.google.com/codelabs/flutter-app-testing?hl=ja#5

シンプルな実装

まずは An introduction to widget testingのドキュメントを例に取りながらシンプルな WidgetTest の実装を行います。

ドキュメントでは、以下のように外部から titlemessage を受け取って表示させる Widget が例として挙げられています。

lib/my_widget.dart
import 'package:flutter/material.dart';

class MyWidget extends StatelessWidget {
  const MyWidget({
    super.key,
    required this.title,
    required this.message,
  });

  final String title;
  final String message;

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: Scaffold(
        appBar: AppBar(
          title: Text(title),
        ),
        body: Center(
          child: Text(message),
        ),
      ),
    );
  }
}

この Widget の WidgetTest は、 test ディレクトリで以下のように記述されています。

test/my_widget_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:sample_flutter/widget_test/my_widget.dart';

void main() {
  testWidgets('SimpleWidget has a title and message', (tester) async {
    await tester.pumpWidget(const MyWidget(title: 'T', message: 'M'));

    final titleFinder = find.text('T');
    final messageFinder = find.text('M');

    expect(titleFinder, findsOneWidget);
    expect(messageFinder, findsOneWidget);
  });
}

以下でテストコードを詳しくみていきます。

以下では testWidgets メソッドでテストの定義をしています。
第一引数にはテストの概要を記載し、何をテストするのかを明確にします。
第二引数では WidgetTester を受け取ることができます。これを使ってテストを実装していきます。

また、 tester.pumpWidget で引数に入れた Widget をビルド、描画することができます。このメソッドにテスト対象となる Widget を渡すことでテストすることができます。
以下では titleTmessageM という文字列を渡しています。

testWidgets('SimpleWidget has a title and message', (tester) async {
  await tester.pumpWidget(const MyWidget(title: 'T', message: 'M'));

次に以下の部分では、 find.text でそれぞれ「T」「M」というテキストが存在するかどうかを確かめています。 flutter_test には Finders という仕組みが用意されており、これによって Widget を探すことができます。 find.text 以外にも Widget を探すための様々なメソッドが用意されています。

例えば、 MyWidget には AppBar が含まれるため、コメントアウトしてある部分のように byType メソッドで AppBar を探すこともできます。

final titleFinder = find.text('T');
final messageFinder = find.text('M');
// final appBarFinder = find.byType(AppBar);

最後に、以下のように expect メソッドを実行しています。
expect の第一引数は actual、第二引数は matcher と呼ばれています。
actual はテストの結果得られた実際の値、 matcher は期待される値を表します。

以下では titleFinder という Finder オブジェクトの期待される結果が findsOneWidget、つまり一つ存在するということを示しています。

expect(titleFinder, findsOneWidget);
expect(messageFinder, findsOneWidget);

Todo アプリのテスト

次に Todo アプリのテストを実装してみたいと思います。
テスト対象の画面のコードは以下の通りです。

import 'package:flutter/material.dart';

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

  
  TodoScreenState createState() => TodoScreenState();
}

class TodoScreenState extends State<TodoScreen> {
  final List<String> _todos = [];
  final TextEditingController _controller = TextEditingController();

  void _addTodo() {
    final String todo = _controller.text.trim();
    if (todo.isNotEmpty) {
      setState(() {
        _todos.add(todo);
        _controller.clear();
      });
    }
  }

  void _deleteTodo(int index) {
    setState(() {
      _todos.removeAt(index);
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Todo List'),
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _controller,
                    decoration: const InputDecoration(
                      labelText: 'Enter Todo',
                      border: OutlineInputBorder(),
                    ),
                    onSubmitted: (_) => _addTodo(),
                  ),
                ),
                const SizedBox(width: 8),
                ElevatedButton(
                  onPressed: _addTodo,
                  child: const Text('Add'),
                ),
              ],
            ),
          ),
          Expanded(
            child: _todos.isEmpty
                ? const Center(child: Text('No todos yet.'))
                : ListView.builder(
                    itemCount: _todos.length,
                    itemBuilder: (context, index) {
                      return ListTile(
                        title: Text(_todos[index]),
                        trailing: IconButton(
                          icon: const Icon(Icons.delete, color: Colors.red),
                          onPressed: () => _deleteTodo(index),
                        ),
                      );
                    },
                  ),
          ),
        ],
      ),
    );
  }
}

表示は以下のようになります。

初期状態 追加時 削除時

このTodoアプリのクリアすべき要件として以下の四つを定めます。

  1. 初期状態でTodoが表示されない
  2. Todoの追加が正しく行われる
  3. Todoの削除が正しく行われる
  4. 空のTodoが追加されない

それぞれテストを追加していきます。

1. 初期状態でTodoが表示されない

まずは初期状態で Todo が表示されないことを確認します。
以下のようなテストコードを追加します。

pumpWidgetTodoScreen をビルド、描画し、初期状態では「No todos yet.」というテキストが表示されていることと ListTile が存在しないことを確認しています。

Widget が存在しないことを確かめるには findsNothing という Matcher が使用できます。

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sample_flutter/widget_test/todo_screen.dart';

void main() {
  group('TodoScreen のテスト', () {
    testWidgets('初期状態でTodoが表示されない', (tester) async {
      await tester.pumpWidget(const MaterialApp(home: TodoScreen()));

      expect(find.text('No todos yet.'), findsOneWidget);
      expect(find.byType(ListTile), findsNothing);
    });
  });
}

2. Todoの追加が正しく行われる

次に Todo の追加が行われることを確認します。
以下のようにコードを追加していきます。

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sample_flutter/widget_test/todo_screen.dart';

void main() {
  group('TodoScreen のテスト', () {
    testWidgets('初期状態でTodoが表示されない', (tester) async {
      await tester.pumpWidget(const MaterialApp(home: TodoScreen()));

      expect(find.text('No todos yet.'), findsOneWidget);
      expect(find.byType(ListTile), findsNothing);
    });

+   testWidgets('Todoの追加が正しく行われる', (tester) async {
+     await tester.pumpWidget(const MaterialApp(home: TodoScreen()));

+     await tester.enterText(find.byType(TextField), '新しいTodo');
+     await tester.tap(find.text('Add'));
+     await tester.pump();

+     expect(find.text('新しいTodo'), findsOneWidget);
+     expect(find.text('No todos yet.'), findsNothing);
+     expect(find.byType(ListTile), findsOneWidget);
+   });
  });
}

それぞれ詳しくみていきます。

enterText メソッドでは、名前の通りテキストを入力することができます。
第一引数にはテキストを入力するためのテキストフィールドを渡します。今回の Todo アプリはテキストフィールドは一つだけなので、 find.byType(TextField) で見つけられます。

第二引数には入力するテキストを指定します。以下の例では「新しいTodo」というテキストを入力しています。

await tester.enterText(find.byType(TextField), '新しいTodo');

以下では Todo の追加ボタンを押しています。
tester.tap メソッドではボタンなどの画面内の要素をタップすることができます。
以下の例では「Add」というテキストを探して、その部分をタップしています。

なお、今回のアプリでは ElevatedButton が画面内に一つしか存在しないため、コメントアウトしてある部分のように ElevatedButton を探してタップする形でも問題なく動作します。

await tester.tap(find.text('Add'));

以下では tester.pump メソッドでウィジェットを再構築させています。
この画面では、tester.tap(find.text('Add')); メソッドを呼び出した段階で Todo が追加され、画面に表示されます。しかし、厳密には tap メソッドを実行した瞬間ではなく、少し時間が経ってから Todo が表示されます。

したがって、 pump メソッドを呼び出すことで Todo が表示されるまでのわずかな時間を待つことでウィジェットが再構築されます。

await tester.pump();

以下では「新しいTodo」が追加されているかどうかと「No todos yet.」が表示されていないか、そして ListTile が一つあるかどうかを確認しています。

expect(find.text('新しいTodo'), findsOneWidget);
expect(find.text('No todos yet.'), findsNothing);
expect(find.byType(ListTile), findsOneWidget);

3. Todoの削除が正しく行われる

次に Todo が正しく削除されるかどうかをテストしてきます。
以下のようにコードを追加していきます。

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sample_flutter/widget_test/todo_screen.dart';

void main() {
  group('TodoScreen のテスト', () {
    testWidgets('初期状態でTodoが表示されない', (tester) async {
      await tester.pumpWidget(const MaterialApp(home: TodoScreen()));

      expect(find.text('No todos yet.'), findsOneWidget);
      expect(find.byType(ListTile), findsNothing);
    });

    testWidgets('Todoの追加が正しく行われる', (tester) async {
      await tester.pumpWidget(const MaterialApp(home: TodoScreen()));

      await tester.enterText(find.byType(TextField), '新しいTodo');
      await tester.tap(find.text('Add'));
      await tester.pump();

      expect(find.text('新しいTodo'), findsOneWidget);
      expect(find.text('No todos yet.'), findsNothing);
      expect(find.byType(ListTile), findsOneWidget);
    });

+   testWidgets('Todoの削除が正しく行われる', (tester) async {
+     await tester.pumpWidget(const MaterialApp(home: TodoScreen()));

+     await tester.enterText(find.byType(TextField), '削除するTodo');
+     await tester.tap(find.text('Add'));
+     await tester.pump();

+     expect(find.text('削除するTodo'), findsOneWidget);

+     await tester.tap(find.byIcon(Icons.delete));
+     await tester.pump();

+     expect(find.text('削除するTodo'), findsNothing);
+     expect(find.text('No todos yet.'), findsOneWidget);
+   });
  });
}

それぞれ詳しくみていきます。

以下では、まず enterText メソッドでテキストフィールドの Todo を入力し、 tap メソッドで Todo を追加し、 pump メソッドを実行しています。

await tester.enterText(find.byType(TextField), '削除するTodo');
await tester.tap(find.text('Add'));
await tester.pump();

以下では、削除するためのTodoが作成されているかどうかを確認しています。

expect(find.text('削除するTodo'), findsOneWidget);

以下では find.byIcon で削除アイコンを探し、それをタップしています。

await tester.tap(find.byIcon(Icons.delete));
await tester.pump();

以下では、削除したTodo が存在しないことと、「No todos yet.」のテキストが表示されていることを確認しています。

expect(find.text('削除するTodo'), findsNothing);
expect(find.text('No todos yet.'), findsOneWidget);

これで以下の一連の流れがテストできます。

  1. Todo の追加
  2. Todo が追加されていることを確認
  3. Todo の削除
  4. Todo が削除されていることを確認

4. 空のTodoが追加されない

最後に、テキストフィールドが空の状態で Todo を追加しようとした際に、追加できなくなっているかどうかを確認します。
以下のテストを追加します。

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sample_flutter/widget_test/todo_screen.dart';

void main() {
  group('TodoScreen のテスト', () {
    testWidgets('初期状態でTodoが表示されない', (tester) async {
      await tester.pumpWidget(const MaterialApp(home: TodoScreen()));

      expect(find.text('No todos yet.'), findsOneWidget);
      expect(find.byType(ListTile), findsNothing);
    });

    testWidgets('Todoの追加が正しく行われる', (tester) async {
      await tester.pumpWidget(const MaterialApp(home: TodoScreen()));

      await tester.enterText(find.byType(TextField), '新しいTodo');
      await tester.tap(find.text('Add'));
      await tester.pump();

      expect(find.text('新しいTodo'), findsOneWidget);
      expect(find.text('No todos yet.'), findsNothing);
      expect(find.byType(ListTile), findsOneWidget);
    });

    testWidgets('Todoの削除が正しく行われる', (tester) async {
      await tester.pumpWidget(const MaterialApp(home: TodoScreen()));

      await tester.enterText(find.byType(TextField), '削除するTodo');
      await tester.tap(find.text('Add'));
      await tester.pump();

      expect(find.text('削除するTodo'), findsOneWidget);

      await tester.tap(find.byIcon(Icons.delete));
      await tester.pump();

      expect(find.text('削除するTodo'), findsNothing);
      expect(find.text('No todos yet.'), findsOneWidget);
    });

+   testWidgets('空のTodoが追加されない', (tester) async {
+     await tester.pumpWidget(const MaterialApp(home: TodoScreen()));

+     await tester.tap(find.text('Add'));
+     await tester.pump();

+     expect(find.text('No todos yet.'), findsOneWidget);
+     expect(find.byType(ListTile), findsNothing);
+   });
  });
}

それぞれ詳しくみていきます。

以下では初期状態において、Todo の追加ボタンを押して、 pump を実行しています。

await tester.pumpWidget(const MaterialApp(home: TodoScreen()));

await tester.tap(find.text('Add'));
await tester.pump();

以下では、初期状態で Todo の追加ボタンを押しても追加されていないことを確認しています。

expect(find.text('No todos yet.'), findsOneWidget);
expect(find.byType(ListTile), findsNothing);

以上です。

まとめ

最後まで読んでいただいてありがとうございました。

今回は Widget Test の基礎的な実装方法についてまとめてみました。
誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://codelabs.developers.google.com/codelabs/flutter-app-testing?hl=ja

https://docs.flutter.dev/cookbook/testing/widget/introduction

Discussion