⏲️

jestのuseFakeTimersがasync/awaitのあるコードでうまく動作しない

2022/02/08に公開

Update

追記 2022/02/09

下記のエラーがどうしても再現できなくなった。

リダイレクトがうまくいかなかった理由は多分こう書いていたから?test側のルーティングをみすってたのかも。

たぶんもともとリダイレクトをテストしたくてこう書いていた?

      render(
        <MemoryRouter
          initialEntries={[
            `https://example.com/callback?code=hogehoge&email=hoge%40example.com`,
          ]}
        >
          <Switch>
	    <Callback />
            <Route path="/login">ログインページ</Route>
          </Switch>
        </MemoryRouter>,
      );

Switchを消すか、CallbackSwitchの外にだすと、うまく機能する。

      render(
        <MemoryRouter
          initialEntries={[
            `https://example.com/callback?code=hogehoge&email=hoge%40example.com`,
          ]}
        >
          <Callback />
          <Switch>
            <Route path="/login">ログインページ</Route>
          </Switch>
        </MemoryRouter>,
      );

Old: useFakeTimers()はawaitとsetTimeoutが組み合わさると挙動がおかしくなるみたい

追記: 以下の内容は嘘でした。setTimeout自体をawaitで囲むとuseFaketimerでうまくいかない現象は実際にあるのですが、以下のようにsetTimeout外のasyncまで影響はないです。どうやらtestのルーティングまわりを試行錯誤しているうちに、解決できていたのを勘違いしました。同じ道をたどる人がいるかもなので、一応内容を残しておきます。

5秒後にリダイレクトしたいコード。

コンポーネント
export const Callback = () => {
  ..省略..
  useEffect(() => {   
    const verify = async () => {
      await Promise.resolve('hoge');

      // 5秒後にログイン画面へ遷移
      setTimeout(() => {
        history.push('/login');
      }, 5000);
    }
    
    verify();

  }, []);
  ..省略..
 }
テスト
test('5秒後にログイン画面へ遷移', async () => {
  jest.useFakeTimers();
  
  render(
    <MemoryRouter>
      <Callback />
      <Route path="/login">ログインページ</Route>
    </MemoryRouter>
  );

  jest.advanceTimersByTime(6000);

  await waitFor(() =>
    expect(screen.getByText(/ログインページ/)).toBeInTheDocument(),
  );
})

しかし、なぜか history.push('/login') が実行されない。
試しに await Promise.resolve('hoge');をコメントアウトすると実行される。
history.push('/login')をsetTimeoutの外に出しても実行される。

どうやら useFakeTimers()awaitsetTimeoutが組み合わさると挙動がおかしくなるみたい。

該当の記事
https://stackoverflow.com/questions/51126786/jest-fake-timers-with-promises
https://stackoverflow.com/questions/52177631/jest-timer-and-promise-dont-work-well-settimeout-and-async-function

解決策

jestのバージョンによって異なる。

v26系の場合

注意点としては以下のコードはuseFakeTimers('legacy')legacyを指定しないと動かないこと(modernではだめ)。

テスト
// 追加
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));

test('5秒後にログイン画面へ遷移', async () => {
  jest.useFakeTimers('legacy'); // 注意!! modernだと動かない
  
  render(
    <MemoryRouter>
      <Callback />
      <Route path="/login">ログインページ</Route>
    </MemoryRouter>
  );

  // NOTE: 追加!
  await flushPromises();
      
  jest.advanceTimersByTime(6000);

  await waitFor(() =>
    expect(screen.getByText(/ログインページ/)).toBeInTheDocument(),
  );
})

jestのバージョン 27以上

こっちは動作確認していないです。

テスト
const flushPromises = () => {
  return new Promise(resolve => jest.requireActual("timers").setImmediate(resolve))
}

test('5秒後にログイン画面へ遷移', async () => {
  jest.useFakeTimers();
  
  render(
    <MemoryRouter>
      <Callback />
      <Route path="/login">ログインページ</Route>
    </MemoryRouter>
  );

  // NOTE: 追加!
  await flushPromises();
      
  jest.advanceTimersByTime(6000);

  await waitFor(() =>
    expect(screen.getByText(/ログインページ/)).toBeInTheDocument(),
  );
})

もしくはいっそのこと

jestではなく、sinonのfakeTimerを使うのもあり。

実際のコードを参考にしたい人向け

コールバックページ用のコードを書いていた(適宜端折った)。

改善前

Callback.tsx
import React, { useEffect, useState } from 'react';
import { Link, useLocation, Redirect } from 'react-router-dom';


export const CallbackPage: React.FC = () => {
  const [error, setError] = useState({
    hasError: false,
    message: '',
  });
  const [isRedirecting, setIsRedirecting] = useState(false);
  const [isRequesting, setIsRequesting] = useState(true);
  const location = useLocation();

  useEffect(() => {
    let timerId: NodeJS.Timeout;
    
    const verify = async () => {

      // 自作コード。urlからcodeとemailを取得
      const { code, email } = extractCodeAndEmailBypassDecode(
        location.search,
      );

      // code検証リクエスト
      const resVerifyEmail = await AuthRequest.verify({
        code,
        email,
      });

      if (!resVerifyEmail.isSuccess) {
        setError({
          hasError: true,
          message: resVerifyEmail.error,
        });

        return;
      }

      // 5秒後にログイン画面へ遷移
      timerId = setTimeout(() => {
        setIsRedirecting(true);
      }, 5000);

    }
    
    verify();

    return () => {
      clearTimeout(timerId);
    };
  }, [location.search, setIsRedirecting]);

  if (isRedirecting) {
    return <Redirect to="/login" />;
  }

  if (!isRedirecting && isRequesting) {
    return (
      <div>
        メール検証処理中...<br />
        この画面のまましばらくお待ち下さい。
      </div>
    );
  }

  return (
    <div>
      {error.hasError ? (
        <div>{error.message}</div>
      ) : (
        <div>
          メールアドレス検証に成功しました! 5秒後にログイン画面に移動します。
          <Link to="/login">
            移動しない場合は次のこのリンクをクリックしてください。
          </Link>
        </div>
      )}
    </div>
  );
};

そしてテストコードを書いていた。

Callback.test.tsx
import {
  screen,
  render,
  waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { AuthRequest } from 'helpers/requests/authRequest';
import { MemoryRouter, Route, Switch } from 'react-router-dom';

import { Callback } from './Callback';

const authRequestVerifySpy = jest.spyOn(AuthRequest, 'verify');

describe('<Callback />', () => {
  beforeEach(() => {
    authRequestVerifySpy.mockResolvedValue({
      isSuccess: true,
    });
  });

  afterAll(() => {
    jest.resetAllMocks().restoreAllMocks();
  });

  describe('urlの検証', () => {
    afterEach(() => {
      jest.runOnlyPendingTimers();
      jest.useRealTimers();
    });
    
    test('検証成功時5秒後にリダイレクトする', async () => {
      jest.useFakeTimers();

      render(
        <MemoryRouter
          initialEntries={[
            `https://example.com/callback?code=hogehoge&email=hoge%40example.com`,
          ]}
        >
          <Callback />
          <Route path="/login">ログインページ</Route>
        </MemoryRouter>
      );
      
      expect(screen.getByText(/メール検証処理中/)).toBeInTheDocument();

      expect(authRequestVerifySpy).toBeCalledWith({
        code: 'hogehoge',
        email: 'hoge@example.com',
      });

      await waitFor(() =>
        expect(
          screen.getByText(/メールアドレス検証に成功しました!/),
        ).toBeInTheDocument(),
      );

      jest.advanceTimersByTime(6000);

      await waitFor(() =>
        expect(screen.getByText(/ログインページ/)).toBeInTheDocument(),
      );
    });

  });
});

改善後

Callback.test.tsx
import {
  screen,
  render,
  waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { AuthRequest } from 'helpers/requests/authRequest';
import { MemoryRouter, Route, Switch } from 'react-router-dom';

import { Callback } from './Callback';

const authRequestVerifySpy = jest.spyOn(AuthRequest, 'verify');

const flushPromises = () => new Promise((resolve) => setImmediate(resolve));

describe('<Callback />', () => {
  beforeEach(() => {
    authRequestVerifySpy.mockResolvedValue({
      isSuccess: true,
    });
  });

  afterAll(() => {
    jest.resetAllMocks().restoreAllMocks();
  });

  describe('urlの検証', () => {
    afterEach(() => {
      jest.runOnlyPendingTimers();
      jest.useRealTimers();
    });
    
    test('検証成功時5秒後にリダイレクトする', async () => {
      jest.useFakeTimers();

      render(
        <MemoryRouter
          initialEntries={[
            `https://example.com/callback?code=hogehoge&email=hoge%40example.com`,
          ]}
        >
          <Callback />
          <Route path="/login">ログインページ</Route>
        </MemoryRouter>
      );
      
      expect(screen.getByText(/メール検証処理中/)).toBeInTheDocument();

      expect(authRequestVerifySpy).toBeCalledWith({
        code: 'hogehoge',
        email: 'hoge@example.com',
      });

      await waitFor(() =>
        expect(
          screen.getByText(/メールアドレス検証に成功しました!/),
        ).toBeInTheDocument(),
      );
      
      // NOTE: 追加!
      await flushPromises();

      jest.advanceTimersByTime(6000);

      await waitFor(() =>
        expect(screen.getByText(/ログインページ/)).toBeInTheDocument(),
      );
    });

  });
});

Discussion