はじめに
ファイルアップロード機能の開発中に、Storybook上でtype="file"属性を持つinput要素のテストを行う必要が生じましたが、input要素の取得に手間取ったため実装方法を共有します。
この記事は「AlphaDrive Advent Calendar 2023」の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
- ファイル選択前(ファイル選択ボタンのみ表示する)
- ファイル選択後(ファイル名と選択取消ボタンを表示する)
ファイル選択コンポーネントの作成
ファイル選択機能を持つコンポーネントを以下のとおり実装しました。
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
クエリを使用できず、また、ラベルやプレイスホルダーからの取得もできなかったため実装方法に悩みました。
実装例
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
クエリによって要素を取得できます。
SampleForm.tsx
<input
type="file"
data-testid="hidden-input" // 追加
ref={fileInputRef}
hidden
onChange={onChange}
/>
SampleForm.stories.tsx
const inputElement = await findByTestId("hidden-input");
解決策② querySelector
を使うパターン
querySelector
を使用して、CSSセレクタに基づきtype属性がfile
であるinput要素を取得する方法もあります。
SampleForm.stories.tsx
const inputElement = document.querySelector<HTMLInputElement>("input[type=file]");
参考資料
フロントエンドテストの考え方を分かりやすくまとめてくださっているので、何度も拝読しています。
まとめ
今回のテスト実装を通して、アクセシビリティロールやTestingLibraryの各クエリについて理解を深めることができました。上の参考資料にあるとおり、ByTestId
を多用せず、なるべくアクセシブルなクエリを使ったテストを優先できるよう引き続き考えていきたいと思います。