Sassyブログ

好きなことで暮らしを豊かにするブログ

RxJSで実装したretry処理のretry回数を検証する方法

今回はエラーの種別ごとに最大リトライ回数を取得して指定回数分リトライかける実装のテストコードを書いた時に、苦戦したのでその解決策を残そうと思います。

世の中に同じ思いをしている方はいるのですが、アンサーを見てもあまりピンとこなかったので、メンバーにアドバイスをもらい解決することができました!

stackoverflow.com

stackoverflow.com

ポイントはRxJSのdefer関数を使うことです。

例えば以下のような実装があったとします。 コードは適当です。

export const retryWhenError = () => {
  let maxRetryCount: number | null = null;
  return api.postData().pipe(
    mapTo(success()),
    retryWhen((errors) =>
      errors.pipe(
        map((e: AxiosError, i: number) => {
          if (maxRetryCount === null) {
            maxRetryCount = getRetryCount(e);
          }
          const currentRetryCount = i + 1;
          if (currentRetryCount > maxRetryCount) {
            throw e;
          }
          return e;
        })
      )
    ),
    catchError((e) => of(e))
  );

何かしらの登録処理を実行してエラーとなったとします。

そのエラーの種別により指定回数リトライ処理をかけるという仕様があった場合に指定回数分リトライ処理が実行されているかを検証するコードを書こうとした時に私はRxJS力が乏しかったので、 postDataをモックにしてtoHaveBeenCalledTimesで指定回数分叩かれていることを確認するというテストコードを最初書いていました。

しかし、これだとうまく動きません。

おそらくなのですが、受け取ったエラーのObservableをそのままthrowして使いまわしているからと考えてます。。

それを使って再度試行するので内部では指定回数分再試行されているのですが、最初のObservableを使いまわしているのでtoHaveBeenCalledTimesでの結果は1となってしまっているのではないかと考えます。

このテストについては一筋縄ではいかないのです。

そのためテストコード側でdeferを使って計測用の関数を別途用意して,postDataのモックの実装に仕込んであげます。

deferについてはこちらを参照ください。

rxjs.dev

subscribeされたタイミングで関数を実行して返ってくる Observable を更にsubscribeせる operator です。

これだとあまりピンときませんね…

  const counter = jest.fn();

  const validRetryCount = (e: Observable<never>) => {
    let isFirst = true;
    return () =>
      defer(() => {
        if (isFirst) {
          isFirst = false;
          return e;
        }
        counter();
        return e;
      });
  };

  afterEach(() => {
    mockCounter.mockReset();
  });

  it('〇〇の場合は3回再試行すべき', () => {
    jest
      .spyOn(api, 'postData')
      .mockImplementation(validRetryCount(throwError(createErrorObject())));

    retryWhenError().subscribe();

    expect(counter).toHaveBeenCalledTimes(3);
  });

このように少し手間がかかりますが、ひと手間加えてあげることでリトライ回数の計測して検証することが可能になります。

ちなみにどのように流れてるかログを張ってみました。

まずは自分の想定する流れに対してAから順番に付けていきます。 また、成功時、失敗時、テストコード側でsubscribe関数にに渡すコールバック内にもログを仕込んでdeferの動きを見てみたいと思います。

export const retryWhenError = () => {
  console.log("A");
  let maxRetryCount: number | null = null;
  return api.postData().pipe(
    map(() => {
      console.log("success");
      return success();
    }),
    retryWhen((errors) =>
      errors.pipe(
        map((e: AxiosError, i: number) => {
          if (maxRetryCount === null) {
            console.log("C");
            maxRetryCount = getRetryCount(e);
          }
          const currentRetryCount = i + 1;
          if (currentRetryCount > maxRetryCount) {
            console.log("F");
            throw e;
          }
          console.log("D");
          return e;
        })
      )
    ),
    catchError((e) => {
      console.log("failure");
      return of(failure(e));
    })
  );
  const counter = jest.fn();

  const validRetryCount = (e: Observable<never>) => {
    let isFirst = true;
    return () =>
      defer(() => {
        if (isFirst) {
          console.log("B");
          isFirst = false;
          return e;
        }
        counter();
        console.log("E");
        return e;
      });
  };

  afterEach(() => {
    mockCounter.mockReset();
  });

  it('〇〇の場合は3回再試行すべき', () => {
    jest
      .spyOn(api, 'postData')
      .mockImplementation(validRetryCount(throwError(createErrorObject())));

    retryWhenError().subscribe(() => console.log(v));

    expect(counter).toHaveBeenCalledTimes(3);
  });

コンソールに出力すると以下のようになります。

A

B

C

D

E

D

E

D

E

F

failure

{
  type: 'failure',
  error: ErrorObject
}

deferでObservableを返すしたあとは、各ObserverでObsevableを返してあげることにより、deferに渡したコールバック関数が実行されていますね。

subscribeされたタイミングで関数を実行して返ってくる Observable を更にsubscribeせる operator です。

つまり、deferを使うことでretryWhen(Observer)で返したエラー(Observable)を更にsubscribeして流してあげることなのでしょうね。

subscribeされることでdefer内の関数が呼ばれるので、そこに計測用のカウンターを仕込むことでリトライ回数を計測できるようにしています。

もう少しRxJS勉強しないとですね…

書籍としては以下がありますが全て英語書籍ですね。

日本語でRxがどういうものかを把握するには以下の電子書籍を読んでみるのも良さそうですね。

booth.pm