はじめに
Flutterでは、テストコードを実装しやすい環境が整っています。この記事では、単体テスト・Widgetテスト・インテグレーションの実装方法を紹介します。
単体テスト
Dartの
test
パッケージを利用したテスト方法を紹介します。
testパッケージの利用
pubspec.yaml
に
test
を読み込むようにします。
1 2 3 4 | 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
に反映させるべきだと思います。
テストの書き方
まずは、このような簡単なクラスをテストしてみます。
1 2 3 4 5 6 7 8 | class Greeting { String greeting(String name) { if (name == null || name.isEmpty) { return '名前を入力してください'; } return 'こんにちは $name さん'; } } |
テストコードは、
test/
配下にDartファイルを作成し、実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | 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ボタンをクリックすると、そのテストのみが実行されます。
コマンドで実行するには次のようにします。
1 | $ flutter test test/greeting_test.dart |
モック化
例えば、テストコードを書こうとしたとき、テスト対象クラスが、Webサービスにアクセスするクラスに依存している場合、テストは自動化されても、実行環境を整える手間がかかります。
そこで、そのようなクラスはモック化(模倣)し、テストに必要なデータを返却するようにすることで、本来テストしたい領域のみを単体でテストすることができます。
mockitoの導入
pubspec.ymlに以下を追加し、Packages getします。
1 2 3 4 5 | dev_dependencies: flutter_test: sdk: flutter test: 4.1.9 mockito: ^4.1.1 # 追加 |
バージョンは、mockitoのページで最新版を確認してください。
メソッドのモック化
例として、非同期メソッドをモック化してみます。実際には、通信処理や、重い処理の実行をモック化する際に、使用できます。
こちらのサンプルコードをモック化します。
1 2 3 4 5 6 7 8 | class Greeting { Future <String> slowGreeting() async { return new Future.delayed(new Duration(seconds: 10), () { // 重い処理 return "こんにちは"; }); } } |
次に、モック化したテストコードです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | 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
クラスのモック化には使用できないので注意してください。
1 2 3 4 5 | test('thenReturn', () { final greeting = MockGreeting(); when(greeting.greeting("太郎")).thenReturn("こんばんは 太郎 さん"); expect(greeting.greeting("太郎"), "こんばんは 太郎 さん"); }); |
Widgetテスト
Flutterは、Widget単位でのテストもサポートしています。Widgetの状態遷移をテストコードで実装し、結果を検証することで、テストすることができます。
flutter_testの導入
flutter_testはプロジェクト作成時から入っていますが、入っていない場合はpubspec.yamlに以下を追加します。
1 2 3 | dev_dependencies: flutter_test: #追加 sdk: flutter #追加 |
テストの書き方
「単体テスト」で使用したGreetingクラスを使用して、Widgetを実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | 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のテストコードのサンプルです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | 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(ボタンなど)を取得することができます。
1 | find.text('名前を入力してください') |
find.byKey()メソッド
取得したいWidgetに設定した
Key
で、Widgetを特定します。
1 | find.byKey(const Key('name text field') |
Widgetの操作方法
findで取得したWidgetを操作する代表的な方法を紹介します。
tap()メソッド
1 | await tester.tap(find.text('あいさつする')); |
引数に渡されたボタンをタップします。
enterText()メソッド
1 | await tester.enterText(find.byKey(const Key('name text field')), '太郎'); |
第1引数で渡されたWidgetに、第2引数のテキストを入力します。
画面が変化した後に呼ぶpump()メソッド
ボタンアクション等によってWidgetツリーを更新した直後に
expect()
メソッドで検証する場合、ツリーが更新されるまで待つ必要があります。
そのような時は、
pump()
メソッドを挟む事で、処理待ちを行うことができます。
1 | await tester.pump(); |
検証
単体テストの時と同じように
expect()
メソッドを使用します。
1 | expect(find.text('こんにちは 太郎 さん'), findsOneWidget); |
第1引数に検証したいWidget、第2引数に取得したWidgetを検証する方法を指定します。検証方法は次の4つです。
- findsOneWidget
1つのWidgetが見つかること - findsNothing
Widgetが見つからないこと - findsWidgets
1つ以上のWidgetが見つかること - findsNWidgets
特定の数のWidgetが見つかること
テストの実行
単体テストと同じ方法で実行できます。
コマンドで実行するには次のようにします。
1 | $ flutter test test/greeting_widget_test.dart |
インテグレーションテスト
インテグレーションテストでは実機でアプリを実際に動作させ、自動的に操作・検証することで、アプリ全体を網羅的にテストします。
単体テストより実行に時間はかかりますが、実機で通しテストを行う点がポイントです。
「Widgetテスト」で使用したページを使用して、インテグレーションテストを行なっていきます。
flutter_driverの導入
pubspec.yamlに以下を追加します。
1 2 3 4 | dev_dependencies: flutter_driver: #追加 sdk: flutter #追加 test: any |
testパッケージに依存しているため、testパッケージも使用できるようにします。
テストの書き方
単体テストやWidgetテストとは異なり、インテグレーションテストはアプリとは異なるプロセスで実行されます。そのため、
test_driver
ディレクトリを作成し、配下に
app.dart
と
app_test.dart
を作成します。
ファイル構成は以下のようになります。
1 2 3 4 5 6 | YOUR_PROJECT_ROOT ├── lib │ └── main.dart └── test_driver ├── app.dart └── app_test.dart |
app.dartの実装
app.dart
は、FlutterDriverを有効化し、アプリをドライブする実装をします。
1 2 3 4 5 6 7 8 9 | 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
には、テストスイートを実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 | 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は以下のように取得します。
1 | find.byValueKey('name text field'); |
引数で渡す値は
Widget build()
で
key: const Key('name text field')
と指定した値を渡します。
Widgetの操作方法
findで取得したWidgetを操作する代表的な方法を紹介します。
tap()メソッド
1 | await driver.tap(greetingButton); |
引数に渡されたボタンをタップします。
enterText()メソッド
1 2 | await driver.tap(nameTextField); await driver.enterText(''); |
現在フォーカスが当たっているTextFieldにテキストを入力します。(既存のテキストは削除されます。)
WidgetTestの時とは異なり、あらかじめTextFieldをタップしてフォーカスを当てておく必要があるため、
tap()
メソッドとセットで使うケースがほとんどだと思います。
画面表示結果の検証
expect()
で検証します。
1 | expect(await driver.getText(greetingText), 'こんにちは 太郎 さん'); |
第1引数で
driver.getText()
メソッドを呼び出し、Widgetのテキストを取得します。第2引数に渡した文字列と合致しているかどうか、検証されます。
テストの実行方法
実機もしくはエミュレータ を起動している状態で、以下コマンドを実行すると、テストが実行されます。
1 | $ flutter drive --target=test_driver/app.dart |
テストが成功すると、以下のように出力されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | 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では単体からインテグレーションテストまで、幅広くテストを自動化できます。手軽に実装できるので、どんどん実戦投入していきたいものですね。