Storybookでinput[type="file"]要素にファイルを添付する

## はじめに

ファイルアップロード機能の開発中に、Storybook上でtype="file"属性を持つinput要素のテストを行う必要が生じましたが、input要素の取得に手間取ったため実装方法を共有します。

この記事は「[AlphaDrive Advent Calendar 2023](https://qiita.com/advent-calendar/2023/alphadrive-tech)」の15日目のエントリーです。

## 開発環境

- node: v16.18.1
- react: v18.2.0
- next: v14.0.3
- storybook: v7.6.4
- typescript: v5.2.2
- @storybook/react: v7.6.4
- @storybook/jest: v0.2.3
- @storybook/testing-library: v0.2.2

## 簡略化したUI
- ファイル選択前（ファイル選択ボタンのみ表示する）
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2579883/42d9976c-afcb-69ee-7917-728a89ff7269.png" width=45%>

- ファイル選択後（ファイル名と選択取消ボタンを表示する）
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2579883/67e30a01-03bf-937a-c04d-e492ffce8ad2.png" width=45%>

## ファイル選択コンポーネントの作成

ファイル選択機能を持つコンポーネントを以下のとおり実装しました。

```tsx:SampleForm.tsx
import React, { useState, useRef, ChangeEvent } from "react";

export const SampleForm: React.FC = () => {
  const [file, setFile] = useState<File | null>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const onChange = (event: ChangeEvent<HTMLInputElement>) => {
    const selectedFile = event.target.files?.[0];
    if (!selectedFile) {
      return;
    }
    setFile(selectedFile);
  };

  const handleClick = () => {
    if (!fileInputRef.current) {
      return;
    }
    fileInputRef.current.click();
  };

  const handleCancel = () => {
    setFile(null);
    if (fileInputRef.current) {
      fileInputRef.current.value = "";
    }
  };

  return (
    <div>
      <input
        type="file"
        ref={fileInputRef}
        hidden
        onChange={onChange}
      />

      {!file && <button onClick={handleClick}>ファイルを選択</button>}

      {file && (
        <div style={{ display: "flex", alignItems: "center" }}>
          <p style={{ marginRight: "10px" }}>{file.name}</p>
          <button onClick={handleCancel}>選択キャンセル</button>
        </div>
      )}
    </div>
  );
};

```

## Storybookでのファイル選択機能のテスト実装

### 悩んだポイント

今回のケースでは、`input[type="file"]`には特定のアクセシビリティロールが存在しないため、Testing Libraryの`ByRole`クエリを使用できず、また、ラベルやプレイスホルダーからの取得もできなかったため実装方法に悩みました。

### 実装例

```tsx:SampleForm.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { expect } from "@storybook/jest";
import { userEvent,within } from "@storybook/testing-library";

import { SampleForm } from "./SampleForm";

const meta: Meta<typeof SampleForm> = {
  component: SampleForm,
};

export default meta;
type Story = StoryObj<typeof SampleForm>;

export const selectFile: Story = {
  async play({ canvasElement }) {
    const { findByText, findByTestId } = within(canvasElement);

    // ここで 解決策① or 解決策② の方法でinput要素を取得する必要があります！

    expect(inputElement).toBeInTheDocument();

    // public配下に置いたテスト用のファイルをfetch
    const res = await fetch("/sample.csv");
    const blob = await res.blob();

    // 新しくFileオブジェクトのインスタンスを作成
    const file = new File([blob], "test.csv", {
      type: "text/csv",
    });

    userEvent.upload(inputElement, file);
    expect(await findByText("test.csv")).toBeInTheDocument();
  },
};
```

### 解決策① Testing Libraryの`ByTestId`を使うパターン

input要素に`data-testid`属性を付けると、Testing Libraryの`ByTestId`クエリによって要素を取得できます。

https://testing-library.com/docs/queries/bytestid/

```tsx:SampleForm.tsx
<input
  type="file"
  data-testid="hidden-input" // 追加
  ref={fileInputRef}
  hidden
  onChange={onChange}
/>    
```

```tsx:SampleForm.stories.tsx
const inputElement = await findByTestId("hidden-input");
```

### 解決策② `querySelector`を使うパターン


`querySelector`を使用して、CSSセレクタに基づきtype属性が`file`であるinput要素を取得する方法もあります。

https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelector

```tsx:SampleForm.stories.tsx
const inputElement = document.querySelector<HTMLInputElement>("input[type=file]");
```

## 参考資料

フロントエンドテストの考え方を分かりやすくまとめてくださっているので、何度も拝読しています。

https://speakerdeck.com/cybozuinsideout/web_frontend_testing_and_automation-2023?slide=55

## まとめ
今回のテスト実装を通して、アクセシビリティロールやTestingLibraryの各クエリについて理解を深めることができました。上の参考資料にあるとおり、`ByTestId`を多用せず、なるべくアクセシブルなクエリを使ったテストを優先できるよう引き続き考えていきたいと思います。