StateNotifierでFlutterの状態管理とユニットテスト入門（CIのおまけつき）

# はじめに
はじめまして、@glassmonkey と申します。
趣味でFlutterを書き始めた初心者です。
隔週で[Flutterのもくもく会](https://flutter-okurayama.connpass.com/)とかもやってたりするので、もしご興味ある方はご参加ください。

今回は[以前の勉強会](https://qiita.com/glassmonkey/items/75f6df813d0458e9cf5b)でFlutterにおけるユニットテストについて話題があがったので、少しまとめてみました。

また、その題材には以前から興味はあったものの手を出せていなかった[StateNotifier](https://pub.dev/packages/state_notifier)を扱いました。

おまけでFlutterのmatrix buildなGitHub Actionsも置いておきます。
リリースはこれからチャレンジなので、テストまでとなります。

# 扱う題材について

この記事で扱うコードは[flutter_state_notifer_sample](https://github.com/glassmonkey/flutter_state_notifer_sample)にあげているので、もし興味や評価していただけたらスターなどよろしくお願いします😂

今回はflutterプロジェクトを作ったときに生成させるカウンターアプリベースな感じです。
ただし、テストを書く意味を出すために、9の次は0に戻る感じのロジックにしました。

![counter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152938/8e8d2fe7-90ad-5bdd-062e-ffd550e7b998.gif)

# コードの解説

今回扱うケースでは、設計パターン的にはMVVMになるとのことです。参考: [state_notifier と freezed を使って、Flutterのカウンターアプリをつくるよ](https://qiita.com/karamage/items/4b1aff984b1af7541b73)
また、MVVMについては深くは触れませんが[こちらの記事](
http://blog.pionet.co.jp/experience/archives/2143#:~:text=MVVM%E3%81%A8%E3%81%AFGUI%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3,%E8%A1%8C%E3%81%86%E6%96%B9%E6%B3%95%E3%81%AE%E3%81%93%E3%81%A8%E3%81%A7%E3%81%99%E3%80%82)が参考になると思います。
今回のケースでは下記にわかれます

* Model
ロジックを担保。（今回のケースだと9カウントの次は0に戻す）

* ViewModel
Viewから受け取ったイベントをModelに通知する


* View
現在の状態を表示する。（カウント値の表示）
ユーザーから入力（イベント）を受け取る。（ボタンタップでイベントを発行する）

それぞれテストとセットで解説をします。

## Model

### Modelのアプリケーションコード
https://github.com/glassmonkey/flutter_state_notifer_sample/blob/master/lib/counter_model.dart

```dart
import 'package:equatable/equatable.dart';

//実際はfreezedとか使った方がよい
class CounterModel extends Equatable {
  const CounterModel({this.count = 0});

  CounterModel increment() {
    final int nextCount = count + 1;
    if (nextCount > 9) {
      return const CounterModel(count: 0);
    }
    return CounterModel(count: nextCount);
  }

  final int count;
  @override
  bool get stringify => true;
  @override
  List<Object> get props => <Object>[count];
}
```

今回は[Equatable](https://pub.dev/packages/equatable)の、countの値でのみ同一性が担保されるようにしています。

```dart
List<Object> get props => <Object>[count];
```


実務では[freezed](https://pub.dev/packages/freezed)で書いたほうが色々便利ですが多機能で今回の趣旨から外れると考えたので採用していません。

カウントのロジックに関してはイミュータブルな感じにしました。
一番の理由としては、内部の状態変化を気にせずに済むので、テストがしやすい点です。
incrementが呼ばれると新しいCounterModelを生成するようにしています。
その際に`9カウントの次は0に戻す`をここで行っています。

```dart
  CounterModel increment() {
    final int nextCount = count + 1;
    if (nextCount > 9) {
      return const CounterModel(count: 0);
    }
    return CounterModel(count: nextCount);
  }
```


### Modelのテストコード
https://github.com/glassmonkey/flutter_state_notifer_sample/blob/master/test/model_test.dart

```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:fluttertestcountapp/counter_model.dart';

void main() {
  group("カウンターのテスト", () {
    test("0から始まる", () {
      const CounterModel counter = CounterModel();
      expect(counter.count, 0);
      final CounterModel nextCounter = counter.increment();
      //イミュータブルなのでそのまま
      expect(counter.count, 0);
      expect(nextCounter.count, 1);
    });
    test("9から0になる。", () {
      const CounterModel counter = CounterModel(count: 9);
      final CounterModel nextCounter = counter.increment();
      expect(counter.count, 9);
      expect(nextCounter.count, 0);
    });
  });
  //todo マイナスの計算などの異常系のテストを追加していく
}
```

テストの観点ではもう異常系を考慮するなど色々あるでしょうが、
入門用として下記の２点を確かめるロジックを追加しました。それぞれの状態をexpectで等しいかテストしています。

* 初期状態が0で加算すると1になる
* 9を加算にすると0に戻る。

ちなみにAndroid Studioだと、`group` > `test` で下記のように処理を囲むと左の行番号のところに再生ボタン<img width="28" alt="スクリーンショット 2020-06-06 23.26.10.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152938/892f771c-cfee-b44b-d096-dafcd312a2e2.png">が表示されるので、これをクリックするとテストごとに実行できたりできるので大変便利です。（groupの場合は内部のtest全てが実行されます。)

<img width="516" alt="スクリーンショット 2020-06-06 23.24.22.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152938/77b179eb-3547-60ac-2896-64ef63b2eed1.png">


## ViewModel

### ViewModelのアプリケーションコード

https://github.com/glassmonkey/flutter_state_notifer_sample/blob/master/lib/counter_notifer.dart

```dart
import 'package:fluttertestcountapp/counter_model.dart';
import 'package:state_notifier/state_notifier.dart';

class CountNotifier extends StateNotifier<CounterModel> {
  CountNotifier() : super(const CounterModel());

  void next() {
    state = state.increment();
  }
}
```

ViewModelは変更手段を公開するイメージかなと思います。

`StateNotifier` の仕様で現在の状態が`state`に格納されているので、それを更新させる`next`メソッドを公開しています。


### ViewModelのテストコード

https://github.com/glassmonkey/flutter_state_notifer_sample/blob/master/test/notifer_test.dart

```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:fluttertestcountapp/counter_model.dart';
import 'package:fluttertestcountapp/counter_notifer.dart';

void main() {
  group("Count notifierのテスト", () {
    test("カウント結果が変動する", () {
      final CountNotifier countNotifier = CountNotifier();
      expect(countNotifier.debugState, const CounterModel(count: 0));
      countNotifier.next();
      expect(countNotifier.debugState, const CounterModel(count: 1));
    });
  });
}
```
今回テストコードは`next`が呼ばれたらmodelが変更された程度のテストしか書いていません。
理由としてはViewModelとして作成した`CountNotifier`の責務はそれだけだからです。

カウント値が0~9までといった知識はモデルである `CountModel` が知っていることなので、`CountNotifier` は通知だけしているのです。

この責務の分離が重要で、もしこの`CountNotifier`に0~9まで担保するテストを書いてしまうと、もしカウントが0~100までに変更になったときに`CountModel`だけでなく、`CountNotifier` のテストまで修正しないといけなくなり
面倒
、もとい修正範囲が広がってしまいます。

## View

# Viewのアプリケーションコード


https://github.com/glassmonkey/flutter_state_notifer_sample/blob/master/lib/main.dart

```dart
class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final int count =
        context.select<CounterModel, int>((CounterModel state) => state.count);
    return Scaffold(
      appBar: AppBar(
        title: const Text("カウンターサンプル"),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              count.toString(),
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => context.read<CountNotifier>().next(),
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}
```

カウンターアプリとしての機能はViewModelである`CountNotifier`のnextを呼んでいます。

```dart
onPressed: () => context.read<CountNotifier>().next(),
```

`CountNotifier`の状態を表示しているだけです。

```dart
final int count =
        context.select<CounterModel, int>((CounterModel state) => state.count)
```

`context.select` は[provider](https://pub.dev/packages/provider)の `4.1` から使えるようになった機能で状態が変化するとリビルドしたいときに、`contest.read`はリビルドする必要の無い処理で使いわけるといい感じです。

# Viewのテストコード
https://github.com/glassmonkey/flutter_state_notifer_sample/blob/master/test/widget_test.dart

Viewに関してはユニットテストの観点ではあまり必要でない認識です。
ロジック部分はModelやViewModelで担保されているはずなので、
必要でない限りは作成する労力に見合わないと思われます。
（もし意見などあればコメントお願いします）

FirebaseのTest Labのサポートの拡充に個人的には期待したいですね

https://github.com/flutter/flutter/issues/36681

サンプルのままなので説明は割愛します。

# ユニットテストの意義
## ユニットテストは面倒なもの?
ざっくりアプリケーションコードとテストコードを対応させて説明しましたが、テストコードが面倒や意義を感じないケースはあると思われます。

その考えに対してですが、個人的にはテストコードを 「後から追加するもの」ではなく、「**動かすためのエントリーポイント**」 と考えると多少はマシになるのかなと考えています。

その作成したエントリーポイントが後で品質に寄与するなら一石二鳥でお得に感じませんか？

面倒な気持もわかる。


## ユニットテストはエントリーポイント
ModelやViewModelが初学者に難しく感じさせる要因の一つに
実行までが遠い点だと考えています。

実際ModelやViewModelをアプリとしてViewから呼び出そうと思うと色々準備が必要で大変ですよね。
実際にこのサンプルだけでも以下の転を乗り越えないといけないわけです。
Provider? Model? な最初の状態でこれを乗り越えるのは非常に難しいと思われます。

* ViewからViewModelを呼び出すロジックが必要
* ViewModelからModelを呼び出すロジックが必要
* ProviderでViewModelを配布する
…etc

そこでユニットテストの場合だとViewModelを呼び出す場合はどうでしょうか？
これで終わりです。

```dart
 final CountNotifier countNotifier = CountNotifier();
      expect(countNotifier.debugState, const CounterModel(count: 0));
```

最初はprint関数で返り値をチェックしてもいいでしょう。

```dart
 final CountNotifier countNotifier = CountNotifier();
 print(countNotifier.debugState) //0が表示される
```

ユニットテストをエントリーポイントとして実行することを最初にゴールに置くことで、
より難易度の低くなるのでとっつきやすくなると思います。
また、同時にModelやViewModelの責務も考えるきっかけになると思います。

なので、できる限り一緒に書き始めることをおすすめします。

ただし、[ネイティブ依存するPluginが存在するとユニットテストでは動かせない](https://stackoverflow.com/questions/52028969/testing-flutter-code-that-uses-a-plugin-and-platform-channel)ので、
その場合はMockする必要があり、テスト先行でやりずらい点も多々あるので、
テストファーストにはそこまでこだわる必要は無いのかなとも思ってはいます。

# おまけ
GitHub ActionsでFlutter用のCIのサンプルも載せてるのでこちらももしよかったらご活用ください。
iOSとAndroidをそれぞれビルドしてtestとbuildを検証しています。

https://github.com/glassmonkey/flutter_state_notifer_sample/blob/master/.github/workflows/ci.ym

```yml
# Name of your workflow.
name: dev channel test
# Trigger the workflow on push or pull request.
on: [pull_request]
# A workflow run is made up of one or more jobs.
jobs:
  prepare:
    runs-on: ubuntu-latest
    if: "! contains(github.event.head_commit.message, '[ci skip]')"
    steps:
      - run: echo "${{ github.event.head_commit.message }}"
  test:
    strategy:
      matrix:
        config:
          - {name: ios-test, os: macOS-latest, build_options: "ios --release --no-codesign"}
          - {name: android-test, os: ubuntu-latest, build_options: "apk"}
    needs: prepare
    name: ${{ matrix.config.name }}
    runs-on: ${{ matrix.config.os }}
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-java@v1
        with:
          java-version: '12.x'
      - uses: subosito/flutter-action@v1
        with:
          flutter-version: '1.17.x' # you can use 1.17
          channel: 'stable' # optional, default to: 'stable'
      - run: flutter doctor
      - run: flutter pub get
      - run: bin/lint.sh
      - run: flutter test
      - run: flutter build ${{ matrix.config.build_options }}
```

# おわりに
Flutterの状態管理の１つである[StateNotifier](https://pub.dev/packages/state_notifier)を扱ったサンプルにテストコードと合わせた解説を書いてみました。

Flutterに限った話ではないですが、テストコードを書き始めて後の仕様変更のミスに気づけたり、モジュールの責務に関して考えるようになったので、この記事がテストと仲良くなるきっかけになったのなら嬉しいです。
面倒ですけどねｗ


最後に宣伝です。
twitterではたまにFlutter関係をたまにつぶやいているので[@glassmonekey](https://twitter.com/glassmonekey)をフォローしてくれると嬉しいです。
来週の日曜日に[もくもく会@Zoom](https://flutter-okurayama.connpass.com/event/178885/)も企画してるのでこちらも、良ければご参加ください。