[Flutter] ChangeNotifierのunit test:notifyListeners()が呼ばれていることをテストする
はじめに
早速ですが、以下の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));
}
}
addListener
をFuture
に変換しています。
テスト対象のメソッドを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
を重宝すると思うため、テストコードをより簡便にして快適に実装していきましょう...!
Discussion