はじめに
最近JOINしたチームで多くのsetTimeoutが使用されており実装はできているのに
思うようにテストの結果が伴わない事があったので調べてみました。
対象者
この記事は下記のような人を対象にしています。
- 駆け出しエンジニア
- プログラミング初学者
- TDDを取り入れている開発をしている方
- useFakeTimersって美味しいの?って思っている方
結論
私がなんとなくで使っていた解釈と違っていた為、思うようにテストができていなかった事がわかった。
詳細と検証
検証バージョン
- "@testing-library/user-event": "^14.5.2"
- "vitest": "^2.0.5"
useFakeTimersとは
vi.useFakeTimers 型: (config?: FakeTimerInstallOpts) => Vitest
タイマーのモックを有効にするには、このメソッドを呼び出す必要があります。これにより、vi.useRealTimers() が呼び出されるまで、以降のタイマー関連の呼び出し(例: setTimeout, setInterval, clearTimeout, clearInterval, setImmediate, clearImmediate, および Date)がすべてラップされます。
テストで自由自在に時間を制御できると思っていましたが、少し癖がありました。
ここから検証の内容になりますが、あえてuseFakeTimersを使っている事はご理解下さい笑
表示時間等に要件がなければこんなことをする必要はありません。
ケース① コンポーネントが表示されて1秒後に「dialog title」が表示される
function FakeTimeTryPage() {
const [isShowDialog, setIsShowDialog] = useState(false);
useEffect(() => {
setTimeout(() => {
setIsShowDialog(!isShowDialog);
}, 1000);
}, []);
return (
<div>
<button>
dialog open
</button>
{isShowDialog && <p>dialog title</p>}
</div>
);
}
このような場合下記のコードでテストが出来ます。
it("renderされてから1秒後にdialog titleが見えている事", async () => {
vi.useFakeTimers();
render(<FakeTimeTryPage />);
expect(screen.queryByText("dialog title")).not.toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(999);
});
expect(screen.queryByText("dialog title")).not.toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(1);
});
expect(screen.getByText("dialog title")).toBeInTheDocument();
vi.useRealTimers();
});
- レンダーされた直後「dialog title」は見えない
-
vi.advanceTimersByTime(999)
を使用し999ms時間を進める - 999ms後「dialog title」は見えない
-
vi.advanceTimersByTime(1)
を使用し1ms時間を進める - 999ms + 1ms後「dialog title」は見える
ケース② ケース①と同じで「dialog title」が見えるタイミングは同じ実装
function FakeTimeTryPage() {
const [startTimer, setStartTimer] = useState(false);
const [isShowDialog, setIsShowDialog] = useState(false);
useEffect(() => {
setTimeout(() => {
setStartTimer(true);
}, 500);
}, [isShowDialog]);
useEffect(() => {
if (startTimer) {
setTimeout(() => {
setIsShowDialog(true);
}, 500);
}
}, [startTimer]);
return (
<div>
<button>dialog open</button>
{isShowDialog && <p>dialog title</p>}
</div>
);
}
変わったところはコンポーネントがレンダリングされて500ms後に新しいタイマーを起動させて500ms後に「dialog title」を表示させるようにしました。
この場合ケース①のテストコードでテストができそうですが、これは要素が見つからずテストが落ちてしまいます。
TestingLibraryElementError: Unable to find an element with the text: dialog title.
This could be because the text is broken up by multiple elements.
In this case, you can provide a function for your text matcher to make your matcher more flexible.
ここでわかった事
act(() => {
vi.advanceTimersByTime(999);
});
vi.advanceTimersByTime(999)
を実行したタイミングで発火しているタイマーのみを進める事ができる。
その為、今回のケースでは最初の999msタイマーを進めたのですが、実際は1つ目のタイマーの500ms分しか進める事ができておらず、そこから1ms進めてもコンポーネントがレンダリング後からカウントすると501msとなりテストが落ちている事がわかった。
改めてドキュメントを読んでみると明確に記載がありました。
指定されたミリ秒数が経過するか、キューが空になるまで、開始されたすべてのタイマーを実行します。
よって今回のケースのテストを作るとすると下記になる事がわかります。
it("renderされてから1秒後にdialog titleが見えている事", async () => {
vi.useFakeTimers();
render(<FakeTimeTryPage />);
expect(screen.queryByText("dialog title")).not.toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(500);
});
// 合ってもなくてもいい
expect(screen.queryByText("dialog title")).not.toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.getByText("dialog title")).toBeInTheDocument();
vi.useRealTimers();
});
ケース③ これもケース①と同じで「dialog title」が見えるタイミングは同じ実装
function FakeTimeTryPage() {
const [isShowDialog, setIsShowDialog] = useState(false);
useEffect(() => {
setTimeout(() => {
setTimeout(() => {
setIsShowDialog(!isShowDialog);
}, 500);
}, 500);
}, []);
return (
<div>
<button>dialog open</button>
{isShowDialog && <p>dialog title</p>}
</div>
);
}
変わったところはコンポーネントがレンダリングされてからuseEffectで500msのsetTimeoutの中でさらに500msのsetTimeoutを宣言し合計1000ms後に「dialog title」を表示させるようにしました。
このケースはケース①とタイマーの考え方が同じ結果になりました。
非同期処理とタイマーの関係
function FakeTimeTryPage() {
const [isShowDialog, setIsShowDialog] = useState(false);
useAsync(async () => {
await fetchDataFake();
setIsShowDialog(!isShowDialog);
}, []);
return (
<div>
<button>dialog open</button>
{isShowDialog && <p>dialog title</p>}
</div>
);
}
コンポーネントがレンダリングされた時に非同期処理が合った場合にタイマーはどのように影響するかを調べました。
自分の勝手な考えでは非同期でもタイマー進めたら次に進んでくれてテストが通るだろうと思っていましたが、
非同期処理とタイマーは全く別物でタイマーをいくら進めてもテストが落ちる結果となりました。
しかし下記のように非同期内にタイマーがあった場合はどうでしょうか?
// 非同期関数
const fetchDataFakeInTimer = async (): Promise<string> => {
return new Promise((resolve) => {
setTimeout(() => {
resolve("Mock API response");
}, 1000);
});
};
// コンポーネント
function FakeTimeTryPage() {
const [isShowDialog, setIsShowDialog] = useState(false);
useAsync(async () => {
await fetchDataFakeInTimer();
setIsShowDialog(!isShowDialog);
}, []);
return (
<div>
<button>dialog open</button>
{isShowDialog && <p>dialog title</p>}
</div>
);
}
この場合は明確にタイマーが実装されているので下記のテストのようにタイマーを進める必要がある事がわかりました。
it("renderされてから1秒後にdialog titleが見えている事", async () => {
vi.useFakeTimers();
render(<FakeTimeTryPage />);
expect(screen.queryByText("dialog title")).not.toBeInTheDocument();
await act(async () => {
vi.advanceTimersByTime(1000);
});
expect(screen.getByText("dialog title")).toBeInTheDocument();
vi.useRealTimers();
});
おわりに
非同期、タイマーを混同して考えてしまっていたので明確な違いを知る事ができた。
またタイマーが増えてくるとテストを書く時にどこのタイマーが動いているかを
理解しないとテストが書けないと思いました。
目で見てなかなか分かりにくいところなので、タイマーの処理が絡むところはなるべくmockしてテストが書けるような実装とテストを心がける必要があるのではないかと思いました。
参考記事
最後まで読んで頂きありがとうございました。
少しでも参考になったと思ったらいいね!お願いします