⏲️
jestのuseFakeTimersがasync/awaitのあるコードでうまく動作しない
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
を消すか、Callback
をSwitch
の外にだすと、うまく機能する。
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()
はawait
とsetTimeout
が組み合わさると挙動がおかしくなるみたい。
該当の記事
解決策
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