FlutterのIntegration Testは、公式のcookbookに載っている方法をそのまま利用すると、いくつか不便な点があります。
- 毎回コマンドラインから実行する必要がある
- 実行するたびにアプリのビルドが走って時間がかかる
- ブレイクポイントを打ってデバッグすることができない
ということでなんとかしましょう。
対象IDEはVSCodeです。
#参考元
こちらのMedium記事を大いに参考にします。
https://medium.com/flutter-community/hot-reload-for-flutter-integration-tests-e0478b63bd54
内容をザッッッッックリと要約すると
- 通常
flutter drive
は次の2つのプロセスを動かして相互通信させる- アプリ自体(シミュレーター上)
- テストコード(単なるDartプログラム)
- ならば、それぞれを自分でIDE上で動かして通信させれば良い
- 結果、Hot Reload/Hot Restart、ブレイクポイントなど自由自在
まあ、ザックリこういうことです。
リンク先の記事内ではAndroid Studioでの例が載っているのですが、
これを、VSCodeでやりたい!!というのが今回の目的です。
なお、こちらのQiita記事も参考にさせていただいたのでリンクを貼っておきます。
ではやり方を見ていきましょう。
#使うアプリ
アプリ自体は何でもいいです。今回はこうしました。
import 'package:flutter/material.dart';
void main() =>
runApp(MaterialApp(home: Scaffold(appBar: AppBar(), body: MyApp())));
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final Hoge _hoge = Hoge();
@override
Widget build(BuildContext context) {
return Column(children: <Widget>[
Text(_hoge.text, key: const Key('text')),
RaisedButton(
key: const Key('button'),
child: const Text('button'),
onPressed: () {
setState(() {
_hoge.change();
});
},
)
]);
}
}
class Hoge {
String text = 'hogehoge';
void change() {
if (text == 'hogehoge') {
text = 'fugafuga';
} else {
text = 'hogehoge';
}
}
}
ボタンを押すたびに、hogehogeとfugafugaが切り替わります。それだけ。
テストコードから中身にアクセスするため、Keyが設定してあります。
#テスト用にアプリを起動するコード
テスト用にアプリを起動するためのコードは次のような感じ。
import 'package:flutter_driver/driver_extension.dart';
import 'package:stepbystep/main.dart' as app;
void main() {
// app_test.dart の方とやりとりしたい場合はこの引数にhandlerを追加
enableFlutterDriverExtension();
// runAppに好きなWidgetを渡しても良い
app.main();
}
#アプリ自体(シミュレーター上)を動かす
launch.json
に、configurationを追加して次のようにします。
追加したconfigurationの名前と、ポート番号は適当です。
{
"version": "0.2.0",
"configurations": [
{
"name": "App of Integration Test",
"type": "dart",
"request": "launch",
"program": "test_driver/app.dart",
"args": ["--observatory-port", "8888", "--disable-service-auth-codes"]
},
{
"name": "Flutter",
"request": "launch",
"type": "dart"
}
]
}
この状態で、VSCode上から今追加したconfigurationを指定してデバッグを開始すると、test_driver/app.dart
に記述したアプリがシミュレーター上で走ります。
いつものようにHotReloadやHotRestartも可能です。ブレイクポイントも機能します。
そして重要な点は、ポート番号8888でこのアプリにアクセス可能になるということです。
試しにブラウザを開いて、アドレスバーに
http://localhost:8888/
と打ち込んでみましょう。
こんな感じの画面が出れば、確かにこのポート番号でFlutterが動いていることがわかります。
###細かい注意(DEBUG CONSOLEの出力)
configurationを指定してデバッグ開始した場合、DEBUG CONSOLEの出力がconfigurationごとに別々になるので注意してください。DEBUG CONSOLE内の右の方にプルダウンリストがあるのでそこから選択すればOK。
#テストコードの実行
Integration Testの公式cookbookでは、テストコードは/test_driver/
ディレクトリに入れることになっていますが、
VSCode上でテストコードをテストとして実行するためには、
_test.dart
で終わるファイル名がついたコードを/test/
ディレクトリ内に配置する必要があります。
(参考:v3.6 - Dart Code - Dart & Flutter support for Visual Studio Code)
import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';
void main() {
FlutterDriver driver;
setUpAll(() async {
driver =
await FlutterDriver.connect(dartVmServiceUrl: 'http://localhost:8888/')
.timeout(Duration(seconds: 10));
});
tearDownAll(() async {
if (driver != null) {
await driver.close();
}
});
test('the button changes the text from hogehoge to fugafuga', () async {
expect(await driver.getText(find.byValueKey('text')), equals('hogehoge'));
driver.tap(find.byValueKey('button'));
expect(await driver.getText(find.byValueKey('text')), equals('fugafuga'));
});
}
FlutterDriver.connect()
にdartVmServiceUrl
引数を与えると、そのURLを使ってアプリに接続しようとします。
なので、先程ブラウザで開いたのと同じhttp://localhost:8888/
を指定しましょう。
(timeout
については「問題点」の所で後述します)
あとは、VSCode上でこのファイルを開いた状態で、configurationにいつものFlutter
を指定し、デバッグを実行すればOKです。
Integration Testが実行されます。
テストコード内にブレイクポイントを打つと、きちんと機能します。
アプリを起動したまま、テストコードだけ繰り返し実行することもできます。
##プロセスを終了させる
これでテストはできましたが、実はテストを終了しても裏でプロセスが残っています。
メモリ・CPUの観点からもよくないし、port 8888も占有されたままです。
そのまま同じアプリの開発を続けるなら恐らく問題ないのですが、
違うアプリの開発に移ってから同様にIntegration Testをすると次のエラーが出ます。
flutter: Could not start Observatory HTTP server:
SocketException: Failed to create server socket (OS Error: Address already in use, errno = 48), address = 127.0.0.1, port = 8888
なので、このプロセスを終了させましょう。
次のコマンドを実行します。
lsof -i :8888
すると、port 8888を使用しているプロセスの一覧が出るので、PIDを指定してkillします。
kill (PIDを指定)
テスト終了時に自動でプロセスも終了したらいいんですけどね。そのような方法をご存知の方は教えていただけると嬉しいです!!!
#問題点
実はこの方法ですが、けっこうでかい問題点が含まれております。
何か解決案をお持ちの方は教えていただけると嬉しいです!
アプリ側の状況によってテスト結果が変わる
「アプリを1から起動し、テストを実行して、アプリを閉じる」という一連の流れを手動で断ち切ってしまっていますので、
アプリを操作したり、テストコードを連続で実行したりすると、結果に影響します。
例えばこの記事の例だと、Integration Testを一度実行するとtextがfugafuga
に切り替わっている(初期値はhogehoge
)ので、そのままもう一度テストを実行すると2回目は失敗します。
テストの独立性を保証する工夫が必要です。
とりあえず毎回アプリをHot Restartするのが一番ラクな気がします。
Dart: Run All Tests
でIntegration Testも実行されてしまう
Unit TestとWidget Testだけ全て実行したい場面でも、/test/
に配置したIntegration Testも一緒に実行されてしまいます。
その際にテスト用アプリが実行されていなければ、当然エラーになります。
上記コードではとりあえずFlutterDriver.connect
にタイムアウトを設定してありますので、アプリが実行されていない場合はここでタイムアウトしてIntegration Testが終了します。
でもそうすると、「Integration Test 以外のテストが全て通れば良しとする」という風に結果を自分で判断しなければいけません。
自動テストや自動デプロイを組んでいると影響が出そうです。
ただ、自動テストの場合はVSCode上で実行するわけではないので、自動で実行するテストをうまく指定できれば問題ないかもしれません。
Unit TestとWidget Testを/test/unit_and_widget_test/
のようなディレクトリにまとめて、launch.json
でこのディレクトリを指定してまとめて実行することも考えましたが、
この方法だと、一つのファイルを実行したらシェルが終了してしまうらしく、次のファイルを実行する際にエラーになりました。残念。
###一応解決策
FlutterDriver.connect
が失敗した場合にフラグ立てをして、テストケースの中身をif文で回避する手があります。
import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';
void main() {
FlutterDriver driver;
bool doTest = true;
setUpAll(() async {
driver =
await FlutterDriver.connect(dartVmServiceUrl: 'http://localhost:8888/')
.timeout(Duration(seconds: 3), onTimeout: () {
print('Flutter driver connection failed!!!!!');
doTest = false;
return null;
});
});
tearDownAll(() async {
if (driver != null) {
await driver.close();
}
});
test('the button changes the text from hogehoge to fugafuga', () async {
if (doTest) {
expect(await driver.getText(find.byValueKey('text')), equals('hogehoge'));
driver.tap(find.byValueKey('button'));
expect(await driver.getText(find.byValueKey('text')), equals('fugafuga'));
}
});
}
無理やり回避した感がすごいですが、一応、Run All Tests
の際も、Integration Test以外がすべて通ればOKを出してくれます。
(setUpAllはテストケースの実行が決定してから呼ばれるため、test関数のskip引数はうまく使えない)
#おわり
この記事は以上です!
Integration Testでもアプリコードやテストコードにブレイクポイントを打ってデバッグできるのでとっても便利です!
これでストア申請に必要なスクショも撮れますね。
でも問題点には注意が必要です。
最後に。記事に載せようと思ってUnit TestやWidget Testも書きましたが、載せるタイミングがありませんでした。何が誰の参考になるかもわからないので載せておきます。
import 'package:test/test.dart';
import 'package:stepbystep/main.dart';
void main() {
test('change method changes text', () {
final Hoge hoge = Hoge();
expect(hoge.text, equals('hogehoge'));
hoge.change();
expect(hoge.text, equals('fugafuga'));
hoge.change();
expect(hoge.text, equals('hogehoge'));
});
}
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:stepbystep/main.dart';
void main() {
testWidgets('button changes text',
(WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(home: MyApp()));
expect(find.text('hogehoge'), findsOneWidget);
await tester.tap(find.byKey(const Key('button')));
await tester.pumpAndSettle();
expect(find.text('fugafuga'), findsOneWidget);
await tester.tap(find.byKey(const Key('button')));
await tester.pumpAndSettle();
expect(find.text('hogehoge'), findsOneWidget);
});
}