【Flutter】VSCode上でIntegration Testを動かす

FlutterのIntegration Testは、[公式のcookbook](https://flutter.dev/docs/cookbook/testing/integration/introduction)に載っている方法をそのまま利用すると、いくつか不便な点があります。


 - 毎回コマンドラインから実行する必要がある
 - 実行するたびにアプリのビルドが走って時間がかかる
 - ブレイクポイントを打ってデバッグすることができない

ということでなんとかしましょう。

対象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記事も参考にさせていただいたのでリンクを貼っておきます。

https://qiita.com/sensuikan1973/items/c8b56dfaf780e61af567

ではやり方を見ていきましょう。

#使うアプリ

アプリ自体は何でもいいです。今回はこうしました。

```dart:main.dart
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が設定してあります。

#テスト用にアプリを起動するコード

テスト用にアプリを起動するためのコードは次のような感じ。


```dart:test_driver/app.dart
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の名前と、ポート番号は適当です。


```json:launch.json
{
  "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が動いていることがわかります。

![Screen Shot 2019-11-24 at 19.49.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85139/72804f09-c6e7-583a-f55f-c13cd412ee8a.png)


###細かい注意(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](https://dartcode.org/releases/v3-6/#debugger))



```dart:test/integration_test.dart
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文で回避する手があります。

```dart:test/integration_test.dart
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も書きましたが、載せるタイミングがありませんでした。何が誰の参考になるかもわからないので載せておきます。

```test/unit_test.dart
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'));
  });
}

```


```test/widget_test.dart
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);
  });
}

```