[Flutter] 実践E2Eテスト環境構築ガイド

2026-04-11 hit count image

Flutterプロジェクトにintegration_testベースのE2Eテスト環境を構築し、Firebase/GetX/SQLiteを使用する実際のアプリに適用する方法を紹介します。

flutter

概要

FlutterにおけるE2E(End-to-End)テストは、実際のデバイスやエミュレーターでアプリを実行してユーザーフローを検証するテストです。ウィジェットテストが個々のコンポーネントを分離してテストするのに対し、E2Eテストはナビゲーション、DBクエリ、コントローラーの初期化などが実際の環境で統合的に動作するかを確認します。

この記事では、Firebase、GetX、SQLiteを使用する実際のプロジェクトにE2Eテストを適用した経験をもとに、環境構築からCI連携までの過程を整理します。

以前作成したFlutter統合テスト基本ガイドではFlutter 2.x基準での基本的な設定方法を扱いました。この記事はFlutter 3.x環境での実践的な適用ガイドです。

以前のガイドとの違い

Flutter 3.xではE2Eテストの設定が大幅に簡素化されました。

項目Flutter 2.x(以前)Flutter 3.x(現在)
ドライバーファイルtest_driver/integration_test.dartが必要不要
Android設定build.gradle修正、MainActivityTest.java作成不要
iOS設定XcodeでRunnerTestsターゲットを手動作成不要
実行コマンドflutter driveflutter test integration_test

Flutter 3.xではpubspec.yamlにパッケージを追加し、integration_test/ディレクトリにテストファイルを作成すれば、すぐに実行できます。

環境構築

パッケージ追加

pubspec.yamldev_dependenciesintegration_testを追加します。

dev_dependencies:
  flutter_test:
    sdk: flutter
  integration_test:
    sdk: flutter

E2Eテストでテスト専用DBを使用する場合、assetsにも登録します。

flutter:
  assets:
    - assets/data.db
    - assets/test.db # E2Eテスト用

ディレクトリ構造

integration_test/
  app_test.dart              # 単一エントリーポイント(全テスト実行)
  helpers/
    test_app.dart            # アプリブートストラップ
    mock_setup.dart          # コントローラーモッキング
    db_setup.dart            # DBリセット
  flows/
    learning_flow.dart       # 学習フローテスト
    review_flow.dart         # クイズフローテスト
    search_flow.dart         # 検索フローテスト

注意: flows/ディレクトリのファイルは_test.dartで終わらないようにします。flutter test integration_testはディレクトリ内の_test.dartファイルを自動的に検索し、それぞれ別プロセスとして実行します。つまりapp_test.dartlearning_flow_test.dartが両方存在すると、各ファイルのmain()が独立して実行され、Firebase初期化、DBコピー、コントローラー登録が2回実行されます。これにより2番目のプロセスがすでに占有されたリソースと競合してハングが発生します。_test.dartサフィックスを削除するとテストランナーの自動検索対象から除外され、app_test.dartから明示的にimportして呼び出す時のみ実行されます。

テストインフラの実装

実際のアプリにE2Eテストを適用する際の核心は外部依存関係の制御です。Firebase、広告SDK、アプリ内課金などはテスト環境で適切にモッキングまたは無効化する必要があります。

SQLite DBリセットヘルパー

SQLiteを使用するアプリでE2Eテストを行う際、DBを毎回初期状態にリセットしないと、テスト結果が決定的(deterministic)になりません。

ウィジェットテストではローカルファイルシステムのDBファイルをFile.copySync()で直接コピーできますが、E2Eテストは実際のデバイスのアプリサンドボックス内で実行されます。そのためrootBundleでアセットを読み込み、アプリのdocumentsディレクトリにコピーする方式を使用します。

また、ウィジェットテストではsqlite3の同期API(sqlite3.open())でDBを直接開けますが、E2EテストではプロダクションコードのDB初期化ロジックをそのまま呼び出します。プロダクションコードのinit()は内部的にDBファイルの存在を確認し、テーブル作成やカラム追加などのマイグレーションを実行します。プロダクションコードを再利用することでマイグレーションの漏れを心配する必要がありません。

// integration_test/helpers/db_setup.dart
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:your_app/helpers/db_helper.dart';

Future<void> resetDatabase() async {
  final dbHelper = DBHelper();

  // 1. 既存のDB接続を解除
  dbHelper.dispose();

  // 2. DBパスを確認し、既存ファイルを削除(WAL/SHMなどの補助ファイルを含む)
  final dbPath = await dbHelper.getDBPath();
  final basePath = p.dirname(dbPath);
  await dbHelper.deleteFileWithAuxiliary('$basePath/data.db');

  // 3. アセットのtest.dbをアプリdocumentsディレクトリにdata.dbとしてコピー
  final data = await rootBundle.load('assets/test.db');
  final dir = Directory(basePath);
  if (!dir.existsSync()) {
    await dir.create(recursive: true);
  }
  await File('$basePath/data.db').writeAsBytes(data.buffer.asUint8List());

  // 4. DB初期化(プロダクションコードのマイグレーションロジックを再利用)
  await dbHelper.init();
}

このresetDatabase()関数は、以下のテストアプリブートストラップinitTestApp()内から呼び出されます。

コントローラーのモッキング

E2Eテストは実際のデバイスで実行されるため、ネイティブSDKを呼び出すコントローラーはmockに置き換える必要があります。例えば広告SDK(MobileAds)、通知(FlutterLocalNotifications)、アプリ内課金(InAppPurchase)などがテスト中に呼び出されると、権限リクエストダイアログが表示されたりクラッシュが発生する可能性があります。

GetXを使用している場合、コントローラーのonInit()でネイティブSDKを呼び出すケースが多いです。これを防ぐには、該当コントローラーを継承してonInit()をoverrideし、SDK呼び出しをスキップします。

// integration_test/helpers/mock_setup.dart

// 広告SDK呼び出しを防止するmock
// 元のBannerController.onInit()がMobileAds.instance.setAppMuted(true)を呼び出す
class _MockBannerController extends BannerController {
  _MockBannerController() : super(isRectangle: true);

  @override
  // ignore: must_call_super
  void onInit() {}
}

// 通知権限リクエストを防止するmock
// 元のNotificationController.onInit()がシステム通知権限をリクエストする
class _MockNotificationController extends NotificationController {
  @override
  // ignore: must_call_super
  void onInit() {}
}

// ignore: must_call_superはDart analyzerの警告を無視するコメントです。onInit()@mustCallSuperが宣言されているため、superを呼び出さないと警告が発生しますが、E2Eテストでは意図的に省略します。

コントローラー登録時の重要なポイント:

  1. GetXのGet.put()重複登録動作を活用します。 GetXは同じ型のインスタンスがすでに登録されている場合、Get.put()を再度呼び出しても新しいインスタンスを生成せず既存のものを返します。そのため、アプリのBindingコード(Get.put(MyController()))が実行される前にmockコントローラーを先にGet.put()で登録すると、Binding実行時に既存のmockがそのまま使用されます。

  2. 有料機能フラグを活用して広告を無効化します。 アプリ内課金で広告を削除するアプリなら、テストで有料ユーザー状態を強制設定して広告ウィジェット自体がレンダリングされないようにできます。これによりMobileAds SDKの初期化なしでもアプリが正常動作します。アプリ内課金プラットフォームインターフェースもFake実装に置き換えてストア呼び出しをブロックする必要があります。

  3. SharedPreferencesでダイアログを事前無効化します。 チュートリアル、アプリレビューリクエストなどのダイアログは「既に見た」フラグをSharedPreferencesに保存していることが多いです。このフラグを事前にtrueに設定するとダイアログが表示されません。

SharedPreferences.setMockInitialValues({
  'tutorialShown': true,
  'reviewDialogShown': true,
});

テストアプリブートストラップ

上で作成したヘルパーを一つのinitTestApp()関数にまとめて実行できるようにします。この関数はテスト実行前に一度だけ呼び出され、アプリの実行環境を準備します。

// integration_test/helpers/test_app.dart
Future<void> initTestApp() async {
  // 1. Firebase初期化(実デバイスで必要)
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  // 2. テストモード設定(Firebaseイベント送信をブロック)
  CrashlyticsHelper.setTestMode(true);
  AnalyticsHelper.setTestMode(true);
  Get.testMode = true;

  // 3. 環境変数設定(サーバーURL、言語、アプリ名などをテスト用値に置換)
  _setTestEnv();

  // 4. DBリセット(test.db → data.dbコピー + マイグレーション)
  await resetDatabase();

  // 5. Mockコントローラー登録
  await registerMockControllers();
}

Firebaseは実際に初期化しますが、テストモードフラグでイベント送信をブロックします。デバッグビルドのFirebase設定が使用されるため、プロダクションデータに影響しません。

テストコードの作成

単一エントリーポイント構造

flutter test integration_testは各_test.dartファイルを別プロセスで実行します。複数のファイルがそれぞれinitTestApp()を呼び出すと、Firebase再初期化、DBファイル競合などでハングが発生します。

解決: app_test.dartを単一エントリーポイントとし、個別のフローテストは関数としてexportしてimportします。

// integration_test/app_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:your_app/main.dart';

import 'flows/learning_flow.dart';
import 'flows/review_flow.dart';
import 'flows/search_flow.dart';
import 'helpers/test_app.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  setUpAll(() async {
    await initTestApp();
  });

  group('Smokeテスト', () {
    testWidgets('アプリが正常に起動する', (tester) async {
      await tester.pumpWidget(const MyApp());
      await tester.pumpAndSettle();
      expect(find.byType(HomeScreen), findsOneWidget);
    });
  });

  learningFlowTests();
  reviewFlowTests();
  searchFlowTests();
}

各flowファイルはmain()なしで関数のみをexportします。

// integration_test/flows/learning_flow.dart
void learningFlowTests() {
  group('学習フロー', () {
    testWidgets('一覧 → 詳細画面ナビゲーション', (tester) async {
      await tester.pumpWidget(const MyApp());
      await tester.pumpAndSettle();
      // ... テストコード
    });
  });
}

Smokeテスト

最も基本的なテストで、アプリがクラッシュせずに起動し、最初の画面がレンダリングされるかを確認します。

testWidgets('アプリが正常に起動する', (tester) async {
  await tester.pumpWidget(const MyApp());
  await tester.pumpAndSettle();
  expect(find.byType(HomeScreen), findsOneWidget);
});

このテストだけでも、Firebase初期化、DBロード、状態管理コントローラー登録、ウィジェットレンダリングがすべて正常に動作するかを検証できます。

ナビゲーションフローテスト

実際のUIタップで画面間の遷移を検証します。

testWidgets('一覧 → 詳細 → サブ詳細ナビゲーション', (tester) async {
  await tester.pumpWidget(const MyApp());
  await tester.pumpAndSettle();

  // リスト項目タップ → 詳細画面
  await tester.tap(find.text('項目1'));
  await tester.pumpAndSettle();
  expect(find.byType(DetailScreen), findsOneWidget);

  // サブ項目タップ → サブ詳細画面
  await tester.tap(find.byType(SubItem).first);
  await tester.pumpAndSettle();
  expect(find.text('サブ詳細'), findsOneWidget);
});

Get.toNamed()でプログラム的に遷移するのではなく、実際のUIタップを使用します。これによりBinding、Arguments渡し、Controller初期化がすべて自然に検証されます。

ExpansionTileなど展開/折りたたみウィジェットのテスト時は展開されたウィジェットのタップ位置の問題を参照してください。

検索フローテスト

テキスト入力と非同期検索結果を検証します。

testWidgets('検索バー → キーワード入力 → 結果表示', (tester) async {
  await tester.pumpWidget(const MyApp());
  await tester.pumpAndSettle();

  // 検索バータップ → 検索画面へ遷移
  await tester.tap(find.byType(SearchBar));
  await tester.pumpAndSettle();

  // キーワード入力
  await tester.enterText(find.byType(TextField), '検索語');
  await tester.pumpAndSettle();

  // 検索結果確認
  expect(find.byType(SearchResultItem), findsWidgets);
});

E2Eテストは実際の時間が流れるため、debounceタイマーは自然に期限切れになります。pumpAndSettle()はその後UIが安定するまで待機します。そのため別の待機ロジックは不要です。

実行方法

この記事ではAndroidエミュレーター基準で説明します。iOSシミュレーターでもflutter test integration_testで同様に実行可能です。

ローカル実行

エミュレーターが接続された状態で以下のコマンドを実行します。

flutter test integration_test/app_test.dart -d <device-id>

またはbin/ディレクトリにDartスクリプトを作成してエミュレーター自動検出、自動起動、前回アプリの強制終了を自動化できます。

// bin/e2e_test.dart
import 'dart:io';

Future<void> main(List<String> args) async {
  // エミュレーター自動検出(flutter devices --machineの出力をパース)
  String? deviceId = await _findRunningEmulator();
  if (deviceId == null) {
    // エミュレーター自動起動(flutter emulators --launchを使用)
    deviceId = await _startAndroidEmulator();
  }

  // 前回テストのアプリプロセスを強制終了(ハング防止)
  if (deviceId != null) {
    await Process.run('adb', [
      '-s', deviceId, 'shell', 'am', 'force-stop', 'com.example.app',
    ]);
  }

  // E2Eテスト実行
  final testProcess = await Process.start('flutter', [
    'test', 'integration_test', '-d', deviceId!,
    ...args,
  ], mode: ProcessStartMode.inheritStdio);

  exit(await testProcess.exitCode);
}

// _findRunningEmulator、_startAndroidEmulatorの実装は
// flutter devices --machineのJSON出力パース、
// flutter emulators --launch呼び出しなどで構成します。
dart run bin/e2e_test.dart

CI (GitHub Actions)

workflow_dispatch(手動トリガー)またはPRラベルで実行するワークフローを構成します。E2Eテストは実行時間が長くコストがかかるため、PR毎に自動実行するのではなく、必要時のみ手動トリガーすることを推奨します。

name: E2E Test
on:
  workflow_dispatch:
  pull_request:
    types: [labeled]
jobs:
  e2e_test:
    name: E2E Test
    if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'e2e-test'
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'
      - name: Install dependencies
        uses: ./.github/actions/install_dependencies
      - name: Enable KVM
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
            | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm
      - name: Accept Android SDK licenses
        run: yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
      - name: Cache Gradle
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: gradle-${{ runner.os }}-
      - name: Pre-build APK
        run: flutter build apk --debug
      - name: Run E2E tests
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          target: google_apis
          arch: x86_64
          emulator-options: >-
            -no-window -gpu swiftshader_indirect -noaudio
            -no-boot-anim -camera-back none
          disable-animations: true
          emulator-boot-timeout: 300
          script: flutter test integration_test/app_test.dart

主要ポイント:

  • KVM有効化:Linuxランナーでエミュレーターのハードウェアアクセラレーションに必要です。
  • SDKライセンス同意:システムイメージを初めてインストールする際のライセンス同意プロンプトでCIがハングする可能性があります。yes |で自動同意します。
  • Gradleキャッシュ:FlutterアプリのGradle初回ビルドはCI環境(2 vCPU)で35分以上かかることがあります。キャッシュにより2回目以降は大幅に短縮されます。
  • Pre-build APK:エミュレーター実行とGradleビルドを同時に行うとリソース(CPU/メモリ)が不足する可能性があります。APKを先にビルドしてエミュレーターステップではテスト実行のみにします。
  • target: google_apis:Firebaseを使用するアプリはGoogle Play Servicesが含まれたシステムイメージが必要です。

トラブルシューティング

pumpAndSettleがハングする場合

pumpAndSettle()はすべてのフレームとアニメーションが停止するまで待機します。ローディングスピナー(CircularProgressIndicator)のような無限繰り返しアニメーションや、アクティブなTimer/Streamが継続的にフレームをトリガーすると、永久に待機します。

解決: 非同期データロードを待つ必要がある場合、pumpAndSettle()の代わりにポーリングループを使用します。

// 特定のウィジェットが表示されるまで最大10秒待機
for (var i = 0; i < 20; i++) {
  if (find.byType(AnswerButton).evaluate().isNotEmpty) break;
  await tester.pump(const Duration(milliseconds: 500));
}

ダイアログがタップをブロックする場合

pumpAndSettle()Future.delayedのような非同期タイマーも処理します。そのため「300ms後にダイアログ表示」というコードがあると、pumpAndSettle()完了時点ですでにダイアログが画面を覆っている可能性があります。

ダイアログが原因かどうか確認するには、テスト失敗ログでRenderAbsorbPointerを探してください。このウィジェットはダイアログの背景オーバーレイで、背後のウィジェットへのすべてのタップをブロックします。

解決: SharedPreferencesフラグでダイアログ自体を無効化するのが最も安定的です。それが難しい場合は、ダイアログを閉じてから次の操作を行います。

// ダイアログが表示されていれば閉じるボタンをタップ
final closeButton = find.text('閉じる');
if (closeButton.evaluate().isNotEmpty) {
  await tester.tap(closeButton);
  await tester.pumpAndSettle();
}

展開されたウィジェットのタップ位置の問題

tester.tap()はウィジェットの中央をタップします。ExpansionTileが折りたたまれた状態では中央がヘッダー領域ですが、展開された状態ではウィジェットの高さが大きくなり、中央がコンテンツ領域に移動します。コンテンツ領域をタップしてもExpansionTileは折りたたまれません。

解決: 折りたたみ操作にはIcons.expand_lessのような折りたたみアイコンを直接タップします。

// 展開 — 折りたたまれた状態ではウィジェット中央がヘッダーに位置するため正常動作
await tester.tap(find.byType(MyExpansionWidget).first);

// 折りたたみ — 展開された状態ではアイコンを直接タップ
await tester.tap(find.byIcon(Icons.expand_less).first);

エミュレーター再実行時のハング

E2Eテスト実行後、エミュレーターでアプリを終了せずにすぐ再実行すると、アプリインストール/起動段階でハングが発生することがあります。前回のアプリプロセスがデバイスでまだ実行中であるためです。

解決: テスト実行前にアプリを強制終了します。

adb -s emulator-5554 shell am force-stop com.example.app

上記のローカル実行スクリプトでこの作業を自動化できます。

まとめ

Flutter 3.xでのE2Eテスト環境構築はフレームワークレベルで大幅に簡素化されましたが、実際のプロジェクトに適用する際は外部依存関係の制御が核心です。

要約:

  • integration_testパッケージ追加のみで基本設定完了
  • Firebase、広告、アプリ内課金などはテスト専用エントリーポイントでモッキング
  • すべてのテストを単一app_test.dartで実行して初期化の重複を防止
  • pumpAndSettle()の動作を理解してダイアログ/アニメーションに対応
  • CIではGradleキャッシュとPre-buildが必須

私のブログが役に立ちましたか?下にコメントを残してください。それは私にとって大きな大きな力になります!

アプリ広報

今見てるブログを作成たDekuが開発したアプリを使ってみてください。
Dekuが開発したアプリはFlutterで開発されています。

興味がある方はアプリをダウンロードしてアプリを使ってくれると本当に助かります。



SHARE
Twitter Facebook RSS