Flutterアプリをテストして、カバレッジを見える化する

アプリエンジニアの伊藤です。以前はこんな記事を書きました。

tech.andpad.co.jp

その後、Flutterを採用して新規アプリを開発することになりました。 今回はそのFlutterでのアプリ開発を交えて、アプリチームで注力しているテストに関する内容を中心に書いていこうと思います。

本記事の説明のためにサンプルアプリを作っていますので、こちらも参照していただき、より理解を深めていただければ幸いです。

github.com

Flutterアプリの構成とテスト

テストの説明に入る前に、新規開発したアプリ構成についてまとめます。その後、そのアプリに対しどのようにテストコードを書いていくかを説明します。Flutterには3種類のテストのテストがありますが、今回はUnit TestWidget Testについての内容になります。

providerを使ったMVVM

Flutterアプリの状態管理については、日々新しいものが出てきてキャッチアップが大変だと感じています。そんな中で新規アプリ開発で採用したのは、providerを使ったMVVMの構成です。(providerは状態変化を通知してくれるシンプルなライブラリです)

f:id:tomo-ito:20201115183118p:plain:w800

ブロック内が実装クラスの名称で、それぞれの役割は以下の通りです。

  • screen 画面表示レイアウトを担うwidgetクラスです。modelからの状態変化の通知を受けて画面を再描画します。またボタンタップ等の操作をmodelに通知します。

  • model screenが必要とする表示データの状態を管理するクラスです。ChangeNotifierによる通知機能を有して、screenに状態変化を通知します。また、repostitoryから必要なデータの取得も行います。

  • repository 通信データやローカルデータを抽象化して、modelがデータアクセスを容易にできるようにします。

この構成を採用したのは、シンプルで学習コストが低いことと、Googleが公式に推奨していることが理由です。(Simple app state management)

現在では数多くの状態管理ライブラリが公開されてるので、自分達の技術の習得具合と今後の普及状況によって取り入れを検討していくのが良いのかなと考えています。 (技術の最新動向について、同僚の@karamageさんのこちらの記事がとてもわかりやすいです😄)

Unit Testを書く

さて、テストに話しを戻します。 まずはUnit Testについてで、ロジックを検証するために書くので、modelとrepositoryが対象となります。

Unit Testを書く際は、外部リソースや実行環境に依存する部分をモック化するのが定番ですが、Flutterにおいてもネイティブ開発でおなじみのmockitoを使うことができます。

DartにはImplicit interfacesという仕組みがあり、クラス宣言にて、実装済みクラスをimplements句で指定することで、プロパティや関数をインターフェースとして取り込むことができます。(他の言語のinterfaceやprotocolなどを別途宣言する必要がありません)

Dart版mockitoでもこの仕組みを使っていますし、自作のモックやスタブを作る際にもとても役に立つ機能で気に入っています。

repositoryのテストコード

以下にrepositoryクラスに対するUnit Testを示します。

import 'package:flutter_list_sample/data/http_client.dart';
import 'package:flutter_list_sample/data/work_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';

class MockHttpClient extends Mock implements HttpClient {}

void main() {
  WorkRepository repository;
  MockHttpClient httpClient;

  setUp(() async {
    httpClient = MockHttpClient();
    repository = WorkRepository(
      httpClient: httpClient,
    );
  });

  test('fetchWorks()', () async {
    // arrange
    final body = '''[
      { "name":"仮設工事", "completed":false },
      { "name":"基礎工事", "completed":true },
      { "name":"木工事", "completed":false }
    ]''';
    when(httpClient.get(any)).thenAnswer((_) => Future.value(body));

    // act
    final works = await repository.fetchWorks();

    // assert
    expect(works.length, 3);
    expect(works[0].name, '仮設工事');
    expect(works[0].completed, false);
    expect(works[1].name, '基礎工事');
    expect(works[1].completed, true);
    expect(works[2].name, '木工事');
    expect(works[2].completed, false);
  });
}

ポイントとしては、外部リソースに依存するhttp通信クライアントをモック化して、テスト用のJSONデータを返すようにしています。JSONデータからWorkクラスのListデータに変換され、取得できていることが確認できます。

modelのテストコード

続いて、modelクラスに対してのUnit Testを示します。

import 'package:flutter_list_sample/logic/work_list_model.dart';
import 'package:flutter_list_sample/data/work.dart';
import 'package:flutter_list_sample/data/work_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';

class MockWorkRepository extends Mock implements WorkRepository {}

void main() {
  WorkListModel model;
  MockWorkRepository repository;

  setUp(() async {
    repository = MockWorkRepository();
    model = WorkListModel(workRepository: repository);
  });

  test('fetch()', () async {
    // arrange
    final works = [
      Work(name: "仮設工事", completed: false),
      Work(name: "基礎工事", completed: true),
      Work(name: "木工事", completed: false),
    ];
    when(repository.fetchWorks()).thenAnswer((_) => Future.value(works));

    // act
    await model.fetch();

    // assert
    expect(model.items.length, 1);
    expect(model.items[0], '基礎工事');
  });
}

repositoryクラスのときと同様に、mockitoでrepositoryをモック化しています。このテストではcompletedがtrueのものだけをitemsに設定していることを確認できます。

Widget Testを書く

次にWidget Testですが、こちらはUIが期待通りに表示されることを検証するために書くので、screenが対象となってきます。

screenのテストコード

screenクラスに対してのWidget Testを示します。

import 'package:flutter/material.dart';
import 'package:flutter_list_sample/logic/work_list_model.dart';
import 'package:flutter_list_sample/ui/work_list_screen.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:provider/provider.dart';

class MockWorkListModel extends Mock implements WorkListModel {}

class ModelContainer with ChangeNotifier {
  final WorkListModel model;

  ModelContainer({@required this.model});

  static Widget builder(BuildContext context, Widget child) {
    return ChangeNotifierProvider<WorkListModel>.value(
      value: context.watch<ModelContainer>().model,
      child: child,
    );
  }
}

void main() {
  MaterialApp app;
  MockWorkListModel model;

  setUp(() async {
    model = MockWorkListModel();

    app = MaterialApp(
      home: ChangeNotifierProvider(
        create: (_) => ModelContainer(model: model),
        builder: ModelContainer.builder,
        child: WorkListScreen(),
      ),
    );
  });

  testWidgets('WorkListScreen', (WidgetTester tester) async {
    // arrange
    when(model.items).thenAnswer((_) => ['基礎工事']);

    // act
    await tester.pumpWidget(app);

    // assert
    expect(find.text('工事一覧'), findsOneWidget);
    expect(find.text('基礎工事'), findsOneWidget);

    // dump
    debugDumpApp();
  });
}

検証内容としては、AppBarのテキスト表示(工事一覧)、ListTileの テキスト表示(基礎工事)をチェックしています。ListTileの表示データは modelのモッククラスで設定しており、それがUI上に表示されたことを確認できます。

最後の行にdebugDumpApp()とありますが、これは通常は目視で確認できないUIの状態をエミュレータ上で確認するためのものです。

flutter run path/to/widget_test.dart

のようにWidget Testのテストファイルを実行するとdumpを実行した タイミングのスナップショットがエミュレータ上に表示されます。

f:id:tomo-ito:20201115183127p:plain:w300

testコマンドの場合はログからテスト失敗の原因を読み取る必要があり、 中々難しいケースもあるかと思うので、この機能は助かります。

さて、アプリとテストコードの説明については以上ですが、弊社では テスト結果を見える化する取り組みをしており、そのご紹介もしたいと思います。

カバレッジの見える化

テストコードを書くというのは品質を高める上で大事なこと、というのは 誰もが頭ではわかっていることかと思います。しかし、日々の業務の中ではビジネス要件の優先度が高かったり、不具合の修正に追われたりで、テストコードの作成はおざなりになりがちです。実際に弊社でも、次々に発生する開発要件と開発リソース不足からテストにはあまり力をかけることができていませんでした。

そこで、少しずつでもみんなで意識をあげていこうと取り入れたのが、Codecovによるテストカバレッジの見える化です。 Codecovはカバレッジを計測、可視化してくれるWebサービスで、導入も簡単です。まずはネイティブアプリ側から積極的に活用を始めていますが、カバレッジは現在も高いとは言えないです。

この点については別途改善の取り組みがあり、またの機会にブログなどでご報告できればと思います。

Flutterでカバレッジ

続いてはFlutterでのカバレッジ出力についてご紹介したいと思います。 Flutterでテスト結果のカバレッジ出力するには--coverageオプションをつけます。

flutter test --coverage

完了すると、/coverage/lcov.infoが生成されます。 次にlcov.infoからカバレッジレポートのHTMLを出力する方法です。

brew install lcov
genhtml coverage/lcov.info -o coverage/html

上記を実行して生成されたHTMLのイメージを添付します。

f:id:tomo-ito:20201115183137p:plain

ここまででローカル上でカバレッジの確認はできるようになりました。

Codecovへのアップロード

それでは今度は生成されたlcov.infoをCodecovにアップロードする方法についてです。 実際の運用ではCIからアップロードすることになるかと思いますが、ローカルマシンのterminalからもアップロードは可能です(about-the-codecov-bash-uploader)。 既にCodecovとGitHubのリポジトリが連携されていることを前提として、以下のコマンドを実行します。

bash <(curl -s https://codecov.io/bash) -t {token} -f /coverage/lcov.info

成功するとWeb上でカバレッジに関する情報を確認できます。

Dashboard ⋅ tmyk110/flutter_list_sample

さいごに

いかがでしたでしょうか。今回は自分の開発経験を元に説明しやすい形のサンプルで提示させて頂きましたが、実際に開発するアプリやその運用はもっと複雑で一筋縄にいかないことが多いかと思います。特にWidget Testなどはscreenクラスに対してテストを書くのは大変になってくるので、コンポーネント化された部品からはじめるのが現実的かなとも感じます。

弊社ではFlutterやテスト、自動化など新しい技術に挑戦できる環境がありますので、ぜひ興味を持っていただけたら嬉しいです!

engineer.andpad.co.jp