ファイル選択を含む機能を jest / storybook それぞれでテストする方法

## 背景
ファイルを選択する機能を含むコンポーネントをテストする際に、テスト用の File オブジェクトを取得する必要がある。
テストを行う際、従来は Node.js 上で Jest を起動してテストすることが多かったが、最近は storybook が推奨する playwright などブラウザのランタイム上でテストを実行することも増えてきたのではないかと思う。
ランタイムが Node.js の時と playwright のようなブラウザの時とでファイルを取得するテクニックが異なるので、それぞれでうまくいった方法を共有したい。

## 環境
- node: v16.18.1
- next: v13.2.1
- react: v18.2.0
- jest: v29.2.1
- @storybook/react: v6.5.16
- @testing-library/react: v13.4.0
- @testing-library/user-event: v13.5.0

## テスト対象のコンポーネント
下記のような、選択したファイルを Data URL として読んでプレビュー画像として表示するコンポーネントのテストを考える。

```tsx:pages/index.tsx
import Image from "next/image";
import { useState } from "react";

const TestPage: React.FC = () => {
  const [fileData, setFileData] = useState<string | null>(null);

  const onSelectFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.currentTarget.files?.[0];

    if (file) {
      const reader = new FileReader();

      reader.onload = () => {
        setFileData(reader.result as string);
      };

      reader.readAsDataURL(file);
    } else {
      setFileData(null);
    }
  };

  return (
    <div>
      <input
        id="file"
        name="file"
        type="file"
        aria-label="file-select"
        onChange={onSelectFile}
      />
      {fileData && (
        <div>
          <Image
            src={fileData}
            width={515}
            height={645}
            alt="avatar"
            aria-label="preview"
          />
        </div>
      )}
    </div>
  );
};

export default TestPage;
```

ファイル選択後の storybook での見た目はこんな感じ。
![スクリーンショット 2023-03-26 0.32.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131942/85b57adc-4dd4-d5ec-1662-56b06fde163a.png)

## storybook の場合
storybook の場合はブラウザの fetch API を利用してファイルを blob 形式で取得することができるので、それを利用する。
まず public/ ディレクトリ内に該当の画像ファイルを配置しておく。

```
├── public
│   └── dummy.png
├── src
│   ├── pages
│   │   ├── _app.tsx
│   │   ├── _document.tsx
│   │   ├── index.stories.tsx
│   │   ├── index.test.tsx
│   │   └── index.tsx
│   └── styles
│       ├── Home.module.css
│       └── globals.css
└── tsconfig.json
```

次に storybook をビルドする際のコマンドに、 public ディレクトリ内のファイルをサーブするオプションを追加する。

```bash
# 開発時
$ start-storybook -s public
# デプロイ時
$ build-storybook -s public
```

そして下記のように story を実装する。

```tsx:pages/index.stories.tsx
import { ComponentMeta, ComponentStoryObj } from "@storybook/react";
import { within } from "@storybook/testing-library";
import userEvent from "@testing-library/user-event";

import TestPage from "./index";

type Story = ComponentStoryObj<typeof TestPage>;

export default {
  component: TestPage,
} as ComponentMeta<typeof TestPage>;

export const Default: Story = {
  async play({ canvasElement }) {
    const { getByLabelText } = within(canvasElement);

    // public/ 内の画像ファイルを Blob 形式で fetch API 経由で取得する
    const response = await fetch("/dummy.png");
    const blob = await response.blob();
    const file = new File([blob], "image.png");

    userEvent.upload(getByLabelText("file-select"), file);
  },
};
```

fetch API を用いて取得した File オブジェクトを `userEvent.upload` に渡せば、無事にファイル選択をシミュレートできる。

## jest の場合
先ほど public/ 内に配置したファイルを `fs` 経由で取得する。

```tsx:pages/index.test.tsx
import fs from "fs";

import { render } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import TestPage from "./index";

describe("when a file is selected", () => {
  it("shows preview image", async () => {
    const { findByRole, getByLabelText } = render(<TestPage />);

    const buffer = fs.readFileSync("public/dummy.png");
    const file = new File([buffer], "dummy.png");

    userEvent.upload(getByLabelText("file-select"), file);

    expect(await findByRole("img", { name: "preview" })).toBeInTheDocument();
  });
});
```

実行してみると無事に下記の通り PASS した。

```bash
$ npm run test -- pages/index.test.tsx
 PASS  pages/index.test.tsx
  when a file is selected
    ✓ shows preview image (201 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        0.638 s, estimated 1 s
Ran all test suites matching /src\/pages\/index.test.tsx/i.
```