お知らせ フロントエンド バックエンド インフラ 品質保証 セキュリティ 製品 興味・関心 その他

フロントエンドのテストを書いてみる

はじめに

こんにちは!開発チームの遠藤です。  
MRC(Markerise Cloud)では、現在フロントエンドのリプレイスが行われており、  
テストコードを書きながら開発しています。  
本記事では、実際に React コンポーネントをどのような観点で、
テストコードを書いているのかを、簡単にですが、紹介したいと思います。

環境

  • react: v17.0.2
  • react-dom: v17.0.1
  • @testing-library/react: v11.1.2
  • @testing-library/react-hooks: v3.4.2

テスト観点

テストの観点や粒度は、プロジェクトによって様々ですが、
フロントエンド開発では、以下の種別でテストコードを書いています。

  • コンポーネント
  • カスタムフック
  • 関数

何をチェックしているのか

  • コンポーネントテスト
    • DOM を参照して見た目をチェック
      • コンポーネントがレンダリングされたとき
      • ユーザーイベントが発火されたとき e.g.
  • カスタムフックテスト
    • 状態と更新関数の紐づきをチェック
  • 関数テスト
    • 関数内の処理をチェック

コード例 (コンポーネント)

実際にコンポーネントの例を書いてみました。(あくまでも例なのでご了承ください)  
機能としては、ファイル選択を行うと選択されたファイルのサイズを byte ~ TB の単位付きで表示するものです。

コンポーネント

type Testable = { "data-testid"?: string | undefined };

export const FileSizeCheck: VFC<Testable> = ({ "data-testid": testid }) => {
  const [file, setFile] = useInputFileState();
  const fileSize = useMemo(
    () => file && convertFileSize(file.size).join(""),
    [file]
  );
  return (
    <div data-testid={testid}>
      <input type="file" onChange={setFile} />
      {fileSize ? <div>{`Size: ${fileSize}`}</div> : <></>}
    </div>
  );
};

テスト種別に当てはめてみる

上のコードをテスト種別に当てはめてみると、次のようになります。

  • コンポーネント → FileSizeCheckが対象
  • カスタムフック → useInputFileStateが対象
  • 関数 → convertFileSizeが対象

コード例 (カスタムフック・関数)

以下、コンポーネント内で使用しているコードになります。

カスタムフック

export const useInputFileState = (): [
  File | undefined,
  Dispatch<ChangeEvent<HTMLInputElement>>
] => {
  const [value, setValue] = useState<File>();
  return [value, getInputOnChange(setValue)];
};

const getInputOnChange =
  (setValue: Dispatch<File>) => (event: ChangeEvent<HTMLInputElement>) => {
    if (!event.target.files?.length) return;
    const file = event.target.files[0];
    setValue(file);
  };

関数

type FileSizeUnit = "TB" | "GB" | "MB" | "KB" | "byte";

export const convertFileSize = (size: number): [number, FileSizeUnit] => {
  if (size >= 1024 ** 4) return [calc(size, 4), "TB"];
  if (size >= 1024 ** 3) return [calc(size, 3), "GB"];
  if (size >= 1024 ** 2) return [calc(size, 2), "MB"];
  if (size >= 1024 ** 1) return [calc(size, 1), "KB"];
  return [size, "byte"];
};

const calc = (size: number, n: number) =>
  Math.floor((size / 1024 ** n) * 100) / 100;

テストコードを書いてみる

次にテストコードを書いてみます。
実際には、仕様に沿ってきちんとテスト項目の洗い出しに加え、同値分割・境界値分析等を用いて、
適切なテストデータを作成する必要がありますが、割愛させて頂きます。

コンポーネントテスト

const fileMock = new File(["test"], "test.csv");

describe("FileSizeCheck", () => {
  test("ファイルを選択するとファイルのサイズが表示される", () => {
    const { getByTestId, queryByText } = render(
      <FileSizeCheck data-testid="file-size-check" />
    );
    const component = getByTestId("file-size-check");
    expect(queryByText("Size: 4byte")).toBeFalsy();
    const input = component.getElementsByTagName("input")[0];
    fireEvent.change(input, { target: { files: [fileMock] } });
    const file = input.files && input.files[0];
    expect(file).toBeTruthy();
    expect(file?.name).toBe("test.csv");
    expect(queryByText("Size: 4byte")).toBeTruthy();
  });
});

カスタムフックテスト

const changeEventFileMock = {
  target: { files: [fileMock] },
} as unknown as ChangeEvent<HTMLInputElement>;

const changeEventEmptyFileMock = {
  target: { files: [] },
} as unknown as ChangeEvent<HTMLInputElement>;

describe("useInputFileState", () => {
  test("stateの初期値はundefinedである", () => {
    const { result } = renderHook(() => useInputFileState());
    const [value] = result.current;
    expect(value).toBeUndefined();
  });
  test("changeイベント(Fileあり)を受け取るとstateにFileがセットされる", () => {
    const { result } = renderHook(() => useInputFileState());
    const [, setValue] = result.current;
    act(() => setValue(changeEventFileMock));
    const [value] = result.current;
    expect(value).toBe(fileMock);
  });
  test("changeイベント(Fileなし)を受け取ってもstateは変化しない", () => {
    const { result } = renderHook(() => useInputFileState());
    const [, setValue] = result.current;
    act(() => setValue(changeEventFileMock))
    act(() => setValue(changeEventEmptyFileMock));
    const [value] = result.current;
    expect(value).toBe(fileMock);
  });
});

関数テスト

describe("convertFileSize", () => {
  test(`引数 1024 ** 4 の戻り値は [1, "TB"] である`, () => {
    const result = convertFileSize(1024 ** 4);
    expect(result).toEqual([1, "TB"]);
  });
  test(`引数 1024 ** 3 の戻り値は [1, "GB"] である`, () => {
    const result = convertFileSize(1024 ** 3);
    expect(result).toEqual([1, "GB"]);
  });
  test(`引数 1024 ** 2 の戻り値は [1, "MB"] である`, () => {
    const result = convertFileSize(1024 ** 2);
    expect(result).toEqual([1, "MB"]);
  });
  test(`引数 1024 ** 1 の戻り値は [1, "KB"] である`, () => {
    const result = convertFileSize(1024 ** 1);
    expect(result).toEqual([1, "KB"]);
  });
  test(`引数 1024 - 1 の戻り値は [1023, "byte"] である`, () => {
    const result = convertFileSize(1024 - 1);
    expect(result).toEqual([1023, "byte"]);
  });
});

終わりに

テストコードを書いていれば、実装時の不具合チェックだけでなく、既存コードへの影響も検知できるので、コードの品質向上が期待できます!

テストで検証できていない実データやユーザー操作が存在する可能性があるので、
私は、テストコードを書いていれば完璧とは考えずに、あくまでもテストと思いながら、
テストコードを実装しています!

また、Testing Library は実際のブラウザ上には描画していないので、注意が必要だと考えています。
E2E テストでは、よりユーザー操作に近い形でテストができるので、テスト要件によっては E2E も選択肢に入ると思います。
Playwrightでもv1.22.0から コンポーネントテストもできるようになったので、こちらもチェックして記事にしてきたいと思います。

以上、参考になりましたら幸いです。

遠藤

遠藤

記事一覧

マーケライズ開発チームのエンジニア
Javaに思い入れあり
最近、Reactを「完全に理解した」→「何も分からん」にレベルアップした!?