📝

Flutterテスト入門:簡単なToDoリストアプリで学ぶテスト基礎

2024/02/17に公開

こんにちは!
今回は、実際に簡単なToDoリストアプリを使って、Flutterでのテストがどのように行われるのかを紹介します。

Flutterのテストに関する知識を共に深めていく過程で、もし改善点や誤りを見つけた場合は、ぜひ優しくご指摘ください。

テストの種類と目的

ユニットテスト(Unit Tests): 個々の関数やメソッドの動作をテストします。
アプリの最も小さい単位のコードが正しく動作することを確認します。

ウィジェットテスト(Widget Tests): 個々のウィジェットのUIと機能をテストします。
ウィジェットや画面がユーザーの操作に対して期待通りに反応することを確認する

統合テスト(Integration Tests): アプリ全体の動作をテストします。
アプリケーション全体または複数の機能が連携して正しく動作するかを検証します。ユーザーの視点からアプリケーションのフローや機能をテストし、実際の使用シナリオを再現して問題がないことを確認することが目的です。

パッケージ

pubspec.yaml
dev_dependencies:
  integration_test:// 統合テストで使います
    sdk: flutter
  flutter_test:
    sdk: flutter

今回作ったアプリ概要

test用にすごく簡単なtodoアプリを作成しました

機能

  • タスク追加
  • タスクの削除
  • タスクの完了

todoアプリにtestを実装していきたいと思います

フォルダ構成

flutter_test_app/
  lib/
      main.dart
      todo_list_screen.dart
    model/
      task.dart
  test/
    integration_test/
      integration_test.dart
    unit/
      task_model_test.dart
    widget/
      todo_list_screen_widget_test.dart

ソースコード

model/task.dart コード
model/task.dart
// タスクを表すクラス
class Task {
  String title; // タスクのタイトルを保持する変数
  bool isCompleted; // タスクの完了状態を保持する変数。完了していればtrue、そうでなければfalse。

  // コンストラクタ。タスクのタイトルを必須で受け取り、完了状態はデフォルトでfalse(未完了)とする。
  Task({required this.title, this.isCompleted = false});

  // タスクの完了状態をトグルするメソッド。
  // 呼び出されると、isCompletedの値を反転させる(未完了なら完了に、完了なら未完了にする)。
  void toggleComplete() {
    isCompleted = !isCompleted; // 現在の完了状態を反転させる
  }
}

todo_list_screen コード
todo_list_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_test_app/model/task.dart';

// ToDoリストの画面を表示するための StatefulWidget
class ToDoListScreen extends StatefulWidget {
  
  _ToDoListScreenState createState() => _ToDoListScreenState();
}

// ToDoListScreen の状態を管理するクラス
class _ToDoListScreenState extends State<ToDoListScreen> {
  final List<Task> _tasks = []; // アプリのタスクリストを保持するリスト

  // 新しいタスクをリストに追加するメソッド
  void _addTask(String title) {
    setState(() {
      _tasks.add(Task(title: title)); // Task インスタンスを作成してリストに追加
    });
  }

  // 指定されたインデックスのタスクをリストから削除するメソッド
  void _removeTask(int index) {
    setState(() {
      _tasks.removeAt(index); // インデックスで指定されたタスクを削除
    });
  }

  // 指定されたインデックスのタスクの完了状態をトグルするメソッド
  void _toggleComplete(int index) {
    setState(() {
      _tasks[index].toggleComplete(); // タスクの完了状態をトグル
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('ToDo List'), // アプリバーのタイトル
      ),
      body: ListView.builder(
        itemCount: _tasks.length, // リストに表示するタスクの数
        itemBuilder: (context, index) {
          final task = _tasks[index]; // 現在のタスクを取得

          return ListTile(
            title: Text(
              task.title,
              style: TextStyle(
                decoration: task.isCompleted ? TextDecoration.lineThrough : null, // タスクが完了していれば取り消し線を表示
              ),
            ),
            trailing: IconButton(
              icon: Icon(Icons.delete),
              onPressed: () => _removeTask(index), // 削除ボタン。タップされたらタスクを削除
            ),
            onTap: () => _toggleComplete(index), // タスクをタップしたら完了状態をトグル
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
          child: Icon(Icons.add), // 追加ボタンアイコン
          onPressed: () {
            TextEditingController taskController = TextEditingController(); // 新しいタスクのタイトルを入力するためのテキストフィールドコントローラ
            showDialog( // ダイアログを表示して新しいタスクを追加
                context: context,
                builder: (BuildContext context) {
                  return AlertDialog(
                    title: Text("新しいタスクを追加"),
                    content: TextField(
                      controller: taskController,
                      decoration: InputDecoration(
                          hintText: "タスクのタイトルを入力してください"),
                    ),
                    actions: <Widget>[
                      ElevatedButton(
                        child: Text("キャンセル"),
                        onPressed: () {
                          Navigator.of(context).pop(); // キャンセルボタンが押されたらダイアログを閉じる
                        },
                      ),
                      ElevatedButton(
                        child: Text("追加"),
                        onPressed: () {
                          if (taskController.text.isNotEmpty) {
                            _addTask(taskController.text); // テキストフィールドに入力されたタイトルで新しいタスクを追加
                            Navigator.of(context).pop(); // タスク追加後にダイアログを閉じる
                          }
                        },
                      ),
                    ],
                  );
                }
            );

          }

      ),
    );
  }
}


ユニットテストやってみた

ユニットテスト(Unit Tests): 個々の関数やメソッドの動作をテストします。

目的

まずユニットテストで検証すること

- 新しいタスクが正しく追加されるか
Taskオブジェクトを作成し、そのtitleが指定したタイトルに一致するか、そしてisCompletedがデフォルトでfalseになっているかを検証しています。

- タスクの完了状態がトグルされるか
新しいTaskオブジェクトを作成し、toggleCompleteメソッドを呼び出した後にisCompletedプロパティがtrueになるかを検証しています。

task_model_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_test_app/model/task.dart';

void main() {
  group('Task Model Tests', () {

    // ここにテストケースを記述

    test('新しいタスクが正しく追加されるか', () {
      final task = Task(title: 'new task');// タスクを作成

      expect(task.title, 'new task');
      expect(task.isCompleted, false);

    });

    test('タスクの完了状態がトグルされるか', () {
      final task = Task(title: 'new task'); // タスクを作成

    // タスク完了のtrueにする
     task.toggleComplete();

      // ちゃんとtask.isCompletedがtrueで返ってきたら成功
      expect(task.isCompleted, true);
    });
  });
}

main関数

Dartプログラムのエントリーポイントです。
Flutterのテストも、このmain関数から実行が始まります。

group関数

group('グループの名前', () {
  // ここにそのグループに含まれるテストケースを記述
});

group(A, (){B})の形式で使います。

  • Aはテストグループの名前を表す文字列です。この名前によって、テストの出力時にどのグループのテストが実行されているかが分かります。
  • Bはこの無名関数内でtest関数を使用して個々のテストケースを定義します。

これにより、テストの出力が整理され、どのテストグループに何が含まれているかが明確になります。

test関数

test('テストケースの説明', () {
  // ここにテストのロジックを記述
});

test(A, (){B})の形式で使います

  • Aはテストケースの説明を文字列で指定
  • Bはテストを実行するためのロジックを含む無名関数

expect関数

test('新しいタスクが正しく追加されるか', () {
  final task = Task(title: 'new task');

  expect(task.title, 'new task');
  expect(task.isCompleted, false);
});

test('タスクの完了状態がトグルされるか', () {
  final task = Task(title: 'new task');
  task.toggleComplete();

  expect(task.isCompleted, true);
});

expect(A, B )形式で使います

  • A(actual)は テスト対象の値
  • B(matcher)は 期待される値を指定

actual:
テスト中に検証したい実際の値やオブジェクトを指します。これは、関数の戻り値、変数の現在の値、あるいはウィジェットの状態など、様々な形で表されることがあります。

matcher:
actualが満たすべき条件を定義します。
Flutterのテストフレームワークは、多くの標準的なmatcherを提供しており、特定の値に等しい、ある範囲内にある、特定の型である、などの条件を簡単に指定できます。

printでログに出して確認してみよう

task_model_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_test_app/model/task.dart';

void main() {
  group('Task Model Tests', () {

    test('新しいタスクが正しく追加されるか', () {
      final task = Task(title: 'new task');// タスクを作成
+     print('タスク作成: ${task.title}, 完了状態: ${task.isCompleted}');

      expect(task.title, 'new task');
      expect(task.isCompleted, false);
    });

    test('タスクの完了状態がトグルされるか', () {
      final task = Task(title: 'new task'); // タスクを作成

+     print('タスク作成前: 完了状態: ${task.isCompleted}');
      task.toggleComplete();// ここでタスク完了のtrueにする
+    print('タスク完了後: 完了状態: ${task.isCompleted}');

      expect(task.isCompleted, true);
    });
  });
}

結果

test '新しいタスクが正しく追加されるか'の結果

test'タスクの完了状態がトグルされるか'の結果

テスト失敗時の例

task_model_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_test_app/model/task.dart';

void main() {
  group('Task Model Tests', () {

    test('新しいタスクが正しく追加されるか', () {
      final task = Task(title: 'new task');// タスクを作成

-     expect(task.title, 'new task');
+     expect(task.title, 'task1'); // 存在しないタスクを追加
      expect(task.isCompleted, false);
    });

    test('タスクの完了状態がトグルされるか', () {
      final task = Task(title: 'new task'); // タスクを作成

      task.toggleComplete();// ここでタスク完了のtrueにする

-      expect(task.isCompleted, true);
+      expect(task.isCompleted,false);// 期待される値をfalseに設定
    });
  });
}

結果

Flutterのユニットテストのエラーメッセージには、主に以下の部分が含まれます:
Expected(期待値): テストが成功するために期待される値。
Actual(実際の値): テスト対象のコードから得られた実際の値。
Which(詳細情報): 期待値と実際の値の間の具体的な差異を説明する部分。

test '新しいタスクが正しく追加されるか'の結果

エラーメッセージの詳細
Expected: 'new task1' - テストで期待される値です。
Actual: 'new task' - 実際にテスト対象から得られた実際の値です。
Which: 両方の文字列が同じで始まるが、実際の値には末尾の文字1が欠けている。

test'タスクの完了状態がトグルされるか'の結果

エラーメッセージの詳細
Expected: <false> - テストが成功するために期待される値です。
Actual: <true> - 実際にテスト対象のコードから得られた実際の値です。

このようなエラーは、テストがコードの品質を保証する上で重要な役割を果たしていることを示しています。テストが失敗することで、意図しない挙動やバグが早期に発見され、修正することができます。エラーメッセージを注意深く読み解き、問題の根本原因を特定して修正するプロセスを通じて、より堅牢なコードを書くことができるようになります。

ウィジェットテストやってみた

今回実施するテストは以下の三つ

  • 追加ボタンを押してタスクが追加されるかどうか
  • タスク削除ボタンをタップするとタスクが削除されかどうか
  • タスクの完了/未完了の切り替えができるかどうか
全体コード
todo_list_screen_widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_test_app/main.dart';

void main() {

  testWidgets('追加ボタンでタスクが追加される', (WidgetTester tester) async {
    // アプリを起動
    await tester.pumpWidget(MyApp());

    // +ボタンを押す
    await tester.tap(find.byIcon(Icons.add));
    await tester.pump();

    // 新しいタスク名を入力
    await tester.enterText(find.byType(TextField), 'タスク1');

    // 追加ボタンを押す
    final Finder addButtonFinder = find.widgetWithText(ElevatedButton, '追加');
    await tester.tap(addButtonFinder);    // ElevatedButton
    await tester.pump();

    // タスクがリストに追加されたことを確認
    expect(find.text('タスク1'), findsOneWidget);
});

  testWidgets('タスク削除ボタンをタップするとタスクが削除される', (WidgetTester tester) async {
    // アプリを起動
    await tester.pumpWidget(MyApp());

    // +ボタンを押す
    await tester.tap(find.byIcon(Icons.add));
    await tester.pumpAndSettle(); 

     // 新しいタスク名を入力
    await tester.enterText(find.byType(TextField), 'タスク1');

    // 追加ボタンを押す
    final Finder addButtonFinder = find.widgetWithText(ElevatedButton, '追加');   
    await tester.tap(addButtonFinder);    
    await tester.pumpAndSettle(); 

    // ここでタスクが追加された
    expect(find.text('タスク1'), findsOneWidget);

    // タスク削除ボタンをタップ
    await tester.tap(find.byIcon(Icons.delete));
    await tester.pumpAndSettle(); 

    // タスクがリストから削除されたことを検証
    expect(find.text('タスク1'), findsNothing);
  });

  testWidgets('タスクの完了/未完了の切り替えテスト', (WidgetTester tester) async {
    // アプリ起動
    await tester.pumpWidget(MyApp());

    // 新しいタスクを追加
    await tester.tap(find.byIcon(Icons.add));
    await tester.pump(); // ダイアログが開くのを待つ

    // 新しいタスク名を入力
    await tester.enterText(find.byType(TextField), 'テストタスク');

    // "追加"ボタンをタップしてタスクをリストに追加
    await tester.tap(find.widgetWithText(ElevatedButton, '追加'));
    await tester.pump(); // タスク追加後の状態を反映させる

    // タスクがリストに追加されたことを確認
    expect(find.text('テストタスク'), findsOneWidget);

    // タスクをタップして完了状態をトグル
    await tester.tap(find.text('テストタスク'));
    await tester.pump(); 

    // タスクが完了状態(取り消し線が引かれた状態)になっていることを検証
    final TextStyle textStyle = tester.widget<Text>(find.text('テストタスク')).style!;
    expect(textStyle.decoration, TextDecoration.lineThrough);
  });


}

タスク追加機能のウィジェットテスト

追加ボタンを押すことでタスクがリストに追加されるか」を検証する方法について解説します。このテストは、アプリのユーザビリティと機能性を確認する上で重要な役割を果たします。

テストの流れ

   testWidgets('追加ボタンでタスクが追加される', (WidgetTester tester) async {
  // 1. アプリケーションを起動
  await tester.pumpWidget(MyApp());

  // 2. "+"ボタンをタップ
  await tester.tap(find.byIcon(Icons.add));
  await tester.pump(); 

  // 3. 新しいタスク名を入力
  await tester.enterText(find.byType(TextField), 'タスク1');

  // 4. "追加"ボタンをタップ
  await tester.tap(find.widgetWithText(ElevatedButton, '追加'));
  await tester.pump();

  // 5. タスクがリストに追加されたかを確認
  expect(find.text('タスク1'), findsOneWidget);
});

タスク追加テストのステップ

  1. アプリを起動する
  • 最初に、MyAppを画面に表示します。これは、アプリが実際にユーザーに使用される状態からテストを始めることを意味します。
  1. +ボタンをタップする
  • find.byIcon(Icons.add)を使って+ボタンを特定し、tapメソッドでタップ操作をシミュレートします。pumpメソッドでUIの更新を待ちます。
  1. タスク名を入力する
  • find.byType(TextField)でテキストフィールドを特定し、enterTextメソッドで'タスク1'という文字列を入力します。
  1. 追加ボタンを押す
  • 追加ボタンをfind.widgetWithText(ElevatedButton, '追加')で探し、タップします。再びpumpでUIの更新を待ちます。
  1. タスクがリストに追加されたことを確認する
  • expectメソッドを使って、タスク1がリストに追加されたことを検証します。

テスト関連関数の説明

testWidgets関数:
ウィジェットテストを定義するために使用され、テストの説明文とウィジェット操作を行うコールバック関数を引数に取ります。ウィジェットテストでは、この関数を使用して、アプリのUIが期待通りに動作するかどうかを検証します。

findメソッド:
テスト対象のウィジェットを検索するために使用されます。
様々なクエリ(byText、byIcon、byTypeなど)を利用して、ウィジェットツリー内から特定のウィジェットを見つけ出すことができます。
これにより、特定の条件に一致するウィジェットの存在や属性を検証することが可能になります。

testerオブジェクト:
WidgetTesterインスタンスで、ウィジェットのタップ、テキスト入力、UIの更新など、ウィジェットとのインタラクションをシミュレートするためのメソッドを提供します。

pumpとpumpAndSettle

  • pumpメソッド:
    フレームを進行させてUIの変更を反映させるために使用されます。

  • pumpAndSettleメソッド:
    アニメーションや非同期処理が完了するまで何度もフレームを進行させます。

pumpとpumpAndSettleの選択基準

  • pumpの使用時:
    単一のウィジェットの更新や簡単なUIの変更をテストする場合、または特定の時間だけアニメーションを進めたい場合に使用します。

  • pumpAndSettleの使用時:

    アニメーションや非同期処理が終了するまでの全体的な状態を確認したい場合に適しています。このメソッドは、全てのアニメーションが落ち着くまで何度もフレームを進行させ、アプリが安定した状態になるのを待ちます。

pumpメソッドを使うのは、ウィジェットの小さな変更や一瞬の画面更新をチェックしたい時に使う。
一方、pumpAndSettleは、アニメーションがじわじわと止まるのを待ったり、画面の読み込みが完全に終わるのを見守りたい時に使う方がいいです。

まとめ

このテストは、Todoアプリでのタスク追加機能の正確性と信頼性を確保します。

タスク削除ボタン(Icons.delete)をタップするとタスクが削除される

タスク削除機能をウィジェットテストで検証する方法を詳細に解説します。
このテストは、ユーザーがタスクを削除したいとき、アプリがその要求に正確に応答するかどうかを確認するために重要です。

テストの流れ

 testWidgets('タスク削除ボタンをタップするとタスクが削除される', (WidgetTester tester) async {
  // 1. アプリを起動
  await tester.pumpWidget(MyApp());

  // 2. "+"ボタンを押してタスク追加フォームを開く
  await tester.tap(find.byIcon(Icons.add));
  await tester.pump();

  // 3. 新しいタスク名「タスク1」を入力
  await tester.enterText(find.byType(TextField), 'タスク1');

  // 4. "追加"ボタンをタップしてタスクをリストに追加
  await tester.tap(find.widgetWithText(ElevatedButton, '追加'));
  await tester.pump();

  // 5. タスクがリストに追加されたことを確認
  expect(find.text('タスク1'), findsOneWidget);

  // 6. タスク削除ボタン(Icons.delete)をタップ
  await tester.tap(find.byIcon(Icons.delete));
  await tester.pump();

  // 7. タスクがリストから削除されたことを検証
  expect(find.text('タスク1'), findsNothing);
});
  1. アプリの起動:
  • 最初に、MyAppを画面に表示させます。これはアプリが開始するところからテストを始めることを意味します。
  1. タスクの追加:
  • find.byIcon(Icons.add)を使用して+ボタンを特定し、タップします。
  1. タスク名の入力
  • find.byType(TextField)でテキストフィールドを探し、「タスク1」と入力します。
  1. タスクの追加を確定:
  • '追加'ボタンをタップして、タスクをリストに追加します。
  1. タスク追加の確認
  • 追加されたタスクがリストに表示されていることをexpectメソッドで検証します。
  1. タスクの削除操作
  • find.byIcon(Icons.delete)で削除ボタンを特定し、タップします。
  1. タスク削除の確認
  • タスクがリストから削除されていることをfindsNothingを用いて検証します。

補足説明

findsNothingについて:
expect(find.text('タスク1'), findsNothing);この行は、テスト対象の画面上に「タスク1」というテキストが存在しないことを検証します。タスクが正しく削除された場合、このテストはパスします。

まとめ

このウィジェットテストは、タスクの追加と削除プロセスをシミュレートし、タスク管理アプリの核となる機能が正しく動作することを確認します。

タスクの完了/未完了の切り替えができるかどうか

タスクの完了と未完了の状態を切り替える機能が正しく動作するかを検証するプロセスを紹介します。このテストは、ユーザーがタスクリストを効率的に管理できるようにするためのアプリの核心機能の一つを確認することに重点を置いています。

テストの流れ

testWidgets('タスクの完了/未完了の切り替えテスト', (WidgetTester tester) async {
  // 1. アプリを起動して準備
  await tester.pumpWidget(MyApp());

  // 2. 新しいタスクを追加する流れ
  await tester.tap(find.byIcon(Icons.add)); // "+"ボタンをタップ
  await tester.pump(); // ダイアログが表示されるまで待機
  await tester.enterText(find.byType(TextField), 'テストタスク'); // タスク名を入力
  await tester.tap(find.widgetWithText(ElevatedButton, '追加')); // "追加"ボタンをタップ
  await tester.pump(); // タスク追加を反映

  // 3. タスクがリストに表示されているか確認
  expect(find.text('テストタスク'), findsOneWidget);

  // 4. タスクの完了/未完了を切り替え
  await tester.tap(find.text('テストタスク')); // タスクをタップして状態切り替え
  await tester.pump(); // 状態更新を反映

  // 5. タスクが完了状態になったか検証
  final TextStyle textStyle = tester.widget<Text>(find.text('テストタスク')).style!;
  expect(textStyle.decoration, TextDecoration.lineThrough); // 取り消し線があるか確認
});

// やっていることは、先ほど紹介したテスト工程と同じなので、省略

  1. タスクの状態切り替え
  • タスクをタップすることで、その完了/未完了の状態をトグルします。
  1. 完了状態の検証
  • タスクテキストに取り消し線が適用されていることを確認し、タスクが完了状態になったことを検証します。

まとめる
このテストは、アプリのタスク管理機能の信頼性を確認し、ユーザーがタスクの状態を直感的に追跡し管理できることを保証します。

統合テストやってみた

統合テストはアプリ開発の中でも特に重要なステップの一つです。
このテストを通じて、アプリの核となる機能が期待通りに動作することを確認します。
今回の焦点は、ToDoリストアプリで最も基本的な「タスク追加、完了、削除」の機能にあります。
これらの操作がスムーズに行えるかを確かめることで、アプリがユーザーにとって実用的であることを保証します。

統合テストの目的

統合テストの主な目的は、アプリの異なる部分が一緒にうまく機能するかを検証することです。
ユーザーがアプリを使用する際には、画面の遷移やデータの保存といった多くのプロセスが背後で連携して動作します。
統合テストでは、これらのプロセスが正しく連携して結果を出せるかを確認することができます。

今回実施するテストは以下の二つ

  • タスクの追加、完了、削除フローをテスト
    ユーザーが新しいタスクを追加し、そのタスクを完了または削除するプロセスをテストします。

  • 複数のタスクを追加し、異なる順番で完了または削除するフローをテスト

    ユーザーが複数のタスクを追加し、それらを異なる順番で完了または削除するフローをテストします。

Flutterの統合テストでは、IntegrationTestWidgetsFlutterBinding.ensureInitialized();を使用してテスト環境を初期化し、testWidgets関数を用いてテストケースを定義します。

integration_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:flutter_test_app/main.dart' as app;

void main() {
  // 統合テストの初期化
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('ToDoリスト統合テスト', () {
    // タスクの追加、完了、削除のフローをテスト
    testWidgets('タスクの追加、完了、削除フロー', (tester) async {
      // テスト対象のアプリを起動
      app.main();
      // アプリの起動と初期化が完了するまで待機
      await tester.pumpAndSettle();

      // タスクの追加フロー
      // "+"アイコンのボタンをタップしてタスク追加画面を開く
      await tester.tap(find.byIcon(Icons.add));
      await tester.pump(); // ダイアログの表示を待機
      // タスク名を入力
      await tester.enterText(find.byType(TextField), 'タスク1');
      // "追加"ボタンをタップしてタスクをリストに追加
      await tester.tap(find.text('追加'));
      // タスク追加後のUI更新を待機
      await tester.pumpAndSettle(); // pumpだとUIの更新が完了する前に次に進んでしまうためエラーが発生する
      // タスクがリストに追加されたことを確認
      expect(find.text('タスク1'), findsOneWidget);

      // タスクの完了フロー
      // タスクをタップして完了状態に切り替え
      await tester.tap(find.text('タスク1'));
      await tester.pump(); // 状態の更新を待機
      // 完了状態のスタイル(取り消し線)が適用されているか確認
      final TextStyle textStyle = tester.widget<Text>(find.text('タスク1')).style!;
      expect(textStyle.decoration, TextDecoration.lineThrough);

      // タスクの削除フロー
      // 削除アイコンをタップしてタスクをリストから削除
      await tester.tap(find.byIcon(Icons.delete));
      await tester.pump(); // UIの更新を待機
      // タスクがリストから削除されたことを確認
      expect(find.text('タスク1'), findsNothing);
    });

    // 複数のタスクを追加し、異なる順番で完了または削除するフローをテスト
    testWidgets('複数のタスクを追加し、異なる順番で完了または削除', (tester) async {
      // テスト対象のアプリを起動
      app.main();
      // アプリの起動と初期化が完了するまで待機
      await tester.pumpAndSettle();

      // 1つ目のタスクを追加
      await tester.tap(find.byIcon(Icons.add));
      await tester.pump(); // ダイアログの表示を待機
      await tester.enterText(find.byType(TextField), 'タスク1');
      await tester.tap(find.text('追加'));
      await tester.pump(); // UIの更新を待機

      // 2つ目のタスクを追加
      await tester.tap(find.byIcon(Icons.add));
      // ここではUIの更新が複数フレームにわたるためpumpAndSettleを使用
      await tester.pumpAndSettle(); 
      await tester.enterText(find.byType(TextField), 'タスク2');
      await tester.tap(find.text('追加'));
      await tester.pump(); // UIの更新を待機

      // 1つ目のタスクを完了(タップ)する
      await tester.tap(find.text('タスク1'));
      await tester.pumpAndSettle(); // 完了状態のUI更新を待機

      // 2つ目のタスクを削除する
      await tester.tap(find.byIcon(Icons.delete).at(1));
      await tester.pumpAndSettle(); // 削除後のUI更新を待機

      // 検証: 1つ目のタスクが完了状態になっていることを確認
      final TextStyle textStyle = tester.widget<Text>(find.text('タスク1')).style!;
      expect(textStyle.decoration, TextDecoration.lineThrough);
      // 検証: 2つ目のタスクがリストから削除されていることを確認
      expect(find.text('タスク2'), findsNothing);
    });
  });
}

まとめ

統合テストをパスすることは、アプリの核心部分が正常に機能している強力な証拠となります。

統合テストで直面した「pump」のエラーとその解決法

統合テストを行うとき、「pump」メソッドだけでは思わぬエラーに直面することがあります。今回のToDoリストアプリのテストで、なぜこのような問題が起きるのか、そしてどう解決するのかをわかりやすく解説します。

  • タスクの追加、完了、削除フローで起きたエラー
══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following TestFailure was thrown running a test:
Expected: exactly one matching candidate
  Actual: _TextWidgetFinder:<Found 2 widgets with text "タスク1": [
            Text("タスク1", <all styles inherited>, dependencies: [DefaultSelectionStyle,
DefaultTextStyle, MediaQuery]),
            EditableText-[LabeledGlobalKey<EditableTextState>#e4abe](controller:
TextEditingController#623c5(TextEditingValue(text: ┤タスク1├, selection:
TextSelection.collapsed(offset: 4, affinity: TextAffinity.downstream, isDirectional: false),
composing: TextRange(start: -1, end: -1))), focusNode: FocusNode#73c88, debugLabel: ((englishLike
bodyLarge 2021).merge((blackMountainView bodyLarge).apply)).merge(unknown), inherit: false, color:
Color(0xff1d1b1e), family: Roboto, size: 16.0, weight: 400, letterSpacing: 0.5, baseline:
alphabetic, height: 1.5x, leadingDistribution: even, decoration: Color(0xff1d1b1e)
TextDecoration.none, textAlign: start, keyboardType: TextInputType(name: TextInputType.text, signed:
null, decimal: null), autofillHints: [], spellCheckConfiguration:
              spell check enabled   : false
                spell check service   : null
                misspelled text style : null
                spell check suggestions toolbar builder: null
            , dirty, dependencies: [Directionality, MediaQuery, ScrollConfiguration,
_EffectiveTickerMode, _ViewScope], state: EditableTextState#5e77c(tickers: tracking 1 ticker)),
          ]>
   Which: is too many
  • test複数のタスクを追加し、異なる順番で完了または削除で起きたエラー
The following StateError was thrown running a test:
Bad state: Too many elements

エラーの原因 Bad state: Too many elements

統合テストでtester.pumpを使ったら、期待した1つのウィジェットではなく、複数のウィジェットが見つかってしまいました。
これは、pumpメソッドが次のフレームまで進めるだけで、非同期処理が完了するのを待たないためです。
結果として、UIの更新が完全には終わっていない状態でテストが進んでしまい、エラーが発生します。

ウィジェットテストでは問題なし?

ウィジェットテストでは、主に1つのウィジェットや簡単なインタラクションをテストします。
ここでは、pumpメソッドを使っても、大抵の場合は問題なくテストを進めることができます。
なぜなら、テスト対象の操作が単純で、すぐに結果を確認できるからです。

統合テストでエラーが出る理由
統合テストでは、アプリ全体の流れをチェックします。ここでpumpを使うと、アプリがデータをロードしたり、画面遷移をしたりするのを完全に待たずにテストが進んでしまいます。
「まだデータが読み込まれていないよ!」という状態でテストが進むと、
「これは期待した動作ではないな」という結果になってしまうのです。

解決策: pumpAndSettle
この問題を解決する鍵はpumpAndSettleメソッドにあります。
このメソッドは、「落ち着くまで待つよ」と言ってくれるようなもので、すべてのアニメーションが終わり、データがロードされ、画面が安定するまで待ってくれます。
つまり、pumpAndSettleを使えば、テストは「これが見たかった! 完璧だ!」という状態で進めることができます。

まとめ

ウィジェットテストではpumpで問題なく進むことが多いですが、統合テストではpumpAndSettleを使って、アプリが完全に安定するまで待つことが大切です。
この小さな変更が、テストの成功と失敗を分けることがあります。
テストでエラーに直面したら、pumpとpumpAndSettleの使い方を見直してみましょう。

全体まとめ

最後まで読んでいただきありがとうございました
非同期テストについては現在学習中ですので、
次は非同期処理アプリをtestをしてみたの記事を作成したいと思います!
ではまた!

Discussion