[Flutter] ChangeNotifierのunit test:notifyListeners()が呼ばれていることをテストする

公開:2021/01/12
更新:2021/01/14
2 min読了の目安(約2500字TECH技術記事

はじめに

早速ですが、以下のChangeNotifierを継承したクラスにunit testを追加することを考えます。

class Counter extends ChangeNotifier {
 int _count = 0;
 int get count => _count;

 void increment() {
   _count++;
   notifyListeners();
 }
}

テストケースとして、increment()が呼ばれた際の_countの値をチェックします。また値だけではなくnotifyListenersによって通知されていることまでテストしたいとします。Widget testで刈り取ることも考えられますが、なるべくロジックをunit testで担保していきたいです。

この場合、直感的には以下のようなテストコードになるかと思います。

 test('test Counter', () {
   final counter = Counter();
   counter.addListener(() {
     expect(counter.count, 1);
   });
   counter.increment();
 });

addListeners内でassertionすることで値チェックに加え、notifyListeners()が呼ばれたことを検証します。

問題と対策

上のテストケースですが、addListenersの中身が実行される前にテストが終了し必ずパスしてしまいます。

対策として、以下のようにするとテストが正しく動作します。

test('test Counter', () {
  final counter = Counter();

  int result;
  counter.addListener(() {
    result = counter.count;
  });
  counter.increment();
  expect(result, 1);
});

callbackの結果を外の変数に書き込むことで、テストを待たせることができます。

改善案

上記でも動作しますが、挙動として自明ではないことに加え、やりたいことに対して比較的行数が必要です。特にテストコードの場合冗長な行をなるべく減らし、テスト対象メソッドとassertionを目立たせたいです。
そこで上記の挙動を一般化しつつテストコードをシンプルにするために、以下のような拡張関数を定義します。

extension ChangeNotifierTestHelper on ChangeNotifier {
  Future awaitNotifyListeners({
    Function block,
    int expectedCount = 1,
    int seconds = 2,
  }) async {
    final completer = Completer();
    var notifiedCount = 0;
    addListener(() {
      if (++notifiedCount == expectedCount) {
        completer.complete();
      }
    });
    block();
    return completer.future.timeout(Duration(seconds: seconds));
  }
}

addListenerFutureに変換しています。
テスト対象のメソッドをblockで渡し、またnotifyListenersが呼ばれるべき回数、呼ばれるまで待機すべき秒数を指定します。指定秒数後もcallbackが呼ばれない場合は例外によりテストが失敗します。

利用側は以下のようになります。

test('test Counter', () async {
  final counter = Counter();

  await counter.awaitNotifyListeners(block: () {
    counter.increment();
  });
  expect(counter.count, 1);
});

test bodyをasyncで定義する必要がありますが、callbackをFutureに変換することでテストコード側はシンプルに書くことができます。
AndroidのLiveDataでいうところのgetOrAwaitValueに似ているかもしれません。

テスト対象メソッドがFutureを返す場合も問題なく使用できます。

await viewModel.awaitNotifyListeners(block: () {
  viewModel.fetchPlaces('query'); // Returns Future
});
expect(viewModel.places, places);

以上になります! 特にMVVMにおいてはViewModelがシンプルな場合ChangeNotifierを重宝すると思うため、テストコードをより簡便にして快適に実装していきましょう...!