はじめに
本記事ではReactコンポーネントのテストを書いた時にwindow.locationのconsole.errorに遭遇したので、その時の対処方法について紹介しています。
テストは通るのにエラーが出力される
Buttonコンポーネントをテストしていると、テストは通るけどconsole.errorが出てるという事象に遭遇しました。
このButtonはクリックすると、postApi()
を呼び出して、画面をリロードします。
import { postApi } from "./fetchers";
export const Button = () => {
const handleClick = async () => {
await postApi();
window.location.reload();
};
return <button onClick={handleClick}>リロード</button>;
};
export const postApi = () => Promise.resolve();
このコンポーネントに対するテストは、postApi()
が呼び出されていることが確認できれば十分だったので、最初は以下のようにテストを書いていました。
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import { Button } from "./";
import { postApi } from "./fetchers";
const user = userEvent.setup(); // クリック動作を再現するためにuserEventを使用
jest.mock("./fetchers"); // postApi()をモック
test("ButtonをクリックするとpostApiが呼び出される", async () => {
render(<Button />);
await user.click(screen.getByRole("button", { name: "リロード" })); // ボタンをクリックする
expect(postApi).toHaveBeenCalled(); // postApi()が呼び出されていることを検証
});
テストを実行すると以下のような結果が表示されます。
テストは通っているようです。しかし、テストの結果に問題はないのに、console.errorが出力されていました🤔
console.errorの内容を見ると Error: Not implemented: navigation (except hash changes)
と書かれていて、 window.location.reload();
の箇所に矢尻が付き、エラーとして検出されていました。
目的のテストは実装できていて、かつ通っているのでスルーしても致命的な問題にはならないのですが、テストを実行するたびにこのエラーが出力されるのは気持ち良くないので解消することにしました。
エラーを解消する
初めて見るエラーだったので戸惑ったのですが、調べてみると表示されたconsole.errorが解消できそうな記事が出てきたのでこちらを参考に対処することにしました。
その解消方法は、window.locationを上書きする方法です。
window.locationでエラーが出る理由は、Jestで使用されるjsdomではwindow.locationに必要なnavigationをサポートしていないからだそうです。(参考)
以上の理由でJestでwindow.locationが使えないので、本来のwindow.locationの代わりを用意することでエラーを回避していきます。
console.errorの解消方法を実装した後のコードの全体像はこちらです。
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import { Button } from "./";
import { postApi } from "./fetchers";
const user = userEvent.setup(); // クリック動作を再現するためにuserEventを使用
jest.mock("./fetchers"); // postApi()をモック
///追加1️⃣
const setUpMyLocation = () => {
const { location } = window;
delete (window as any).location;
window.location = { ...location, reload: () => {} };
};
///
test("ButtonをクリックするとpostApi()が呼び出される", async () => {
///追加2️⃣
setUpMyLocation();
///
render(<Button />);
await user.click(screen.getByRole("button", { name: "リロード" })); // ボタンをクリックする
expect(postApi).toHaveBeenCalled(); // postApi()が呼び出されていることを検証
});
2箇所コードを追加しました。以下で説明していきます。
1️⃣window.locationを上書きするロジックを用意する
window.locationを上書きするロジックでは、グローバルオブジェクトであるwindowのlocationを削除して、reloadだけ上書きしたlocationに置き換えています。
const setUpMyLocation = () => {
const { location } = window; //①
delete (window as any).location; //②
window.location = { ...location, reload: () => {} }; //③
};
①グローバルオブジェクトの元々のwindow.location
を変数location
に一旦格納しておいて
②delete
メソッドでwindow.location
を削除して(※1)
③削除されたwindow.location
に、変数location
とvalueが()=>{}
のreload
で構成された独自のlocationを代入することで置き換えています。
※1 locationはオプショナルではないので、削除しようとするとwindowオブジェクトの型定義が変更されるからダメだとTypesScriptに怒られてしまいます。その場しのぎですが、windowをany型にして中身を自由に変更できるようにしています。
2️⃣setUpMyLocationを呼び出す
こちらではwindow.locationを上書きするロジックを呼び出しています。この関数はwindow.locationを実行するButtonコンポーネントがレンダリングされる前に実行しておく必要があります。
setUpMyLocation();
render(<Button />);
🎉これでエラーは解消されました👏🎉
オマケ: window.locationの実行有無をテストしたいとき
先ほどはButtonをクリックしたときに、postApiが呼ばれていることだけをテストすれば十分でしたが、リロードボタンなのでリロードがちゃんと実行されるかテストしたくなったとします。
こんな時はlocationをjest.fn()を使ってモックすることでリロードが実行されたか検証することができます。
先ほどのコードにモックの実装を追加します。
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import { Button } from "./";
import { postApi } from "./fetchers";
const user = userEvent.setup();
jest.mock("./fetchers");
///変更1️⃣
const setUpMyLocation = (mock: () => void) => {
const { location } = window;
delete (window as any).location;
window.location = { ...location, reload: mock };
};
///
test("ButtonをクリックするとpostApi()が呼び出される", async () => {
///変更2️⃣
const mock = jest.fn();
setUpMyLocation(mock);
///
render(<Button />);
await user.click(screen.getByRole("button", { name: "リロード" }));
expect(postApi).toHaveBeenCalled();
///変更3️⃣
expect(window.location.reload).toHaveBeenCalled();
///
///変更4️⃣
mock.mockClear();
///
});
変更1️⃣ 引数でmockを受け取って、reloadのvalueに設定する。
変更2️⃣ jest.fn()を代入した変数mockを用意して、setUpMyLocation()に渡す。
変更3️⃣ toHaveBeenCalled(※2)を使って、対象のモックされた関数が呼ばれているかを検証する。
変更4️⃣ mock.mockClear()を使って、カウントしていた変数mockの呼び出し回数をリセットする。
※2 toHaveBeenCalledはmock関数かspy関数のexpectにのみ使えるため、このメソッドを使って対象の呼び出し回数を検証したいときは、今回のreloadのようにjest.fn()などのmock関数を使う必要があります。
以上の実装により、リロードが呼び出されているかもテストすることができました🥳
さいごに
window.locationはJestで使用されるjsdomではサポートされていないためコンソールエラーが表示されてしまいますが、上書きすることでエラーの解消ができました。またその上書きした関数にmock関数やspy関数を仕込むことで呼び出しの回数も検証することができます。同様のエラーに困った人の参考になれば嬉しいです。
個人的に今回の記事を書くにあたって、なんとなく使っていた関数について調べたり、細かな「なぜ?」を放置せずに調べることができたのがよかったなと思いました。