カテゴリー: iOSAndroid

Flutterでテストコードを書こう! 単体テスト・Widgetテスト・インテグレーションテスト

はじめに

Flutterでは、テストコードを実装しやすい環境が整っています。この記事では、単体テスト・Widgetテスト・インテグレーションの実装方法を紹介します。

単体テスト

Dartの testパッケージを利用したテスト方法を紹介します。

testパッケージの利用

pubspec.yamltestを読み込むようにします。

dev_dependencies:
  flutter_test:
    sdk: flutter
  test: any # testを追加する

testは、使用中のFlutter SDKに含まれる、 flutter_testが依存しているため、 testの最新バージョンを使用してしまうと、Packages getに失敗してしまうことがあります。
その問題を解決するため、バージョン指定には anyを使用しています。
ただし、実際には、バージョンを指定すべきだと思うため、 anyでPackages getが成功したら、 pubspec.lockで実際にインストールされたバージョンを確認し、 pubspec.yamlに反映させるべきだと思います。

テストの書き方

まずは、このような簡単なクラスをテストしてみます。

class Greeting {
  String greeting(String name) {
    if (name == null || name.isEmpty) {
      return '名前を入力してください';
    }
    return 'こんにちは $name さん';
  }
}

テストコードは、 test/配下にDartファイルを作成し、実装します。

import 'package:auto_test_sample/greeting.dart';
import 'package:test/test.dart';

void main() {
  test('挨拶は「こんにちは 名前 さん」である', () { // テスト名を設定する
    final greeting = Greeting();
    var result = greeting.greeting('太郎');
    expect(result, 'こんにちは 太郎 さん'); / expectメソッドで検証する
  });

  test('名前がnullのときは「名前を入力してください」となる', () {
    final greeting = Greeting();
    var result = greeting.greeting(null);
    expect(result, '名前を入力してください');
  });

  test('名前が空文字のときは「名前を入力してください」となる', () {
    final greeting = Greeting();
    var result = greeting.greeting('');
    expect(result, '名前を入力してください');
  });
}

テストケースごとに、 test()関数を呼び出します。第一引数はテスト名、第二引数がテストの内容となります。

テスト結果の検証は expect()関数を使用します。第一引数は検証対象、第二引数は期待値となります。もし、期待値と異なれば、テストは失敗となります。

テストの実行

テストを実行するには、作成したテストコードのDartファイルを右クリックし、 Run tests in プロジェクト名を選択して実行できます。
test()単位でテストするには、テストコードの行番号の左側にあるRunボタンをクリックすると、そのテストのみが実行されます。
コマンドで実行するには次のようにします。

$ flutter test test/greeting_test.dart

モック化

例えば、テストコードを書こうとしたとき、テスト対象クラスが、Webサービスにアクセスするクラスに依存している場合、テストは自動化されても、実行環境を整える手間がかかります。
そこで、そのようなクラスはモック化(模倣)し、テストに必要なデータを返却するようにすることで、本来テストしたい領域のみを単体でテストすることができます。

mockitoの導入

pubspec.ymlに以下を追加し、Packages getします。

dev_dependencies:
  flutter_test:
    sdk: flutter
  test: 4.1.9
  mockito: ^4.1.1 # 追加

バージョンは、mockitoのページで最新版を確認してください。

メソッドのモック化

例として、非同期メソッドをモック化してみます。実際には、通信処理や、重い処理の実行をモック化する際に、使用できます。
こちらのサンプルコードをモック化します。

class Greeting {
  Future <String> slowGreeting() async {
    return new Future.delayed(new Duration(seconds: 10), () {
      // 重い処理
      return "こんにちは";
    });
  }
}

次に、モック化したテストコードです。

import 'package:auto_test_sample/greeting.dart';
import 'package:mockito/mockito.dart';
import 'package:test/test.dart';

// 1. モック用のクラスを宣言
class MockGreeting extends Mock implements Greeting{}

void main() {
  test('mockito', () async {
    // 2. モックのインスタンス化
    final greeting = MockGreeting();

    // 3. モックの実装
    when(greeting.slowGreeting()).thenAnswer((_) => Future.value("こんばんは"));

    var result = await greeting.slowGreeting();
    expect(result, 'こんばんは');
  });
}

まず、(1)のところでモック用のクラスを宣言します。 Mockクラスを継承し、モック化したいクラスをインターフェースとして設定します。

ちなみに、Javaではここで IGreetingと命名されるようなインターフェイスを定義しておき、 GreetingクラスとMockGreetingの両方に実装させることになると思います。
しかし、Dartではモック化したいクラスをそのままインターフェイスとすることができます。
なぜなら、Dartでは暗黙的インターフェイスという仕様があり、全てのクラスはインターフェイスとして利用できるためです。
その場合、クラス内の全てのメソッドやプロパティが実装必須となります。(privateなものは除きます。)

次に、モックの実装です。
(2)の部分でモックをインスタンス化し、(3)以降のところでモックを実装していきます。
when()メソッドでモック対象を指定し、thenAnswer()メソッドでメソッドの内容を書き換え、返り値を設定します。
今回は、Future<String>が戻り値なので、Future.value()で値を返却しています。

その他のモック化

thenAnswer()以外の、モック化の方法を紹介します。
単純に返り値だけを変更したいのであれば、thenReturn()メソッドが使用できます。ただし、Futureクラスのモック化には使用できないので注意してください。

  test('thenReturn', () {
    final greeting = MockGreeting();
    when(greeting.greeting("太郎")).thenReturn("こんばんは 太郎 さん");
    expect(greeting.greeting("太郎"), "こんばんは 太郎 さん");
  });

Widgetテスト

Flutterは、Widget単位でのテストもサポートしています。Widgetの状態遷移をテストコードで実装し、結果を検証することで、テストすることができます。

flutter_testの導入

flutter_testはプロジェクト作成時から入っていますが、入っていない場合はpubspec.yamlに以下を追加します。

dev_dependencies:
  flutter_test:   #追加
    sdk: flutter  #追加

テストの書き方

「単体テスト」で使用したGreetingクラスを使用して、Widgetを実装します。

import 'package:auto_test_sample/greeting.dart';
import 'package:flutter/material.dart';

class GreetingPage extends StatefulWidget {
  @override
  _GreetingPageState createState() => _GreetingPageState();
}

class _GreetingPageState extends State<GreetingPage> {
  var _greeting = Greeting();
  String _name;
  TextEditingController _controller;

  void initState() {
    super.initState();
    _controller = TextEditingController();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('挨拶ページ'),
      ),
      body: Center(
        child: Column(
          children: <Widget>[
            TextField(
              controller: _controller,
              decoration: InputDecoration(
                labelText: '名前'
              ),
              key: const Key('name text field'),
            ),
            Text(
              _greeting.greeting(_name),
              key: const Key('greeting text'),
            ),
            RaisedButton(
              child: Text('あいさつする'),
              onPressed: () {
                setState(() {
                  _name = _controller.text;
                });
              },
              key: const Key('greeting button'),
            )
          ],
        ),
      ),
    );
  }
}

名前を入力して、「あいさつする」ボタンをタップすると、テキストが更新されるシンプルなWidgetです。
このWidgetのテストコードのサンプルです。

import 'package:auto_test_sample/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('挨拶が表示されるテスト', (WidgetTester tester) async {
    // 1. Widgetツリーの構築
    await tester.pumpWidget(MyApp());

    // 2. 初期値のテスト
    expect(find.text('名前を入力してください'), findsOneWidget);

    // 3. テキストフィールドに名前「太郎」を入力する
    await tester.enterText(find.byKey(const Key('name text field')), '太郎');
    // 4. 「あいさつする」ボタンをタップする
    await tester.tap(find.text('あいさつする'));
    // 5. Widgetツリーの更新待ち
    await tester.pump();
    // 6. 表示された挨拶の文言をチェックする
    expect(find.text('こんにちは 太郎 さん'), findsOneWidget);
  });
}

テストコードの中身を見ていきましょう。

Widgetの取得方法

Widgetを取得する代表的な方法を紹介します。

find.text()メソッド

Textや、Textを内包しているWidget(ボタンなど)を取得することができます。

find.text('名前を入力してください')
find.byKey()メソッド

取得したいWidgetに設定した Keyで、Widgetを特定します。

find.byKey(const Key('name text field')

Widgetの操作方法

findで取得したWidgetを操作する代表的な方法を紹介します。

tap()メソッド
await tester.tap(find.text('あいさつする'));

引数に渡されたボタンをタップします。

enterText()メソッド
await tester.enterText(find.byKey(const Key('name text field')), '太郎');

第1引数で渡されたWidgetに、第2引数のテキストを入力します。

画面が変化した後に呼ぶpump()メソッド

ボタンアクション等によってWidgetツリーを更新した直後にexpect()メソッドで検証する場合、ツリーが更新されるまで待つ必要があります。
そのような時は、pump()メソッドを挟む事で、処理待ちを行うことができます。

await tester.pump();

検証

単体テストの時と同じようにexpect()メソッドを使用します。

expect(find.text('こんにちは 太郎 さん'), findsOneWidget);

第1引数に検証したいWidget、第2引数に取得したWidgetを検証する方法を指定します。検証方法は次の4つです。

  • findsOneWidget
    1つのWidgetが見つかること
  • findsNothing
    Widgetが見つからないこと
  • findsWidgets
    1つ以上のWidgetが見つかること
  • findsNWidgets
    特定の数のWidgetが見つかること

テストの実行

単体テストと同じ方法で実行できます。
コマンドで実行するには次のようにします。

$ flutter test test/greeting_widget_test.dart

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

インテグレーションテストでは実機でアプリを実際に動作させ、自動的に操作・検証することで、アプリ全体を網羅的にテストします。
単体テストより実行に時間はかかりますが、実機で通しテストを行う点がポイントです。
「Widgetテスト」で使用したページを使用して、インテグレーションテストを行なっていきます。

flutter_driverの導入

pubspec.yamlに以下を追加します。

dev_dependencies:
  flutter_driver:   #追加
    sdk: flutter  #追加
  test: any

testパッケージに依存しているため、testパッケージも使用できるようにします。

テストの書き方

単体テストやWidgetテストとは異なり、インテグレーションテストはアプリとは異なるプロセスで実行されます。そのため、 test_driverディレクトリを作成し、配下に app.dartapp_test.dartを作成します。
ファイル構成は以下のようになります。

YOUR_PROJECT_ROOT
├── lib
│   └── main.dart
└── test_driver
    ├── app.dart
    └── app_test.dart

app.dartの実装

app.dartは、FlutterDriverを有効化し、アプリをドライブする実装をします。

import 'package:flutter_driver/driver_extension.dart';
import 'package:YOUR_PROJECT_NAME/main.dart' as app;

void main() {
  // Flutter Driver拡張を有効にする
  enableFlutterDriverExtension();
  // アプリを起動する
  app.main();
}

app_test.dartの実装

app_test.dartには、テストスイートを実装します。

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


void main() {
  group('あいさつアプリのテスト', () {
    // あらかじめ、検証に使用するWidgetを取得しておく。
    final nameTextField = find.byValueKey('name text field');
    final greetingText = find.byValueKey('greeting text');
    final greetingButton = find.byValueKey('greeting button');
    final pushToCounterButton = find.byValueKey('push to counter button');
    final fab = find.byValueKey('fab');
    final counter = find.byValueKey('counter');

    FlutterDriver driver;

    // テスト実行前に呼ばれるコールバックで、テストアプリへの接続を実装する。
    setUpAll(() async {
      driver = await FlutterDriver.connect();
    });

    // テスト終了後に呼ばれるコールバックで、テストアプリの切断を実装する
    tearDownAll(() async {
      if (driver != null) {
        driver.close();
      }
    });

    test('初期画面', () async {
      expect(await driver.getText(greetingText), '名前を入力してください');
    });

    test('あいさつする', () async {
      await driver.tap(nameTextField);
      await driver.enterText('太郎');
      await driver.tap(greetingButton);

      expect(await driver.getText(greetingText), 'こんにちは 太郎 さん');
    });

    test('名前を空にすると初期表示に戻る', () async {
      await driver.tap(nameTextField);
      await driver.enterText('');
      await driver.tap(greetingButton);

      expect(await driver.getText(greetingText), '名前を入力してください');
    });

    test('カウンター画面(MyHomePage)に遷移する', () async {
      await driver.tap(pushToCounterButton);
    });

    test('カウンター画面の初期表示', () async {
      expect(await driver.getText(counter), '0');
    });

    test('カウンターのテスト', () async {
      await driver.tap(fab);
      expect(await driver.getText(counter), '1');
    });
  });
}

GreetingPageのテストと、カウンター画面(MyHomePage)への
テストコードの中身を見ていきましょう。

Widgetの取得方法

Widgetは以下のように取得します。

find.byValueKey('name text field');

引数で渡す値は Widget build()key: const Key('name text field')と指定した値を渡します。

Widgetの操作方法

findで取得したWidgetを操作する代表的な方法を紹介します。

tap()メソッド
await driver.tap(greetingButton);

引数に渡されたボタンをタップします。

enterText()メソッド
await driver.tap(nameTextField);
await driver.enterText('');

現在フォーカスが当たっているTextFieldにテキストを入力します。(既存のテキストは削除されます。)
WidgetTestの時とは異なり、あらかじめTextFieldをタップしてフォーカスを当てておく必要があるため、tap()メソッドとセットで使うケースがほとんどだと思います。

画面表示結果の検証

expect()で検証します。

expect(await driver.getText(greetingText), 'こんにちは 太郎 さん');

第1引数でdriver.getText()メソッドを呼び出し、Widgetのテキストを取得します。第2引数に渡した文字列と合致しているかどうか、検証されます。

テストの実行方法

実機もしくはエミュレータ を起動している状態で、以下コマンドを実行すると、テストが実行されます。

$ flutter drive --target=test_driver/app.dart

テストが成功すると、以下のように出力されます。

00:00 +0: あいさつアプリのテスト (setUpAll)

[info ] FlutterDriver: Connecting to Flutter application at http://127.0.0.1:58335/3Rh2eAUz0u0=/
[trace] FlutterDriver: Isolate found with number: 3659599010666555
[trace] FlutterDriver: Isolate is paused at start.
[trace] FlutterDriver: Attempting to resume isolate
[trace] FlutterDriver: Waiting for service extension
[info ] FlutterDriver: Connected to Flutter application.
00:01 +0: あいさつアプリのテスト 初期画面

00:01 +1: あいさつアプリのテスト あいさつする

00:02 +2: あいさつアプリのテスト 名前を空にすると初期表示に戻る

00:03 +3: あいさつアプリのテスト (tearDownAll)

00:03 +3: All tests passed!

Stopping application instance.

さいごに

Flutterでは単体からインテグレーションテストまで、幅広くテストを自動化できます。手軽に実装できるので、どんどん実戦投入していきたいものですね。

おすすめ書籍

カイザー

シェア
執筆者:
カイザー

最近の投稿

Goの抽象構文木でコードを解析する

はじめに Goでアプリケーショ…

2日 前

フロントエンドで動画デコレーション&レンダリング

はじめに 今回は、以下のように…

1か月 前

Goのクエリビルダー goqu を使ってみる

はじめに 最近携わっているとあ…

1か月 前

【Xcode15】プライバシーマニフェスト対応に備えて

はじめに こんにちは、suzu…

2か月 前