window.locationを含むロジックのテストでconsole.errorが出たときの対処方法

# はじめに
本記事ではReactコンポーネントのテストを書いた時にwindow.locationのconsole.errorに遭遇したので、その時の対処方法について紹介しています。

# テストは通るのにエラーが出力される

Buttonコンポーネントをテストしていると、テストは通るけどconsole.errorが出てるという事象に遭遇しました。

このButtonはクリックすると、``postApi()``を呼び出して、画面をリロードします。
```typescript:index.tsx
import { postApi } from "./fetchers";

export const Button = () => {
  const handleClick = async () => {
    await postApi();
    window.location.reload();
  };
  return <button onClick={handleClick}>リロード</button>;
};
```
```typescript:fetchers.ts
export const postApi = () => Promise.resolve();
```

このコンポーネントに対するテストは、``postApi()``が呼び出されていることが確認できれば十分だったので、最初は以下のようにテストを書いていました。

```typescript:index.test.tsx
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()が呼び出されていることを検証
});
```
テストを実行すると以下のような結果が表示されます。
![スクリーンショット 2023-11-13 17.50.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982501/f0170551-b61f-5c7a-7ee9-67f0f2acdd1b.png)
テストは通っているようです。しかし、テストの結果に問題はないのに、console.errorが出力されていました🤔
![スクリーンショット 2023-11-13 17.52.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982501/7a1af845-fc37-74b6-0743-1265d15758d6.png)


console.errorの内容を見ると` Error: Not implemented: navigation (except hash changes)`と書かれていて、` window.location.reload();`の箇所に矢尻が付き、エラーとして検出されていました。
![スクリーンショット 2023-11-13 17.53.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982501/e2ea05d6-6198-b344-b6e0-4fdf44d849d5.png)
目的のテストは実装できていて、かつ通っているのでスルーしても致命的な問題にはならないのですが、テストを実行するたびにこのエラーが出力されるのは気持ち良くないので解消することにしました。

# エラーを解消する

初めて見るエラーだったので戸惑ったのですが、調べてみると表示されたconsole.errorが解消できそうな記事が出てきたのでこちらを参考に対処することにしました。

https://zenn.dev/sengosha/articles/0fe44b84739c80

その解消方法は、**window.locationを上書きする方法**です。

window.locationでエラーが出る理由は、Jestで使用されるjsdomではwindow.locationに必要なnavigationをサポートしていないからだそうです。（[参考](https://github.com/jsdom/jsdom/issues/2112)）

以上の理由でJestでwindow.locationが使えないので、本来のwindow.locationの代わりを用意することでエラーを回避していきます。

**console.errorの解消方法を実装した後**のコードの全体像はこちらです。

```typescript:index.test.tsx
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に置き換えています。
```typescript
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コンポーネントがレンダリングされる前に実行しておく必要があります。
```typescript
setUpMyLocation();

render(<Button />);
```

🎉これでエラーは解消されました👏🎉

# オマケ： window.locationの実行有無をテストしたいとき
先ほどはButtonをクリックしたときに、postApiが呼ばれていることだけをテストすれば十分でしたが、リロードボタンなのでリロードがちゃんと実行されるかテストしたくなったとします。
こんな時はlocationをjest.fn()を使ってモックすることでリロードが実行されたか検証することができます。
先ほどのコードにモックの実装を追加します。

```typescript:index.test.tsx
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関数を仕込むことで呼び出しの回数も検証することができます。同様のエラーに困った人の参考になれば嬉しいです。

個人的に今回の記事を書くにあたって、なんとなく使っていた関数について調べたり、細かな「なぜ？」を放置せずに調べることができたのがよかったなと思いました。