Open21

Flutterテスト

WaterWoodWaterWood

CI/CD

Continuous Integration(継続的インテグレーション)
Continuous Deployment(継続的デプロイ)

CI

開発者が自分のコードを頻繁にリポジトリに統合することを奨励するプラクティスです。各統合は自動化されたビルドとテストのプロセスを通じて検証されます。これにより、コードの品質が保たれ、バグが早期に発見されるようになります。

ステップ

  1. コードのコミット: 開発者がコードを小さい単位で頻繁にコミットしリポジトリに統合します。
  2. 自動ビルド: コードがリポジトリにプッシュされると、自動ビルドプロセスがトリガーされます。
  3. 自動テスト: ビルドされたコードは自動化されたテストスイート(一括テスト)を通じて検証されます。

CD

コードがビルドおよびテストプロセスを通過した後、自動的にデプロイされるプロセスです。これにより、ソフトウェアの新しいバージョンが迅速かつ信頼性高くリリースされることが保証されます。

ステップ

  1. デプロイ準備: CIプロセスが完了した後、コードがデプロイの準備が整います。
  2. 自動デプロイ: 自動デプロイプロセスがトリガーされ、ステージング環境または本番環境にリリースされます。

主なサービス

  • Github Actions: GitHubリポジトリと統合されたCI/CDツール。ワークフローをYAMLファイルで定義。
  • Jenkins: オープンソースの自動化サーバー。プラグインが豊富で柔軟なカスタマイズが可能。
  • CircleCI: クラウドベースのCI/CDプラットフォーム。高速なビルドとデプロイメントをサポート。
  • GitLab CI/CD: GitLabに組み込まれたCI/CD機能。リポジトリ管理と連携してシームレスなデプロイメントを実現。

GitHubをすでに使っているならGitHub Actionsが総合がスムーズだしセットアップも簡単(逆に言うとカスタマイズ性は低い。それは初心者には問題にはならない)。無料でも使用可能。そしてなにより利用者が多く情報も多い。
https://github.co.jp/features/actions
https://docs.github.com/ja/actions
https://qiita.com/s3i7h/items/b50ceb0008edc3c0312e
https://www.kagoya.jp/howto/it-glossary/develop/githubactions/

WaterWoodWaterWood

GitHub Actions - CI 継続的インテグレーション

数日前からテスト始めたタイミングでCI/CDとはなんぞやと調べていて、テストの後でいい?とAIに訊いたら今すぐやった方がいいよ!と言われたので初めてのGitHub Actions。
なるほど、たしかに早く始めるに越したことはない機能だ。

基本の書き方

まずは基本の基本の形。
CIで何を自動化するのかが良く分かる。
Actionsの公式で自動的に作ってくれるDart用のファイルをFlutter用に書き換えただけ。
GitHub Actions上でUbuntuを使い

  1. 自分のコードをクローン
  2. Flutterをインストール
  3. 各種パッケージをインストール
  4. 文法や型などコードに問題がないかチェック(静的解析)
  5. 自分で作ったテストを実行
name: Flutter Build and Test

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4 # リポジトリのコードをクローン

      # Flutter SDKのインストール 
      - uses: subosito/flutter-action@v2 
        with: 
          flutter-version: '3.24.0' # latestはダメだった

      - name: Install dependencies
        run: flutter pub get

      - name: Analyze project source
        run: flutter analyze

      - name: Run tests
        run: flutter test

ちょっと改良

GitHubにプッシュするたびに実行されるので

  1. Flutter SDKと依存関係のインストールにキャッシュを使う
  2. パッケージのアップデートの確認
  3. コードの自動フォーマット
  4. テスト結果を視覚化してくれるアプリCodecovの導入
name: Flutter Build and Test

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4 # リポジトリのコードをクローン

      - name: Cache Flutter SDK 
        uses: actions/cache@v3 
        with: 
          path: /opt/hostedtoolcache/flutter 
          key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.yaml') }} 
          restore-keys: | 
            ${{ runner.os }}-flutter-

      # Flutter SDKのインストール 
      - uses: subosito/flutter-action@v2 
        with: 
          flutter-version: '3.24.0' # latestはダメだった

      - name: Cache dependencies 
        uses: actions/cache@v3 
        with: 
          path:
            ~/.pub-cache
            ~/.flutter 
          key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.yaml') }} 
          restore-keys: | 
            ${{ runner.os }}-flutter-

      - name: Install dependencies
        run: flutter pub get

      - name: Check for outdated dependencies 
        run: flutter pub outdated

      - name: Check Code Formatting 
        run: dart format --set-exit-if-changed .
  
      - name: Analyze project source
        run: flutter analyze

      - name: Run tests
        run: flutter test --coverage # --machine でJSONに形式に変換できる。より高度な自動解析ではJSONにする必要がある。
        
      - name: Upload Code Coverage to Codecov
        uses: codecov/codecov-action@v5
        with: 
          file: ./coverage/lcov.info
        env:
          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

https://zenn.dev/yorifuji/articles/flutter-github-actions-template#check.yml

WaterWoodWaterWood

Flutterのテスト

dev_dependencies:
  flutter_test:
    sdk: flutter

ユニットテスト

ユニットテストは、アプリケーションの中で最小単位のコード(メソッドや関数)を個別にテストする方法です。これにより、各コンポーネントが期待通りに動作することを確認できます。

class Calculator {
  int add(int a, int b) {
    return a + b;
  }

  int subtract(int a, int b) {
    return a - b;
  }
}

テストコード

import 'package:flutter_test/flutter_test.dart';
import 'package:your_app/calculator.dart';

void main() {
  group('Calculator', () {
    test('足し算のテスト', () {
      final calculator = Calculator();
      expect(calculator.add(2, 3), 5); // 2 + 3 = 5を期待
    });
    test('引き算のテスト', () {
      final calculator = Calculator();
      expect(calculator.subtract(5, 2), 3); // 5 - 2 = 3を期待
    });
  });
}

重要なテスト手法

  1. SetUpとTearDown:
void main() {
  setUp(() {
    // 各テストケース前に実行するコード
  });

  tearDown(() {
    // 各テストケース後に実行するコード
  });

  test('テストケースの例', () {
    // テストケース
  });
}
  1. Mockの使用
  • モックは、外部依存関係をシミュレートするために使用されます。これにより、外部リソース(例: データベース、APIなど)への依存を最小限に抑え、テストの信頼性を向上させます。

ウィジェットテスト

ウィジェットテストは、FlutterアプリケーションのUIコンポーネント(ウィジェット)が期待通りにレンダリングされ、動作するかを検証するテストです。これにより、ユーザーインターフェースの正確性と一貫性を確認することができます。

import 'package:flutter/material.dart';

class MyWidget extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text('My Widget'),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Text('Hello, World!'),
              ElevatedButton(
                onPressed: () {},
                child: Text('Press me'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

テストコード

import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/my_widget.dart';

void main() {
  testWidgets('MyWidget has a title and message', (WidgetTester tester) async {
    // MyWidgetをウィジェットツリーに追加
    await tester.pumpWidget(MyWidget());

    // タイトルを確認
    expect(find.text('My Widget'), findsOneWidget);

    // メッセージを確認
    expect(find.text('Hello, World!'), findsOneWidget);

    // ボタンを確認
    expect(find.text('Press me'), findsOneWidget);

    // ボタンをタップして確認
    await tester.tap(find.text('Press me'));
    await tester.pump();

    // ボタンが押された後の挙動を確認(特に何も起きない場合、この部分は省略)
  });
}

重要なテスト手法

  1. pumpWidgetメソッド:
  • ウィジェットツリーにウィジェットを追加してレンダリングします。これにより、テスト対象のウィジェットが表示されます。
  1. findメソッド:
  • ウィジェットツリー内で特定のウィジェットを検索します。例えば、テキストやボタンを検索できます。
  1. ユーザー操作のシミュレーション:
  • tapやenterTextなどのメソッドを使って、ユーザー操作をシミュレーションします。
  1. pumpメソッド:
  • フレームを進めるために使用します。例えば、アニメーションやビルドメソッドの呼び出しをトリガーします。

統合テスト

統合テストは、アプリケーション全体のフローが正しく動作することを確認するテストです。これにより、アプリ全体が一貫して動作することを保証します。

あとで

WaterWoodWaterWood

ユニットテストで使う主な手法

expect

expect(実際の値, 期待する値);

定義
void expect(
  dynamic actual,
  dynamic matcher,(以下略)

matcher

期待する値と実際の値を比較するためのオブジェクト。

// actualとexpectedが同じか
expect(actual, equals(expected));

// nullチェック
expect(actual, isNull);
expect(actual, isNotNull);

// boolチェック
expect(actual, isTrue);
expect(actual, isFalse);

// 空かどうか
expect(actual, isEmpty);
expect(actual, isNotEmpty);

// 文字列含有
expect('Dart is fun', contains('fun'));

// 型チェック
expect(someObject, isA<MyClass>());

// 例外の時の書き方。someFunctionがSpecificExceptionをスローするか
expect(() => someFunction(), throwsA(isA<SpecificException>()));

// 数値やコレクション
expect(actual, greaterThan(expected));
expect(actual, lessThan(expected));

カスタムmatcher

matcherを作るうえで重要なポイントは

  1. Matcherクラスを継承
  2. matchesメソッドのオーバーライド: メイン機能!
  • 比較するメソッド。true / false を返す。
  1. describeメソッドをオーバライド:
  • エラーメッセージやデバッグ情報
class HasLength extends Matcher {
  final int length;
  HasLength(this.length);

  // ここがメイン機能!
  
  bool matches(item, Map matchState) {
    return item.length == length;
  }

  // Expectedのログ
  
  Description describe(Description description) {
    return description.add('has length $length');
  }

  // 失敗結果のログ
  
  Description describeMismatch(
      item, Description mismatchDescription, Map matchState, bool verbose) {
    if (item is! List) {
      return mismatchDescription.add('is not a List');
    }
    return mismatchDescription.add('has length ${item.length}');
  }
}

使用例
void main() {
  test('custom matcher example', () {
    final list = [1, 2, 3];
    expect(list, HasLength(3)); // テスト成功(ログ排出されない)
    expect(list, HasLength(2)); // テストは失敗。"has length 3"というログが表示される
  });
}

mock

mockとは「見せかけの,偽の,うわべだけの」の意味。
テストにおいて、外部のシステムやコンポーネント(データベースやウェブサービスなと)を必要とする際、偽物のオブジェクトを作ることで外部に依存することなくテストを実施できる。
たとえばテストでHTTPリクエストがなされた場合必ず400エラーが返ってくるようになっており、モックを利用したテストが必須となっている。
以下、HTTPリクエストを例にmockの利用を見る。

mockの作成

import 'package:http/http.dart' as http;
import 'package:mockito/annotations.dart';

([MockSpec<http.Client>()])
void main() {}

build_runnnerでmockを自動で作成する。

flutter pub run build_runner build

テストするコード

import 'package:http/http.dart' as http;

Future<bool> isUrlExists(String? url, {http.Client? client}) async {
  client ??= http.Client(); // テスト時はmockのHttpClientを使う

  final getResponse = await client.get(
    Uri.parse(url),
  );
  logger.i('GET request status code: ${getResponse.statusCode}');
  if (getResponse.statusCode == 200) {
    return true;
  }
  return false;
}

テストコード

group('isUrlExist関数', () {
    fina url = Uri.parse('https://example.com'); 
    test('GETリクエストが成功', () async {
      when(mockClient.get(url)).thenAnswer((_) async => http.Response('', 200));
      final result = await isUrlExists(url.toString(), client: mockClient);
      expect(result, isTrue);
    });

    test('URLが存在しない場合 (GETリクエストが失敗)', () async {
      when(mockClient.get(url)).thenAnswer((_) async => http.Response('', 404));
      final result = await isUrlExists(url.toString(), client: mockClient);
      expect(result, isFalse);

      // モックメソッドの呼び出しを検証 
      verify(client.get(url)).called(1);
    });
});

verify

mockが期待通りに呼び出されたかどうかを確認する。
何回呼び出されたか、どのような引数で呼び出されたかを検証するのに役立つ。

WaterWoodWaterWood

ウィジェットテストで使う主な手法

pumpWidget

WidgetTesterクラスのメソッド
まず最初にテストするウィジェットを立ちあげるのがpumpWidget。

await tester.pumpWidget(MaterialApp(home:Scaffold(body: Widget,)));

その他よく使うpump系関数

// ウィジェットツリーの状態をすぐに更新する
// ウィジェットの初期状態や、イベントが発生した直後の状態を確認する際に使われる
// 引数にdurationを設定して待つこともできる
// 
await tester.pump();
// 主にアニメーションなど時間がかかる処理を待つのに使う
// デフォルトでdurationが100msに設定されている
await tester.pumpAndSettle();

スナックバーの表示など、pumpAndSettleでもエラーになることがある。その時は時間をちゃんと設定する。

Finder

https://qiita.com/KKusumi/items/f458a42bbbf1958ad8d1#findbykey

find.text('見つけたい文字列');
find.widgetWithText(特定のWidget,'見つけたい文字列');
find.byKey(key); // keyを付けると見つけやすいかも
find.byType(Widgetの型);
find.byIcon(Icon);
// カスタマイズできるよ
find.byWidgetPredicate(
  (Widget widget) => widget is Tooltip && widget.message == 'Back',
  description: 'with tooltip "Back"'
);
// ofで渡したWidgetの子Widgetで、matchingに合うWidgetを探す。
find.descendant(
  of: find.byType(List),
  matching: find.byType(見つけたいWidget),
)
find.ancestor(); // descendantの反対

見つけたいWidgetが複数見つかり特定できない時

// testWidgetsでウィジェットテストの準備済みの前提

// Row内のPaddingウィジェットを取得
final rowPaddingFinder = find.descendant(
  of: find.byType(Row),
  matching: find.byType(Padding),
);

// デバッグ出力で確認
final paddingWidgets =
    tester.widgetList<Padding>(rowPaddingFinder).toList();
debugPrint('Row内のPaddingウィジェットの数: ${paddingWidgets.length}');
for (var padding in paddingWidgets) {
  debugPrint('Paddingウィジェット: ${padding.padding}');
}

// 取得したいPaddingウィジェットを特定
final targetPadding = paddingWidgets
    .firstWhere((p) => p.padding == const EdgeInsets.only(top: 2));
expect(targetPadding.padding, const EdgeInsets.only(top: 2));
WaterWoodWaterWood

resolve

// ボタンの背景色を設定
MaterialStateProperty<Color> backgroundColor = MaterialStateProperty.resolveWith((Set<MaterialState> states) {
  if (states.contains(MaterialState.pressed)) {
    // ボタンが押されたときの色
    return Colors.red;
  }
  if (states.contains(MaterialState.hovered)) {
    // ボタンがホバーされたときの色
    return Colors.green;
  }
  // 通常の状態の色
  return Colors.blue;
});

// 実際の色を取得するためにresolveを使います
// ボタンが押された状態の色を取得
Color pressedColor = backgroundColor.resolve({MaterialState.pressed});

// ボタンがホバーされた状態の色を取得
Color hoveredColor = backgroundColor.resolve({MaterialState.hovered});

// 通常の状態の色を取得
Color normalColor = backgroundColor.resolve({});

WaterWoodWaterWood

StatefulWidgetを使わずに状態の変化をテストする - ValueNotifier

StatelessWidgetだが渡されるisSendingの真偽によって表示が変わるボタンクラスをテストしたい。

final isSendingNotifier = ValueNotifier<bool>(false);
await tester.pumpWidget(
  buildTestWidget(
    child = ValueListenableBuilder<bool>(
      valueListenable: isSendingNotifier,
      builder: (context, isSending, child) {
        return StyledButton(
          'test button',
          isSending: isSending,
          onPressed: () {
          // Notifierに値の変更を伝える
            // isSending = true;ではダメ!
            // isSendingはBuilder内のローカル変数
            isSendingNotifier.value = true; 
          },
        );
      },
    ),
  ),
);
WaterWoodWaterWood

SvgPicture.assetのassetNameが取り出せない

SvgPicture.assetではassetNameつまりSvgファイルのパスを入力するのだが、その値をSvgPcture.asset.assetNameでは取り出せない(他のプロパティは取り出せるのに)。

SvgPicture SvgPicture.asset(
  String assetName, {(以下引数略)

というのもSvgPictureクラスではbytesLoaderというリソースローダーに情報が詰め込まれており、それがSvgPictureクラスではBytesLoader型だが、SvgPicture.assetだとSvgAssetLoader型に変換されている。

// SvgPictureクラスでは
final BytesLoader bytesLoader;

// SvgPicture.assetだと
bytesLoader = SvgAssetLoader(
  assetName,
  packageName: package,
  assetBundle: bundle,
  theme: theme,
)

なので呼び出すために型変換も必要となる。

final svgPicture = tester.widget<SvgPicture>(find.byType(SvgPicture));

// 本来SvgPictureクラスのbytesLoaderはBytesLoader型だが
// SvgPicture.assetの場合はSvgAssetLoader型に変換される
final svgAssetLoader = svgPicture.bytesLoader as SvgAssetLoader;
expect(svgAssetLoader.assetName, imagePath);
Hidden comment
WaterWoodWaterWood

Providerにモックをどう渡すか

Supabaseからアーティストのリストを取得するプロバイダーがある。
そのプロバイダーに依存オブジェクトであるSupabaseとLoggerのモックを注入したい。

// テストコード
void main(List<String> args) {
  group('artistListFromSupabaseProvider', () {
    late final SupabaseClient mockSupabase;
    late final MockSupabaseHttpClient mockHttpClient;
    late final MockLogger mockLogger;

    setUpAll(() {
      mockHttpClient = MockSupabaseHttpClient();
      mockSupabase = SupabaseClient(
        'https://mock.supabase.co',
        'fakeAnonKey',
        httpClient: MockSupabaseHttpClient(),
      );
      mockLogger = MockLogger();
    });

    setUp(() async {
      final artist = {
        'id': 1,
        'name': 'Artist 1',
        'image_url': 'https://example.com/artist1.jpg',
        'comment': 'Great artist',
      };
      await mockSupabase.from('artists').insert(artist);
      resetMockitoState();
    });

    tearDown(() async {
      mockHttpClient.reset();
    });

    test('Supabaseからデータを取得', () async {
      // ProviderContainer
      final container = createContainer(
        overrides: [
          loggerProvider.overrideWith((ref) => mockLogger),
          supabaseProvider.overrideWith((ref) => mockSupabase),
        ],
      );

      final artists =
          await container.read(artistListFromSupabaseProvider.future);

      expect(artists.length, 1);
      expect(artists.first.name, 'Artist 1');
      verify(mockLogger.i('Supabaseからアーティストデータを取得中...')).called(1);
      verify(mockLogger.i('1件のアーティストデータをSupabaseから取得しました')).called(1);
      verify(mockLogger.i('1件のアーティストデータをリストにしました')).called(1);
      // 忘れずコンテナは破棄する。そうしないと他のテストに影響してしまう。
      container.dispose();
    });

    // 例外処理のテスト(略)

  });
}

そのためにはまずSupabaseとLoggerのプロバイダーを作る。

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

final supabaseProvider = Provider<SupabaseClient>((ref) {
  return supabase;
});

final supabase = Supabase.instance.client;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logger/logger.dart';

final loggerProvider = Provider<Logger>((ref) {
  return logger;
});

final logger = Logger(
  printer: PrettyPrinter(
    lineLength: 70,
    methodCount: 0,
    errorMethodCount: 10,
  ),
);

テストで作ったProviderContainerでオーバーライドしたモックを渡すためには、渡したいオブジェクトのプロバイダーが必要となる。もともとsupabaseもloggerもプロバイダーを作らずやっていたから、ここの渡し方に苦戦した。

// テストするプロバイダー
final artistListFromSupabaseProvider =
    FutureProvider<List<Artist>>((ref) async {
  // 【重要】プロバイダーを作り、そこを通すことで
  // ProviderContainerでオーバーライドしたモックを受け取ることができる
  final logger = ref.read(loggerProvider);
  final supabase = ref.read(supabaseProvider);

  // fetchArtistList関数にモックを渡す
  return fetchArtistList(supabase, logger);
});

例外処理はmockSupabaseとは別のSupabaseClient(errorSupabaseを名付けた)を作り同じように渡してやれば例外がスローされるのでテストできる。

WaterWoodWaterWood

ProviderContainer

ProviderContainerオブジェクトの作成と破棄のためのテストユーティリティを作成することが推奨されます。テスト間でのProviderContainerの共有はしてはいけません。

import 'package:riverpod/riverpod.dart';
import 'package:test/test.dart';

/// [ProviderContainer]を作成し、テスト終了時に自動破棄するテストユーティリティです。
ProviderContainer createContainer({
  ProviderContainer? parent,
  List<Override> overrides = const [],
  List<ProviderObserver>? observers,
}) {
  // ProviderContainerを作成し、オプションでパラメータを指定します。
  final container = ProviderContainer(
    parent: parent,
    overrides: overrides,
    observers: observers,
  );

  // テスト終了時、containerを破棄します。
  addTearDown(container.dispose);

  return container;
}

https://riverpod.dev/ja/docs/essentials/testing

WaterWoodWaterWood

CI - GitHub Actionsにインテグレーションテストの導入

dev_dependencies:
  flutter_test:
    sdk: flutter
  integration_test: # <- ここを追加  
    sdk: flutter    # <- ここを追加

integration_testディレクトリを作り、テストファイルを作る。
アプリ自身を呼び出すため、main.dartを"as app"でインポートする。

import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_project_app/main_app.dart';
# " as app "でインポート
import 'package:my_project_app/main.dart' as app;

void main() {
  # インテグレーションテストで必須の一行
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('アプリの起動', (WidgetTester tester) async {
    # " as app "でインポートしたので app.main()でアプリを起動できる
    await app.main();
    await tester.pumpAndSettle();

    expect(find.byType(MainApp),findsOneWidget);
  });
}

https://github.com/marketplace/actions/android-emulator-runner

# アンドロイドエミュレータの環境を構築 
- 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: Set up Java 
  uses: actions/setup-java@v4 
  with: 
    distribution: 'zulu'
    java-version: '17'

# エミュレーターのキャッシュ 
- name: Gradle cache
  uses: gradle/actions/setup-gradle@v4
  
- name: AVD cache
  uses: actions/cache@v4
  id: avd-cache
  with:
    path: |
      ~/.android/avd/*
      ~/.android/adb*
    key: avd-${{ matrix.api-level }}

- name: create AVD and generate snapshot for caching
  if: steps.avd-cache.outputs.cache-hit != 'true'
  uses: reactivecircus/android-emulator-runner@v2
  with:
    api-level: ${{ matrix.api-level }}
    force-avd-creation: false
    emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
    disable-animations: false
    script: echo "Generated AVD snapshot for caching."

インテグレーションテストを実施。
ユニットテストとインテグレーションテスト、それぞれのカバレッジファイルをひとつにまとめる。

- name: Unit Test
  run: flutter test --coverage

- name: Integration Test
  uses: reactivecircus/android-emulator-runner@v2
  with:
    api-level: ${{ matrix.api-level }}
    force-avd-creation: false
    emulator-options: -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
    disable-animations: true
    script: flutter test integration_test/main_test.dart --coverage --coverage-path=coverage/integration_lcov.info

# カバレッジファイルをマージするのに必要
- name: Install lcov
  run: |
    sudo apt-get update
    sudo apt-get install lcov

- name: Merge coverage files
  run: lcov -a coverage/lcov.info -a coverage/integration_lcov.info -o coverage/lcov.info

# カバレッジを表示してくれるサイト「Codecov」にlcov.infoファイルを送る
- name: Upload Code Coverage to Codecov
  uses: codecov/codecov-action@v5
  with: 
    file: ./coverage/lcov.info
  env:
    CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
    slug: your_account

※ yamlでは" | "を最初に書くことで複数のコマンドが実行できる。

run: |
  echo "command 1"
  echo "command 2"
WaterWoodWaterWood

インテグレーションテスト

https://qiita.com/allJokin/items/8576ef79710d7e682c2c

ロボットパターン

https://zenn.dev/caphtech/articles/flutter-robot-test-library
「テストの目的(What)を明確にし、テストの方法(How)を隠蔽することができます。これにより、テストコードが読みやすく、再利用しやすくなります」

実際にアプリケーションを使う際、私達人間はどのような操作をするか、それを代替するロボットを作りテストを行う感じか。

WaterWoodWaterWood

スマホの画像を取り込む機能のテストをどうするか

image_pickerパッケージで実装しているスマホに保存されている画像を選択する機能をどうテストするか。そのままtester.tap()などとやると、スマホの画像フォルダが開いてしまいテストが難しい。そこでモックを作ることに。
https://qiita.com/Kurunp/items/db6c8fa94bbfb5c0c8d7

  1. MethodChannelで、FlutterからAndroidのAPIを呼び出すチャネルを作る。
  2. チャネルへのメソッド呼び出しをモックするハンドラを設定する。
  3. 'pickImage'が呼び出されたことをチェックする。
  4. テスト画像を読み込み、一時ファイルパスを作成しリターンする。
Future<void> _setMockImagePicker() async {
    // Androidと繋ぐチャネルを作る
    const channel = MethodChannel('plugins.flutter.io/image_picker');
    // モックの作成
    tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
      channel,
      (MethodCall methodCall) async {
        // メソッドのチェック
        if (methodCall.method == 'pickImage') {
          // テスト用画像のパスを取得
          final byteData = await rootBundle.load('assets/test.jpg');
          final tempDir = await getTemporaryDirectory();
          final tempPath = tempDir.path;
          final file = File('$tempPath/test.jpg');
          await file.writeAsBytes(
            byteData.buffer
                .asUint8List(byteData.offsetInBytes, byteData.lengthInBytes),
          );
          return file.path;
        }
        return null;
      },
    );
}

画像選択する際にモックを用意してテストする。

Future<void> selectImage() async {
    await _setMockImagePicker();
    
    await ensureVisibleWidget(WidgetKeys.image);
    await tapWidget(WidgetKeys.image);
    await tester.pumpAndSettle();
}
WaterWoodWaterWood

応用的finder

find.descendent

リストや似たような構造の中から特定のウィジェットを見つけたいとき、descendantつまり子孫を見つけるfinderを使う。
matchingに目的の要素を。
ofに目的の要素の上位にある要素を。

find.descendant(
    of: 祖先のFinder,
    matching: 目的のFinder
)

find.byWidgetPredicate

boolを返す関数であるpredicate(述語)を引数に取る。非常に柔軟なFinderを作ることができる。

find.byWidgetPredicate(
    (Widget widget) => widget is Tooltip && widget.message == 'Back',
)

https://api.flutter.dev/flutter/flutter_test/CommonFinders/byWidgetPredicate.html