🎈

Storybook&MSWでCSVファイルのアップロード機能をテストする

2023/12/30に公開

はじめに

StorybookでCSVファイルのアップロードテストを実装する機会があったので、アウトプットとして投稿しておきたいと思います。
(実務経験が浅いため至らない点が多いです。温かい目でご覧いただきご指摘いただけると幸いです!)

開発環境

  • 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
  • msw-storybook-addon: v1.10.0
  • parse-multipart-data: v1.5.0

簡略化した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 = "";
    }
  };

  const handleSubmit = async () => {
    try {
      if (file) {
        const formData = new FormData();
        formData.append("file", file);
        const response = await fetch("/upload", {
          method: "POST",
          body: formData,      
        });
        const data = await response.json();
        console.log(data.message);
      }
    } catch (error) {
      console.error("アップロード中にエラーが発生しました", error);
    }
  };

  return (
    <div>
      <input
        type="file"
        data-testid="hidden-input"
        ref={fileInputRef}
        aria-label="file-select"
        accept=".csv"
        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>
      )}
      <button onClick={handleSubmit}>送信</button>
    </div>
  );
};

Storybookでのファイルアップロード機能のテスト実装

※前段階のファイル選択のテスト実装についてはこちらの投稿をご覧ください。

public配下にsample.csvを作成する

public/sample.csv
ProductID,ProductName,Category,Price
1001,Coffee Maker,Kitchen,120.00
1002,Running Shoes,Sports,85.50
1003,Desk Lamp,Furniture,45.00

MSW Storybook Addonをインストールする

MSW Storybook Addonを使うと、StorybookでMock Service Workerを使用してAPIリクエストをモックできるようになります。以下を参考に設定を行いました。
https://storybook.js.org/addons/msw-storybook-addon

詳細は省きますが、こちらの記事を参考に.storybook/main.tsのstatic directory指定などを行いました。
設定後npm run storybookで起動すると、Storybookのコンソールに[MSW] Mocking enabled.と表示されます。

parse-multipart-dataでFormDataをparseする

実装上、Content-typeがmultipart/form-dataのファイルをparseする必要があったため、以下を使用しました。
https://www.npmjs.com/package/parse-multipart-data

リクエスト成功のテスト実装例

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

import { SampleForm } from "./SampleForm";
import { postBulkImportData } from "../src/mocks/handlers";
import { RequestHandler } from "msw";

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

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

// postBulkImportDataハンドラーに渡され、APIリクエストが発生したときに呼び出されるモック関数
const requestSpy = jest.fn();

export const RequestSucceeded: Story = {
  parameters: {
    msw: {
      handlers: [postBulkImportData({ requestSpy })],
    },
  },
  async play({ canvasElement }) {
    const { findByTestId, findByRole } = within(canvasElement);

    const inputElement = await findByTestId("hidden-input");

    // public配下に置いたテスト用のファイルをfetchしFileオブジェクトを作成する
    const res = await fetch("/sample.csv");
    const blob = await res.blob();
    const file = new File([blob], "test.csv", {
      type: "text/csv",
    });

        // 作成したFileオブジェクトをアップロードし送信する
    userEvent.upload(inputElement, file);
    userEvent.click(await findByRole("button", { name: "送信" }));

    await waitFor(() =>
      // モック関数がファイルオブジェクトを引数として呼び出されたかどうかを確認する
      expect(requestSpy).toHaveBeenCalledWith({
        body: new File([], ""),
      }),
    );

    // リクエストに渡されたファイル名・タイプ・内容が正しいかを確認する
    const calledFile = (requestSpy.mock.lastCall?.[0] as any).body;
    expect(calledFile.name).toBe("test.csv");
    expect(calledFile.type).toBe("text/csv");
    expect(await calledFile.text()).toBe(
      "ProductID,ProductName,Category,Price\n1001,Coffee Maker,Kitchen,120.00\n1002,Running Shoes,Sports,85.50\n1003,Desk Lamp,Furniture,45.00",
    );

    requestSpy.mockReset();
  },
};

リクエストをモックするためのハンドラーを作成する

以下で/uploadエンドポイントへのPOSTリクエストをモックするためのハンドラーを定義します。
このハンドラーは、リクエストボディからファイルを解析し、そのファイルをrequestSpy関数に渡します。

handler.ts
import { rest } from "msw";
import { getBoundary, parse } from "parse-multipart-data";

type prams = {
  requestSpy?: ReturnType<typeof jest.fn>;
};

export const postBulkImportData = ({ requestSpy }: prams) =>
  rest.post("/upload", async (req, res, ctx) => {
    if (requestSpy) {
      const files = parse(
        Buffer.from(await req.arrayBuffer()),
        getBoundary(req.headers.get("Content-Type") || ""),
      );
      const reqFile = files.find((_) => _.name === "file");

      const file = new File([reqFile.data], reqFile.filename ?? "", {
        type: reqFile.type,
      });
      requestSpy({ body: file });
    }

    return res(
      ctx.status(200),
      ctx.json({
        status: 200,
        message: "ファイルが正常にアップロードされました",
        code: 0,
      }),
    );
  });

まとめ

今回の実装はつまづいたポイントが多く、かなりサポートいただきながら実装を完了しました。今回アウトプットのために一から環境構築しサンプルコードを実装したことで理解が進んだと感じています。やっぱり手を動かすのは大切だと実感したので来年も頑張ります!

Discussion