flutter + reduxでのMiddleware(非同期処理)のテスト


概要

flutter + reduxの構成でのmiddleareの単体テストの書き方を紹介します。テスト用のDSLはどの言語で勉強するのも大変ですね。

※reducerのテストはdispatchしてstateをexpectするだけなのでここでは説明しません

Prerequisite

pubspec.ymlは以下の通り。APIコール部分はモックするためにMockitoを使用します。

pubspec.yml
dependencies:
  flutter_redux: ^0.6.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^4.1.1

テスト対象のmiddleware

あるActionが呼ばれたらAPIをコール、その前後でLoadingに関するStartとCompleteのActionがdispatchされるmiddlewareのテストを書くこととします。
以下のようなmiddlewareを想定してください。

root_middleware.dart
Middleware<AppState> rootMiddleware(MyClient myClient) =>
  return TypedMiddleware<AppState, FetchStartAction>(_fetch(myClient)
}

void Function(
  Store<AppState> store,
  FetchStartAction action,
  NextDispatcher next,
) _fetch(MyClient myClient) {
  return (store, action, next) {
    next(LoadingAction());
    myClient.fetch().then((res) {
      next(FetchSucceedAction(res: res));
    })
    .whenComplete(() {
      next(LoadCompleteAction());
    });
  };
}

テストコードのセットアップ

具体的なテストコードを書く前の準備として必要なクラスのモック化、およびStoreの宣言などを行います。

root_middleware_test.dart

// APIコールを行うclientをモック化
class MockClient extends Mock implements MyClient {}

class Watcher extends Mock implements MiddlewareClass<AppState> {}

void main() {
  final mockClient = MockClient();
  final watcher = Watcher();

  // storeの初期化
  final store = Store<AppState>(
    appReducer,
    initialState: AppState.init(),
    middleware: [rootMiddleware(
      myClient: mockClient,
    )]..add(watcher),
  );

  // APIクライアントのfetch()をモックする
  when(mockClient.fetch())
      .thenAnswer((_) {
    return Future.value(mockedResult);
  });

  // 各テストの前にFetchStartActionを呼ぶ
  setUp(() {
    store.dispatch(FetchStartAction());
  });

  // 各テスト後にmockMiddlewareを初期化する
  tearDown(() {
    reset(watcher);
  });

  // 全テスト終了後にmockClientを初期化する
  tearDownAll(() {
    reset(mockClient);
  });

middlewareに対して、既にrootMiddlewareが代入されているのに..add(watcher)していますが、
これは実際のmiddlewareの挙動を確認するためのインスタンスです。
Mockextendsしたクラスのインスタンスは呼ばれたメソッドを全て記録しています。verify()を使ってどのメソッドが呼ばれたかを後から検証できます。

MiddlewareからActionがdispatchされたことを確認する

先にコードを見せますが、以下のようになります。

group('before call api', () {
  test('dispatch LoadingAction', () async {
    verify<void>(
      watcher.call(
        store,
        predicate<LoadingAction>((action) => action is LoadingAction),
        any,
      ),
    );
  });
});

この記事で1番大事なのはこの部分↓ですが、分かりにくいので分解して解説します。

verify<void>(
  watcher.call(
    store,
    predicate<LoadingAction>((action) => action is LoadingAction),
    any,
  ),
);

まず、上のコードを単純化すると以下のようになります。

verify(watcher.call(...));

verify(mockedObj.hoge())mockedObj.hoge()が呼ばれたか検証します。つまり、ここではwatcher.call()が呼ばれたか検証しています。

watcher.call(
  store,
  predicate<LoadingAction>((action) => action is LoadingAction),
  any,
)

次に、watcherはdispatchされたActionを全て記録しているため、このactionの型をpredicateで制限して検証しています。
これらを組み合わせると、「LoadingActionが正しくdispatchされたか」を検証できます。

verify<void>(
  watcher.call(
    store,
    predicate<LoadingAction>((action) => action is LoadingAction),
    any,
  ),
);

※ただし、watcherが記録しているのはそのテスト中に実行されたAction全てであるため「verify()したActionがテスト対象のmiddleewareからdispatchされたものであるかは検証できない」という点に注意してください。

MiddlewareからdispatchされたActionのプロパティを確認する

基本的に要領は同じです。先にコードを見せます。

test("action's result is mockedResult", () async {
      await untilCalled<void>(
        watcher.call(
          store,
          predicate<LoadCompleteAction>(
              (action) => action is LoadCompleteAction),
          any,
        ),
      );
      verify<void>(
        watcher.call(
          store,
          predicate<FetchSucceedAction>(
            (action) {
              return action is FetchSucceedAction &&
                     action.result, mockedResult;
            },
          ),
          any,
        ),
      );
    });

先ほどと違うのは以下の部分です。

await untilCalled<void>(
  watcher.call(
    store,
    predicate<LoadCompleteAction>(
      (action) => action is LoadCompleteAction),
    any,
  ),
);

これはuntilCalledLoadCompleteActionが記録されるのを待っています。LoadCompleteAction.whenComplete内でdispatchされるように書いているので、これによってAPIコールの結果を待っています。

参考:https://pub.dev/documentation/mockito/latest/mockito/untilCalled.html

verify<void>(
  watcher.call(
    store,
    predicate<FetchSucceedAction>(
      (action) {
        return action is FetchSucceedAction &&
               action.result, mockedResult;
      }
    ),
    any,
  ),
);

後は↑ですが、actionに渡した値ががAPIclientのモックの値と同じかを検証しています。

まとめ

middlewareのテスト、やはり

verify()したActionがテスト対象のmiddleewareからdispatchされたものであるかは検証できない

のがちょっと厳しい感はありますね…
dartのreduxでmiddlewareをテストするコードがググってもあんまりサンプルが出てこないので、もっと良さそうな書き方をご存じの方はご教示くださいm(__)m

雑記

Actionがdispatchされたかどうかよりもstateがどう変わったかのレベルでテストしたほうが良いかもしれないと書いてからおもいました

でもそれだと1回のActionで複数回変更されるLoadingのようなstateの変更をテストするのが難しいからmiddlewareをモックしてverify()していくしかないとやっぱりおもいました

メモ:API1行解説

redux

middleware.call()

dispatchされたactionをreducerが処理する前にmiddlewareが呼ばれた時の関数。
storeを初期化する際に代入した全てのmiddlewareに対してcall()が呼ばれる。

例えば、公式のexampleにもある通り、以下のようにすればprint()がreducerの前に実行される。

class LoggingMiddleware extends MiddlewareClass<int> {
  call(Store<int> store, action, NextDispatcher next) {
    print('${new DateTime.now()}: $action');

    next(action);
  }
}

// Create your store with the loggingMiddleware
final store = new Store<int>(
  counterReducer,
  middleware: [new LoggingMiddleware()],
);

TypedMiddlewareはこれを便利にしただけのクラスなのでこちらも当然call()が呼ばれる。ただしこちらは<T>に代入したActionの場合のみ。

dart/test

predicate()

テストしたい値を元に何かしらの計算をしてアサーションする時に使う関数(要はMatcherを返しているだけ)。
具体的には以下のようにして使う。

class Rect {
  Rect(this.length, this.width);
  final int length;
  final int width;

  int get area => length * width;
}

test('what is predicate', () {
  final hasCorrectArea = predicate<Rect>((rect) {
    return rect.area == rect.length * rect.width;
  });
  final myRect = Rect(10, 10);
  expect(myRect, hasCorrectArea);
});

setup(), tearDown()

各テストの前後に処理を挟む関数。setup()で各テストの前にdispatch()、後にmockMiddlewareの初期化を行ったりする。

mockito

verify()

以下のようにして、モックされたオブジェクトがそのメソッドを呼んだかを判定するメソッド。

cat.eatFood("chicken"); // catはmockをextentedしたクラスのオブジェクト
verify(cat.eatFood("chicken")); // passed
verify(cat.eatFood("fish"));    // failed

参考