Flutterの記事を整理し本にしました
- 本稿の記事を含む様々な記事を体系的に整理し本にまとめました
- 今後はこちらを最新化するため、最新情報はこちらをご確認ください
- 10万文字を超える超大作になっています(笑)
はじめに
個人アプリレベルであれば、テストはしなくても何となくで動作確認ができるのですが、リリースしてからしばらくたったアプリでバグでは?という連絡を頂いたりすると、流石に思い出して確認するのが難しいなと思いました。
それに、CICDなどの自動化を見据えるとやっぱりテストはおえておかないとなと思い、flutterのテストを調べることにしました。
まとめ
テストの概要
アプリケーション開発には試験が欠かせません。
当然Flutterにもテストの仕組みが準備されています。
ドキュメントもしっかりと提供されていますので、詳細を確認した方はこちらもご参照ください。
https://flutter.dev/docs/cookbook/testing
テストにはUnitテスト、Widgetテスト、Integrationテストという3種類が準備されています。
前者ほど細かい粒度で高速に実施することができ、後者ほど複雑な組み合わせを時間をかけて行う試験になります。
また、JenkinsやGithubActionのような仕組みを利用することで、テストを自動化することもできます。
Flutterとは直接関係がないので省略しますが、テストを先に記述しその後プロダクションコードを記述するという開発手法があり、TDD(テスト駆動開発)と呼ばれています。
「TDD,テスト駆動開発,テストファースト,Red/Green/Refactorサイクル,三角測量」などで調べて頂くと詳しい情報が見つかると思います。
テスト画面の基本
VSCodeでテストを行う場合はテスト用の画面で行います。
まず、左側のフラスコのマークでテストの画面を開きます。
すると、左側に試験の一覧が表示されます。
テストの実行方法
テストの実行は、サイドメニュー上記のアイコンもしくは、Run/Debugから実行できます。
テストは最終的にはすべてを通すのが基本ですが、毎回すべての試験を実施すると極めて時間がかかるので、部分的に実行することがよくあります。
mainの上のRun/Debugは全体の実行を表し、testの上のRun/Debugは1テストケース分だけを表しています。
後述するgroupを使うと、testを一定のグループに分けられ、この単位で実行することもできます。
RunとDebug
- Run:エラーが起きてもそのままOK,NGを淡々と返します
- Debug:エラーや例外が発生すると、そこで中断し細かい情報が出力されます
テストのアイコンの意味
テストには、成功/スキップ/失敗の3つの状態があります。
状況によってわかりやすく結果が可視化されます。
緑が成功、黄色がスキップ、赤が失敗を表現しています。
場合によっては、失敗は期待通りの値が返ってこなかった場合を表し、何らかの理由でテスト自体が進められなくなる場合をエラー表す分類もありますが、今回は上記3種類とします。
テストの種類と実行方法
Unitテスト(単体テスト)
一番シンプルで一番小さい粒度で行われるテストです。
単一のロジックやメソッドの試験を行う場合に用いられます。
まずは、一番シンプルなテストケースを書いてみます。
testメソッドで、テストケース名と処理を記載します。
処理の中で、expectを用いてテストを行い、実測値と期待値を比べます。
次に、期待する値を15に変更し一致しないようにしてみます。
期待する値にならなかっため、失敗になりました。
期待値は15ですが、実測値は10だったため、一致せずNGになった事がわかります。
実行中で止めているため、アイコンはまだ失敗にはなっていません。
次にもう少しUnitテストらしいテストケースを書いてみます。
Calcクラスのaddメソッドのテストを書いています。
addメソッドは2つのパラメタを受け取って合計を返すメソッドなので、戻り値が合計になっているかテストしています。
実行後結果が緑(成功)になったため、試験をパスしたことがわかります。
Widgetテスト
Widgetテストは、Widgetを論理的に構築した上で、タップするなどの動作を再現し動作を確認する試験です。
エミュレータは使わないものの、実際のWidgetツリーを論理的に再現し、Widgetの操作をして結果を確認します。
今回は、HelloWorldのWidgetテストを見ていきます。
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// 画面を構築する
await tester.pumpWidget(MyApp());
//0が書かれているWidgetが1つであることをテストする
expect(find.text('0'), findsOneWidget);
//1が書かれているWidgetがないことをテストする
expect(find.text('1'), findsNothing);
// アイコンが「+」のWidgetをタップする
await tester.tap(find.byIcon(Icons.add));
// Widgetツリーの再構築
await tester.pump();
//0が書かれているWidgetがないことをテストする
expect(find.text('0'), findsNothing);
//1が書かれているWidgetが1つであることをテストする
expect(find.text('1'), findsOneWidget);
});
コメントにもありますが、下記のような流れになっています。
- 画面を構築
- Widgetが期待する通りの状態になっているかテスト
- 「+」ボタンを論理的にタップ
- 再描画を待つ
- 再描画後のWidgetが期待する通りの状態になっているかテスト
Integrationテスト
Integrationテストは、エミュレータを用いて実際の動作に近い環境で行う試験です。
まずはflutter_driverパッケージを追加します。
dev_dependencies:
flutter_driver:
sdk: flutter
test: any
つぎに、テスト対象のHelloWorldにkeyを足します。
これはテストケース側で、値の確認や操作をする際にWidgetを特定するために用いられます。
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
+ key: Key('counter'),
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
+ key: Key('increment'),
onPressed: _incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
}
}
Integrationテストは、エミュレータ上のアプリとテストコードの2つが独立して相互に通信をしながらテストを行います。
そのため、UnitテストやWidgetテストのように1つのプロセスの中で完結せず、2つのファイルを準備して実行する必要があります。
慣例的にディレクトリの名前はtest_driver
を使い、その下に2つのファイルを配置します。
テストファイル名は {アプリファイル}_test.dart とする必要があります。
integration_testなど別の名前や別の場所に配置すると、file not found になるので、気をつけてください。
import 'package:flutter_driver/driver_extension.dart';
import 'package:hello_world/main.dart' as app;
void main() {
// 別プロセスのアプリケーションを起動できるようにするために拡張を有効にする
enableFlutterDriverExtension();
// アプリの起動
app.main();
}
import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';
void main() {
// テストケースをグループ化しておきますが、今回のように1ケースだとあまり意味はないです
group('Counter App', () {
// keyでWidgetを取得する
final counterTextFinder = find.byValueKey('counter');
final buttonFinder = find.byValueKey('increment');
FlutterDriver driver;
// すべてのテストの前にDriverに接続する
setUpAll(() async {
driver = await FlutterDriver.connect();
});
// すべてのテストの後にDriverを開放する
tearDownAll(() async {
if (driver != null) {
driver.close();
}
});
// テスト1:開始時にTextが0であることを確認する
test('starts at 0', () async {
expect(await driver.getText(counterTextFinder), "0");
});
// テスト2:incrementをタップすると、Textが1になることを確認する
test('increments the counter', () async {
// タップ操作
await driver.tap(buttonFinder);
// Textの検証.
expect(await driver.getText(counterTextFinder), "1");
});
});
}
最後にテストを行います。
VSCode上でこの2つを同時に起動して制御することは少し難しいので、今回はコマンド実行の例で動作イメージを紹介します。
flutter drive
というコマンドでテスト対象を指定して実行します。
$ flutter drive --target=test_driver/app.dart #テストの実行
## 以下結果
Running "flutter pub get" in hello_world... 1,676ms
Running Gradle task 'assembleDebug'...
Running Gradle task 'assembleDebug'... Done 32.7s
✓ Built build/app/outputs/flutter-apk/app-debug.apk.
Installing build/app/outputs/flutter-apk/app.apk... 6.1s
00:00 +0: Counter App (setUpAll)
VMServiceFlutterDriver: Connecting to Flutter application at http://127.0.0.1:50805/I1tLm84Priw=/
VMServiceFlutterDriver: Isolate found with number: 1073251873645387
VMServiceFlutterDriver: Isolate is paused at start.
VMServiceFlutterDriver: Attempting to resume isolate
VMServiceFlutterDriver: Connected to Flutter application.
00:05 +0: Counter App starts at 0
00:05 +1: Counter App increments the counter
00:06 +2: Counter App (tearDownAll)
00:06 +2: All tests passed!
起動するエミュレーターは、VSCodeの右下で選択されているものデフォルトで指定されます
テストを実行すると、エミュレータが起動して、自動で画面が操作され、最後に切断されるのが確認できます。
一般にIntegrationテストは、資材のコンパイルと配置やエミュレータの起動を行うため、実施コストが大きいです。
そのため、Unitテスト→Widgetテスト→Integrationテストと単純で高速な試験から順に通していくのが一般的です。
テストを効率化する仕組み
SKIP
何らかの理由で、一時的にテストケースを飛ばしたいときがあります。
このようなときは、理由とともにSKIPの設定を行っておくと、テストケースを削除するなどをせずに全体のテストケースへの影響を及ぼさないような運用が可能です。
ブレイクポイント
テストがうまくいかないときに、特定のポイントで一時停止をして、変数の確認や書き換えをしたり、1行ずつ実行したいときがあります。
そのようなときには、ブレイクポイントを張って、一時停止をすることができます。
ブレイクポイントは特定の条件のときに止めたりなど、様々な使い方ができます。
例外の試験
例外が発生するような試験(準正常系の試験)も行うこともできます。
下記のように、例外が起きることを期待値として準備しておき、実際に起きた場合に成功とすることができます。
void main() {
test('.parse() fails on invalid input', () {
expect(() => int.parse('X'), throwsFormatException);
});
}
事前処理/事後処理
テストの実行前後に共通的に行う処理がある場合は、専用のメソッドが準備されています。
setUpメソッドとtearDownメソッドが事前処理と事後処理に対応します。
一般的には、コネクションやDBデータなどの設定とクリアなどに使われることが多いです。
下記は、事前事後処理のサンプルコードとなります。
void main() {
HttpServer server;
Uri url;
setUp(() async {
server = await HttpServer.bind('localhost', 0);
url = Uri.parse('http://${server.address.host}:${server.port}');
});
tearDown(() async {
await server.close(force: true);
server = null;
url = null;
});
// ...
}
setUp,tearDownとsetUpAll,tearDownAllは実行タイミングが異なるので注意してください。
- setUp,tearDownはそれぞれのテストの前後で実施されます。
- setUpAll,tearDownAllはすべてのテストの前後で1度だけ実行されます