15
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

GLOBISAdvent Calendar 2022

Day 21

Flutterテストとgithubワークフロー設定の基本

Last updated at Posted at 2022-12-16

内容

github ワークフローで実現したいこと

  • code formatできてるかをチェック
  • flutter analyzeで静的分析
  • dependabotでpackage updateの自動化
  • test
    • unit test
    • widget test
    • golden test
    • code coverageチェック

まず、サンプルapp作成

templateを使ってアプリを作成するツールを使います。

基本的なものが整備されるので、便利です〜

  • Blocで実現した定番のcounterアプリ
  • Unit, widget testコード
  • github workflow

アプリを作成

dart pub global activate very_good_cli
very_good create learn_flutter_test
very_good packages get

テストコードを実行

サンプルのテストコードがあるので、早速実行して見ます

commandで実行する方法

fvm flutter test --coverage
# coverage/lcov.infoにcoverageが生成される

生成されたcoverageファイルををhtmlに変換して読めるようにする

brew install lcov
genhtml coverage/lcov.info -o coverage/html

coverage htmlのイメージ
スクリーンショット 2022-12-10 13.45.25.png

スクリーンショット 2022-12-10 14.51.22.png

Android studioで実行する方法

testフォルダを右クリックして、メニューRun 'tests in test with Coverage'をクリックすると、
IDE上の左側のfile browserでファイル毎のCoverageが確認できる
mojikyo45_640-2.gif

Gloden test

どんなテスト?

UIの期待画像を事前に生成しておいて、テストで期待画像を比較して、差分があるかを確認するUI testの一種です。

package golden_toolkitを使うので、以下のようにdependencyを指定します

dev_dependencies:
  golden_toolkit: ^0.14.0

ファイルtest/flutter_test_config.dartを作成して、以下のようなコードにする

  • これはテストが実行する前に実行するファイル
import 'dart:async';

import 'package:golden_toolkit/golden_toolkit.dart';

Future<void> testExecutable(FutureOr<void> Function() testMain) async {
  await loadAppFonts(); // localしないと、screenshotで文字が表示できない問題が発生します
  await testMain();
}

テストコード書く

test/counter_page_golen_test.dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
import 'package:learn_flutter_test/app/app.dart';

void main() {
  testGoldens('CounterPage init', (tester) async {
    await tester.pumpWidgetBuilder(const App());

    await screenMatchesGolden(tester, 'CounterPage_init');
  });
  
  testGoldens('CounterPage click', (tester) async {
    await tester.pumpWidgetBuilder(const App());
    await tester.tap(find.byIcon(Icons.add));
    await screenMatchesGolden(tester, 'CounterPage_click-1-times');
    await tester.tap(find.byIcon(Icons.add));
    await screenMatchesGolden(tester, 'CounterPage_click-2-times');
  });
}

glodenファイル(期待してるUI画像)を生成

fvm flutter test --update-goldens

test/goldensフォルダにファイルが作成されます
スクリーンショット 2022-12-10 13.55.42.png

testを実行

 fvm flutter test test/counter_page_golen_test.dart 
  • 成功の場合
    スクリーンショット 2022-12-10 13.57.22.png

  • 失敗の場合
    失敗させるために、コードを修正します
    lib/counter/cubit/counter_cubit.dartを修正して、変化の単位を10にする

  void increment() => emit(state + 1); //修正前
  void increment() => emit(state + 10); //修正後

テスト結果出力の抜粋です。結果から見ると、click1回、2回した後の画像が期待結果と異なってます。

══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════════════════════════
The following assertion was thrown while running async test code:
Golden "goldens/CounterPage_click-1-times.png": Pixel test failed, 0.90% diff detected.
....

The following assertion was thrown while running async test code:
Golden "goldens/CounterPage_click-2-times.png": Pixel test failed, 0.92% diff detected.
....

00:05 +1 -1: Some tests failed. 

実際の画像の差分はfailuresフォルダに出力した画像から確認できます

  • _testImage.png  ⇨ 実際出力した画像
  • _masterImage.png ⇨ 期待画像
  • _maskedDiff.png  ⇨ 差分の画像
  • _isolatedDiff.png  ⇨ 差分の箇所だけの画像
    failure.gif

responstive UIをテストしたい場合

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
import 'package:learn_flutter_test/app/app.dart';

void main() {
  testGoldens('CounterPage click', (tester) async {
    final builder = DeviceBuilder()
      ..overrideDevicesForAllScenarios(devices: [
        Device.phone,
        Device.iphone11,
        const Device(name: 'ipad pro', size: Size(1366, 1024))
      ]) // テストしたい端末かサイズを指定
      ..addScenario(
        widget: const App(),
        name: 'init',
      )
      ..addScenario(
        widget: const App(),
        name: 'tap add button 1 times',
        onCreate: (scenarioWidgetKey) async {
          final finder = find.descendant(
            of: find.byKey(scenarioWidgetKey),
            matching: find.byIcon(Icons.add),
          );
          await tester.tap(finder);
        },
      );
    await tester.pumpDeviceBuilder(builder);

    await screenMatchesGolden(tester, 'counter-page');
  });
}

生成されたgoldenファイルはこんな感じになります。
counter-page.png

Integration test

pubspec.yamlにintegration_test packageを指定する

dev_dependencies:
  integration_test:
    sdk: flutter

テストdriverを指定する

test_driver/integration_test.dart

import 'package:integration_test/integration_test_driver_extended.dart';

Future<void> main() async => integrationDriver();

テストコードを書く

integration_test/app_test.dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:learn_flutter_test/main_production.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('counter', (tester) async {
    debugPrint('---test start--');
    app.main();
    await tester.pumpAndSettle();
    final counter = find.byKey(const Key('count')).evaluate().single.widget as Text;
    expect(counter.data, '0');

    final addButton = find.byIcon(Icons.add);
    await tester.tap(addButton);
    await tester.pumpAndSettle();
    expect(counter.data, '1');

    await tester.tap(addButton);
    await tester.pumpAndSettle();
    expect(counter.data, '2');
    debugPrint('---test end--');
  });
}

テストを実行

まずはchromedriverをinstallしておきます。

いろんなtarget deviceでテストを実行する

chromedriver --port=4444
# chromeで実行する
fvm flutter drive --driver=test_driver/integration_test.dart --target=integration_test/app_test.dart -d chrome
# no-headlessで実行する
fvm flutter drive --driver=test_driver/integration_test.dart --target=integration_test/app_test.dart -d web-server --no-headless
# headlessで実行する
fvm flutter drive --driver=test_driver/integration_test.dart --target=integration_test/app_test.dart -d web-server --headless

Target deviceにより挙動が少し違います。

github workflowの設定

注意点: Gloden testの画像生成はOSにより差分があるので、worflow上のOSがlocalと同じにしないと、テストが失敗する可能性があります。

name: learn_flutter_test

on: [pull_request, push]

jobs:
  build:
    uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1
    with:
      flutter_channel: stable
      flutter_version: 3.3.8
      runs_on: macos-latest # localのOSと同じ

  integration-test:
    runs-on: macos-latest
    steps:
      - name: 📚 Git Checkout
        uses: actions/checkout@v3
      - name: Setup flutter
        uses: subosito/flutter-action@v2
        with:
          channel: stable
          flutter-version: 3.3.8
          cache: true
          cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }}
      - name: Run Test
        run: |
          chromedriver --port=4444 &
          flutter drive --driver=test_driver/integration_test.dart --target=integration_test/app_test.dart -d web-server

実行結果はここにあります。
https://github.com/qiuyin/learn_flutter_test/actions/runs/3705354235

出来上がったコード

15
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?