Flutter のテストをちゃんと書いてみる　〜ユニットテスト編〜

Flutter アプリ開発を初めてはや半年近く、正直に言ってテストコードなんて1行も書けていなかったので、ある程度アプリ開発の流れがわかったこのタイミングでちゃんと調べてみました。

[Flutterのテストについて調べた | @yujikawa](https://qiita.com/yujikawa/items/fe509b160df5ab9eb74e)

こちらの記事に記載の通り、Flutter にはユニットテスト(Unit tests)、ウィジェットテスト(Widget tests)、インテグレーションテスト(Integration tests)が用意されているため、この記事ではまずユニットテストを書くところから始めたいと思います。

# 参考
この記事を書くにあたり、主に以下の動画、ページを参考にしました。

## The Boring Flutter Development Show, Ep. 21 (Youtube)

[Testing Flutter Apps - Making Sure Your Code Works (The Boring Flutter Development Show, Ep. 21)](https://www.youtube.com/watch?v=bj-oMYyLZEY)

Google の開発者があれこれ雑談しながらノーカットで Flutter アプリを開発する様を垂れ流す __"The Boring Flutter Development Show"__ のテストコード回です。

Flutter 特有のテストの書き方や考え方に加え、どの程度の粒度でテストを書けば良いのか、テスト対象の可視性が `private` だった場合にどうすれば良いのか、など、テストコードを書く際のよくある疑問にも触れているため、 __「テストコードを書く」という行為自体に慣れていない（僕のような）人でも分かりやすく勉強になります__。

## Flutter 公式ドキュメント
[Testing Flutter apps - Flutter](https://flutter.dev/docs/testing)

Flutter 公式ドキュメントにある、テストに関するページです。
体系立てて調べたい場合はこちらを端から読んでいくと良いと思います。この記事も基本的にはこちらを読みながら進めていきます。

# 本文

## テスト用のアプリを用意する

ただテストパッケージで使えるメソッドを試していくだけでは楽しくないため、下のような 1 ページだけの簡単なアプリをテスト駆動っぽい進め方で作っていきたいと思います。

<img width="768" alt="IMG_0379.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134943/10be0ee8-a1e4-53f9-99c4-f92aa4ae8e60.jpeg">

記事一覧アプリの想定で、主な画面イメージと仕様は落書きに記載の通りです。テストコードを書きやすいよう、一部現実ではあまり見ないような仕様も入れています。（最後に「いいね」された日付を出す、とか）

## テスト対象になるクラスとメソッドのスケルトンを作成する

まずは、このアプリで登場するクラスを作成し、使うであろうメソッドをいくつか用意します。中身の実装はまだです。

<details><summary>lib/model/article.dart ... 記事データを表すクラス</summary><div>

```dart

class Article {
  String title;
  DateTime lastUpdated;
  bool isLiked;
  int likeCount;

  Article(
    this.title,
    this.lastUpdated,
    this.isLiked,
    this.likeCount,
  );
}
```
</div></details>

<details><summary>lib/model/article_list.dart ... 記事一覧データを管理するクラス</summary><div>

```dart

import 'package:testing_practice/model/article.dart';

class ArticleList {
  final List<Article> list;

  const ArticleList(this.list);

  List<Article> get dataSource {
    return list; // TODO: sort by "like"
  }

  int get totalLikeCount {
    return -1; // TODO: count total "like"
  }

  void add(Article article) {
    // TODO: add an article to list
  }
}
```
</div></details>

※ この記事ではテストコード以外のコードは折りたたんでいますので、必要に応じてクラス名左の三角アイコンをクリックして開いてください。

`article.dart` はタイトルや「いいね」の数など、記事データ1件分を表すデータクラスです。特にロジックは含まれないため、テスト対象外です。

`article_list.dart` は画面に表示する記事データのリストを管理します。追加や並び替え、「いいね」数のカウントなどもここでやります。つまり、今回はこのクラスをテストします。getter やメソッドも機能に合わせて、 `get dataSource` や `get totalLikeCount` 、 `add` を用意しています。

## flutter_test パッケージを追加する

Flutter アプリのテストには、`test` もしくは `flutter_test` パッケージを利用します。

Flutter プロジェクトを作成すると、デフォルトで `pubspec.yaml` に記載されているのは `flutter_test` の方で、これが Dart のユニットテスト用パッケージである `test` パッケージを含み、Flutter 独自の Widget をテストするための仕組みが追加されたパッケージになっているため、通常はこれをそのまま使えば問題ないでしょう。

```yaml:pubspec.yaml

dev_dependencies:
  flutter_test:
    sdk: flutter
```

## テストコードを書く

テストコードを置くフォルダは `/test` 以下です。ここにテストの内容やソース管理として適切な単位でファイルを作っていきます。
今回は（おそらく）よくあるパターンに倣って、 `/lib/model/article_list.dart` に対するテストとして `/test/model/article_list_test.dart` を作成します。

作成したらまずやることは `flutter_test.dart` のインポートと `main()` のコーディングです。

```dart:article_list_test.dart

import 'package:flutter_test/flutter_test.dart';

void main() {
}
```

なお、このあたりは参考で紹介した "The Boring Flutter Development Show, Ep. 21" の [17:45](http://www.youtube.com/watch?v=bj-oMYyLZEY&t=17m45s) あたりで同じことをやっています。併せて観てみてください。

次に、テストで利用するダミーデータを `setUp()` で用意します。 `setUp()` に渡した関数が一つひとつのテストケースが実行される直前に毎回呼ばれる仕組みです。

```dart:article_list_test.dart

void main() {
  ArticleList articleListForText;
  setUp(() {
    articleListForText = ArticleList(<Article>[
      Article('Flutter today', DateTime(2019, 4, 23), false, 15),
      Article('Master of Dart', DateTime(2018, 3, 13), false, 4),
      Article('10 most popular Softwares built with Flutter', DateTime(2018, 11, 4), false, 93),
      Article('The awesome tricks you must know!', DateTime(2017, 8, 1), false, 23),
      Article('Have you tried FlutterHub?', DateTime(2019, 10, 11), true, 5),
      Article('Discussion - Provider vs Redux', DateTime(2019, 8, 3), false,31),
    ]);
  });
}
```

次に、 `group()` と `test()` 、そして `expect()` でテストケースを追加していきます。

```dart:article_list_test.dart

  group('dataSource test', () {
    test("dataSource has 6 data, same as initial data count", () {
      expect(articleListForText.dataSource.length, 6);
    });

    test("dataSource has 7 data count in total after adding one data", () {
      articleListForText.add(Article("Flutter ver 1.10", DateTime(2019, 12, 12), false, 0));
      expect(articleListForText.dataSource.length, 7);
    });
  });

```

`group()` はテストケースをグループ化してテストケースの塊を分かりやすくするための関数です。
`test()` はテストケース1件を表す関数です。

`group()`、`test()` とも第1引数にグループやテストのタイトルを渡します。ここではグループ名に __「（Widgetが参照する想定の） `dataSource` に関するテスト」__ を、各テストケースとして __「`get dataSource` を通しても件数が変わらず6件であること」__ __「1件追加したら合計7件になること」__ の2つを追加しました。

`test()` の第2引数に関数でテストの内容を書いていきます。 `expect()` 関数を使って、第1引数と第2引数に渡した値が等しいかどうかをテストします。

```dart
expect(articleListForText.dataSource.length, 6);
```

このように書けば `articleListForText.dataSource.length` が `6` かどうかをテストして、等しければパス、違っていれば失敗となる、となります。

```dart
articleListForText.add(Article("Flutter ver 1.10", DateTime(2019, 12, 12), false, 0));
expect(articleListForText.dataSource.length, 7);
```

2つ目のテストは `expect()` 関数の前に `add()` で `Article` を1件追加したあとに件数が7件かどうかをテストしています。
コードからも、「初期状態6件に1件追加したので合計が7件になっているはず」というテストの意図が分かりやすいですね。

## テストを実行する

さて、テストケースが2つできたので、試しに実行してみたいと思います。

Flutter では、VSCode の拡張機能を使って、以下の "Run" ボタンからテストケースごと、グループごとに実行できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134943/6d024a8f-f3ee-84ce-44fe-77a8576065fd.png)

グループの方の "Run" ボタンをクリックして実行すると、結果が左側に、詳細なメッセージが右下のデバッグコンソールに表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134943/897cd8ee-9a18-cba4-d6f3-63e4716f6d6e.png)

今回は1つ目のテストケースはパス、2つ目のテストケースは失敗というのが分かりますね。失敗の原因も「7件の予想が実際には6件だった」とのことで、これはまだ `add()` メソッドを何も実装していないので想定内です。

1件目が成功しているのは、とりあえず `get dataSource` でそのまま保持しているリストを返却しているので、件数だけ見たら OK だったということですね。ただし「いいね」数順のソートをしていないため、この後並び順に関するテストを書いたら失敗すると思います。

なお、プロジェクト全体のテストを実行したい場合は、ターミナルで `flutter test` を実行します。

```
$ flutter test
00:01 +1 -1: dataSource test dataSource has 7 data count in total after adding one data [E]                 
  Expected: <7>
    Actual: <6>
  
  package:test_api                                   expect
  package:flutter_test/src/widget_tester.dart 229:3  expect
  model/article_list_test.dart 26:7                  main.<fn>.<fn>
  
00:01 +1 -1: Some tests failed.                        
```

## 残りのテストケースを書く

同じ要領で「いいね」数のカウントや並べ替えについてもテストを書いていきます。

```dart:article_list_test.dart

  group('order of dataSource', () {
    test('the first data is "10 most popular Softwares built with Flutter"', () {
      expect(
        articleListForText.dataSource.first.title,
        '10 most popular Softwares built with Flutter'
      );
    });

    test('the last data is "Master of Dart"', () {
      expect(
        articleListForText.dataSource.last.title,
        'Master of Dart'
      );
    });
  });

  group('total like count', () {
    test('total like count is 171', () {
      expect(articleListForText.totalLikeCount, 171);
    });
  });
```

ソート後の最初の記事、最後の記事のタイトルが想定通りかのテストや、トータルの「いいね」数がちゃんと 171（電卓で計算しました） になっているかどうかのテストを追加しています。

追加したらまたテストを実行し予想通り失敗することを確認します。（結果の添付は省略します）

## 機能を実装する

さて、テストが書けたら（本当はまだまだ少ないですが、、）いよいよ機能の実装です。

`lib/model/article_list.dart` の TODO 部分に並べ替え、「いいね」数のカウント、記事の追加のロジックを書いていきます。

<details><summary>各機能を実装した article_list.dart</summary><div>

```
class ArticleList {
  final List<Article> list;

  const ArticleList(this.list);

  List<Article> get dataSource {
    list.sort((article1, article2) => article2.likeCount - article1.likeCount);
    return list;
  }

  int get totalLikeCount {
    return list.fold(0, (current, next) => current + next.likeCount);
  }

  void add(Article article) {
    list.add(article);
  }
}
```

</div></details>

実装したら、改めてテストを実行して不具合がないかを調べます。

```
$ flutter test
00:01 +5: All tests passed! 
```

良いですね。5つ全てのテストがパスしました。

実装に何か不備があればここでテストが失敗して教えてくれます。実際、僕が最初にテキトーにコードを書いたら `dataSource` の並べ替えが逆になっていたため、以下のようにテストが失敗していました。

```
$ flutter test
00:01 +2 -1: order of dataSource the first data is "10 most popular Softwares built with Flutter" [E]       
  Expected: '10 most popular Softwares built with Flutter'
    Actual: 'Master of Dart'
     Which: is different.
            Expected: 10 most po ...
              Actual: Master of  ...
                      ^
             Differ at offset 0
  
  package:test_api                                   expect
  package:flutter_test/src/widget_tester.dart 229:3  expect
  model/article_list_test.dart 32:7                  main.<fn>.<fn>
  
00:01 +2 -2: order of dataSource the last data is "Master of Dart" [E]                                      
  Expected: 'Master of Dart'
    Actual: '10 most popular Softwares built with Flutter'
     Which: is different.
            Expected: Master of  ...
              Actual: 10 most po ...
                      ^
             Differ at offset 0
  
  package:test_api                                   expect
  package:flutter_test/src/widget_tester.dart 229:3  expect
  model/article_list_test.dart 39:7                  main.<fn>.<fn>
  
00:01 +3 -2: Some tests failed.                          
```

見てみると、並び順に関するそれぞれのテストケースの本のタイトルが期待と実際で逆になっていることがなんとなく書かれており、「あ、ソートが逆になっちゃってるんだな」ということにすぐに気づけました。便利です。

# まとめ

以上、この記事では Flutter プロジェクトの __ユニットテスト__ の部分をやってみました。

今までは「テスト駆動開発ってやった方がいいんだろうけど実際やったら手間暇かかるんだろうなー」という月並みな認識を持っていたのですが、今回ちょっとしたテスト駆動開発を実験してみて、確かに実装（と今後の変更）に対する安心感が段違いであることをこれだけでも感じられました。

テストケースごと、グループごとの実行も簡単にできるため、__実装した内容をいちいちアプリを起動して動作確認する必要なく素早くデバッグできるの__もアプリ開発におけるテストコードの魅力だと思います。

次回は Widget テストに挑戦してみたいと思います。お楽しみに。

---

この記事で使っているプロジェクトは GitHub にアップしてあります。必要に応じてご覧ください。

[chooyan-eng/FlutterTestPractice - GitHub](https://github.com/chooyan-eng/FlutterTestPractice)