【React】Mock Service Workerで模擬したサーバレスポンスをテストに活用する

現在、Testing Libraryについて学習中です。
今回はサーバのレスポンスをMock Service Workerで模擬し、Testing Libraryで作成したテストに活用することに挑戦してみました。

https://qiita.com/suzuki0430/items/656213ca221ad75aaee4

https://qiita.com/suzuki0430/items/dfec9532327f6d247a5c

https://qiita.com/suzuki0430/items/f82e7fe619d8e592b086

#Mock Service Workerとは
Mock Service Worker(msw)は、ネットワーク呼び出し(APIリクエスト)をインターセプトして特定のレスポンスを返すことを目的としたライブラリです。
mswを利用することで、サーバとの通信を行わずにレスポンスを利用したテストをできたり、SPA開発時のモックサーバとして活用することができます。

https://mswjs.io/docs/

https://zenn.dev/ryo_kawamata/articles/introduce-mock-service-worker

#実装
以下のコンポーネントで構成された画面を考えます。
ScoopOptionの画像とアイテム名がサーバから取得するデータです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/0646cf96-1692-629d-12d2-c93198c0b007.png" width=700>
また、サーバからデータを取得できなかったとき、Options内で警告バナー(AlertBanner)を表示するようにします。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/0be7f4ac-a4e1-ffde-52a8-c0f577843d06.png" width=700>

以下の2パターンを確認するためのテストを作成しました。

- モックサーバの模擬データを正しく取得できているかどうか
- サーバからのデータ取得の失敗を模擬して警告が表示されるかどうか

##セットアップ
mswのセットアップは以下の流れで行います。

- ライブラリのインストール
- handlersの作成
- test serverの作成
- setUpTestsの作成(create-react-appの場合)

###ライブラリのインストール
以下のコマンドでライブラリをインストールします。

```
npm install msw
```

###handlersの作成
handlersはモックするリクエストとレスポンスの中身を指定するものです。
REST APIとGraphQlのどちらにも対応しており、今回はREST APIで作成します。

`ctx`でレスポンスの返し方を決めることができ、例えば`ctx.json`とすることでjson形式に変換しています。

```js:handlers.js
// src/mocks/handlers.js
import { rest } from 'msw';

export const handlers = [
  rest.get('http://localhost:3030/scoops', (req, res, ctx) => {
    return res(
      ctx.json([
        { name: 'Chocolate', imagePath: '/images/chocolate.png' },
        { name: 'Vanilla', imagePath: '/images/vanilla.png' },
      ])
    );
  }),
];
```

###test serverの作成
作成したhandlersを使用して、実際のモックサーバ(test server)を作成します。

```js:server.js
// src/mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

// handlersを元にモックサーバを作成
export const server = setupServer(...handlers);
```

###setUpTestsの作成
create-react-appの場合はsetUpTestsに以下の内容を記述します。
テスト中のモックサーバの設定を行います。

```js:setupTests.js
// src/setupTests.js
import { server } from './mocks/server.js'

// モックサーバのlistenをすべてのテストの前に一回だけ行う
beforeAll(() => server.listen())

// 他のテストに影響を与えないようにテストごとにhandlersをリセットする
afterEach(() => server.resetHandlers())

// すべてのテストが終了したらモックサーバをcloseする
afterAll(() => server.close())
```

##テスト1の作成
モックサーバの模擬データを正しく取得できているかどうかを確認するテストを作成します。

テストするファイルの中身は以下のようになっています。

```jsx:Options.jsx
import axios from 'axios';
import { useEffect, useState } from 'react';
import Row from 'react-bootstrap/Row';
import ScoopOption from './ScoopOption';
import ToppingOption from './ToppingOption';
import AlertBanner from '../common/AlertBanner';
import { pricePerItem } from '../../constants';

export default function Options({ optionType }) {
  const [items, setItems] = useState([]);
  const [error, setError] = useState(false);

  // optionType is 'scoop' or 'toppings'
  useEffect(() => {
    axios
      .get(`http://localhost:3030/${optionType}`)
      .then((response) => setItems(response.data))
      .catch((error) => setError(true));
  }, [optionType]);

  if (error) {
    return <AlertBanner />;
  }

  // TODO: replace `null` with ToppingOption when available
  const ItemComponent = optionType === 'scoops' ? ScoopOption : ToppingOption;
  const title = optionType[0].toUpperCase() + optionType.slice(1).toLowerCase(); //頭文字だけ大文字

  const optionItems = items.map((item) => (
    <ItemComponent
      key={item.name}
      name={item.name}
      imagePath={item.imagePath}
    />
  ));

  return (
    <>
      <h2>{title}</h2>
      <p>{pricePerItem[optionType]} each</p>
      <Row>{optionItems}</Row>
    </>
  );
}
```

テストの中身は以下のようになります。

```jsx:Options.test.jsx
import { render, screen } from '@testing-library/react';
import Options from '../Options';

test('displays image for each scoop option from server', async () => {
  render(<Options optionType="scoops" />);

  // 画像が2個取得できていれば成功
  const scoopImages = await screen.findAllByRole('img', { name: /scoop$/i });
  expect(scoopImages).toHaveLength(2);

  // 画像のaltが設定されていれば成功
  const altText = scoopImages.map((element) => element.alt);
  expect(altText).toEqual(['Chocolate scoop', 'Vanilla scoop']);
});
```

前項のsetUpTestsの作成でテストを実行したときにモックサーバをlistenするような設定を行いました。
そのため、Optionsコンポーネントでは`http://localhost:3030/${optionType}`からサーバのデータを取得しているのですが、テスト実行中はモックサーバからデータを取得するようになっています。

##テスト2の作成
サーバからのデータ取得の失敗を模擬して、警告が表示されるかどうかをテストします。

```jsx:OrderEntry.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import OrderEntry from '../OrderEntry';
import { rest } from 'msw';
import { server } from '../../../mocks/server';

test('handles error for scoops and toppings routes', async () => {
  // handlersを上書きする
  server.resetHandlers(
    rest.get('http://localhost:3030/scoops', (req, res, ctx) =>
      res(ctx.status(500))
    ),
  );

  render(<OrderEntry />);

  await waitFor(async () => {
    const alerts = await screen.findAllByRole('alert');
    expect(alerts).toHaveLength(1);
  });
});
```

エラーレスポンス(`500`)を返すように、`server.resetHandlers`を使ってhandlersを上書きします。

また、警告アラート(AlertBanner)はデータを取得できなかったとき(axiosがエラーをなげたとき)に非同期で表示されます。
そのようなケースでは、要素の取得に`await find[All]By`を使用します。

しかし、これだけだとWarningがでるので、`waitFor`も併用します。

#参考資料

https://www.udemy.com/share/1040ZSCEEZd11VRno=/