🧪

テスト駆動開発でフロントエンドのテスト習熟度アップを図る

2023/05/07に公開

テスト駆動を通じてフロントエンドのテストに慣れる

フロントエンドのコードを見ると、全くテストが書かれていなかった。一部にはあるものの、十分ではなかった。コンポーネントが肥大化してリファクタリングも難しいなど、フロントエンドの開発をする際、テスト不足に悩まされたことはないでしょうか。筆者の主観ですが、フロントエンドに十分なテストが書かれているプロジェクトは少ないように思えます。同じプロジェクトであっても、サーバーサイドのソースと比較すると、より顕著にテスト不足を感じることが多くあります。

フロントエンドのテスト不足の原因は、ひとえに単純にテストを書くのが難しいことに尽きるのではないかと筆者は考えています。React といったフレームワークの知識だけでなく、testing-library、Jest、Storybook、MSW などのエコシステムを使いこなし、更にそれらを組み合わせて管理し使い分けたり、テスタブルなコードを書くためにコンポーネントの分割を考えたりと、ただ作るよりも格段に難易度が上がります。

テストは習うより慣れる、挑戦してみることが大事です。テスト駆動開発の手法は、諸々のメリット・デメリットがあります。良い思い出がない、苦手意識が強い人も少なくないと思いますが、テストを書くコツを掴むためには、最も合理的な手法であると考えます。

この記事では筆者が実際にフロントエンドのテスト駆動開発に挑戦して得られた知見をシェアします。

超要約

長い文章や例示が不要な人向けの要約

  • Hygen を使ってテストファイルを含めたテンプレートを生成することで、品質や生産性が向上する
  • フロントエンドのテスト駆動開発でも、基本的なレッド/グリーン/リファクタリングのプロセスは変わらない。スタイリングは最後に行う
  • 要素取得の基本は getByRole
  • テストのノウハウを伝授するにはペアプログラミングが最も効果的である
  • 過信は避けるべきだが、GitHub Copilot を利用することでタイピング時間が短縮できる

テンプレートを用意する

フロントエンドのテスト駆動開発を実践するにあたって、最も効果的だったことは、テンプレートを使用してコンポーネントや hooks の追加を行うことでした。
これにより、お決まりの文章を入力したり、ファイルを作成したりするような地味で面倒な作業時間が短縮されるだけでなく、新しいコンポーネントを作成するハードルを下げることができます。
新しいコンポーネントを作ることに抵抗が薄くなれば、コンポーネントが肥大化する前に分割しようという気持ちが生まれ、再利用やテストがし易くもなるでしょう。
また、テンプレート自体が「こう書いてくれ」というコード規約のような役割を果たします。

筆者は Hygen を使って、テンプレートを書いています。Hygen テンプレートは Node 環境限定ではなく、様々な環境で使用出来るため、バックエンドやインフラ回りの管理でも使いやすいです。

Hygen による React コンポーネントのテンプレートの例

以下は、Hygen を使用した React のコンポーネントを作成する場合の具体例です。

  • 指定のパスにコンポーネント名でフォルダを作る
  • コンポーネント、テスト、story をそれぞれ生成する
  • childrenの有無を指定する

といった機能を持っています。Hygenの使い方はこの記事では取り扱わず、説明を省略します。

// _templates/fc/new/prompt.js

module.exports = [
  {
    type: "input",
    name: "path",
    message: "path ex: src/app/common",
    default: "src/app/common",
  },
  {
    type: "input",
    name: "name",
    message: "component name ex: Button",
  },
  {
    type: "confirm",
    name: "hasChildren",
    message: "Does this component have children?",
    default: false,
  },
];
---
to: <%= path %>/<%= name %>/index.tsx
---
// _templates/fc/new/index.tsx.ejs.t
import React from "react"

export type <%= name %>Props = {
  <% if (hasChildren) { %>children?: React.ReactNode<% } %>
  onClick?: () => void
}

export const <%= name %>: React.FC<<%= name %>Props> = ({
  onClick,
  <% if (hasChildren) { %>children<% } %>
}) => {
  return (
    <button onClick={onClick}>
      <% if (hasChildren) { %>{children}<% } %>
    </button>
  )
}
---
to: <%= path %>/<%= name %>/index.test.tsx
---
// _templates/fc/new/index.test.tsx.ejs.t
import { render, screen, within } from "@testing-library/react"
import userEvent from "@testing-library/user-event";
import React from "react";

import { <%= name %>, <%= name %>Props } from "."

describe(<%= name %>, () => {
  const handleClick = jest.fn();
  const generateDefaultProps = (): <%= name %>Props => ({
    onClick: handleClick
  })
  afterEach(() => {
    jest.clearAllMocks();
  });

  it("should render button", () => {
    render(<<%= name %> {...generateDefaultProps()} />)
    const button = screen.getByRole("button")
    expect(button).toBeVisible()
  })
  it("should call onClick when click button", async () => {
    render(<<%= name %> {...generateDefaultProps()} />)
    const button = screen.getByRole("button")
    await userEvent.click(button)
    expect(handleClick).toBeCalledTimes(1)
  })
})

---
to: <%= path %>/<%= name %>/index.stories.tsx
sh: "code <%= path %>/<%= name %>/index.stories.tsx"
---
// _templates/fc/new/index.stories.tsx.ejs.t
import { Meta, StoryObj } from "@storybook/react"

import { <%= name %> } from "."

export default {
  component: <%= name %>
} as Meta<typeof <%= name %>>

export const Primary: StoryObj<typeof <%= name %>> = {
  render: props => <<%= name %> {...props} />,
  args: {
  }
}

テスト駆動によるReactコンポーネント作成手順

フロントエンドでテスト駆動開発を行う場合も、基本はレッド・グリーン・リファクタリングであることは変わりません。

1. テンプレートを使用して、コンポーネントを生成する

前項で説明したような、テンプレートを使って生成することをルールとします。テンプレートを使うと、testとstoryが自動生成されるため、テスト駆動の準備が整います。

# hygenを使ってテンプレートを生成する。
# 以下の例ではButtonという名前のコンポーネントに、クリック時のコールバック、子要素の表示という機能を実装する
hygen fc new

テンプレートはプロジェクトに応じて、見直しや機能追加などをしていくことをおすすめします。

2. 必要に応じて型定義を変更する

テスト駆動なので、機能を一つづつ実装していきます。
Propsに変更が必要な場合、最初に型定義を修正しましょう。
型定義を修正した後、型エラーがなくなるようにコンポーネント、テスト、ストーリーを修正します。この時点では機能の実装を行いません。

// Button/index.tsx
export type ButtonProps = {
  children?: React.ReactNode;
  // onClickを追加する
  onClick: () => void;
};
// Button/index.test.tsx
const handleClick = jest.fn();
const generateDefaultProps = (): ButtonProps => ({
  onClick: handleClick,
});
afterEach(() => {
  jest.clearAllMocks();
});

3. テストを追加する

新たな機能を追加する際は、まずその機能のテストを書きましょう。追加項目のみが失敗し、他のテストはすべて正常に動作している状態を維持することが重要です。
アサーションの数は、基本的に 1 テスト 1 つ、多くても 3 つ程度に抑えることが望ましいです。

it("should call onClick when click button", async () => {
  render(<Button {...generateDefaultProps()} />);
  const button = screen.getByRole("button");
  await userEvent.click(button);
  expect(handleClick).toBeCalledTimes(1);
});

5. テストが失敗することを確認する

レッド・グリーン・リファクタリングのレッドの部分です。
例えばアサーション自体を忘れたり、内容が不適切などの場合、書いたばかりのテストが通ってしまうことがあります。
そのため、まずは失敗すること(意図した実装がされていないこと)を確認しましょう。

6. テストが成功するようにコードを修正する

追加したテストが通るようにコードを修正しましょう。もちろん、既存のテスト項目が失敗しないように注意しながら作業を進めます。
テストが成功したら、コミットを行いましょう。

export const Button: React.FC<ButtonProps> = ({ onClick }) => {
  return <button onClick={onClick}></button>;
};

7. 上記手順を繰り返し、スタイル以外の機能を実装する

2~6 の手順を繰り返し、スタイリング以外の実装を完了させます。
テストを先に書いて始めるのは理想的ですが、最初はとても難しいと感ることでしょう。
最初は無理をせずにコードを先に書いて、テストを書くでも良いと考えられます。
そのうちテストに慣れて、先にテストを書いてから実装が出来るようになると思います。

import React from "react";

export type ButtonProps = {
  children?: React.ReactNode;
  onClick?: () => void;
};

export const Button: React.FC<ButtonProps> = ({ onClick, children }) => {
  return <button onClick={onClick}>{children}</button>;
};
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";

import { Button, ButtonProps } from ".";

describe(Button, () => {
  const handleClick = jest.fn();
  const generateDefaultProps = (): ButtonProps => ({
    onClick: handleClick,
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  it("should call onClick when click button", async () => {
    render(<Button {...generateDefaultProps()} />);
    const button = screen.getByRole("button");
    await userEvent.click(button);
    expect(handleClick).toBeCalledTimes(1);
  });

  it("should show children", () => {
    render(
      <Button {...generateDefaultProps()}>
        <img src="./test.png" alt="send tweet"></img>
      </Button>
    );
    const button = screen.getByRole("button");
    const img = within(button).getByRole("img", { name: "send tweet" });
    expect(img).toBeVisible();
  });
});

8. Storybook を使用し、コンポーネントの見た目を確認しながらスタイリングを行う

見た目に関する実装、スタイリングは最後に行います。getByRole を使ったアサーション優先していれば、見た目を整えるための flex の追加等で、テストが通らなくなる心配は薄いでしょう。

テスト駆動の手順をチームに浸透させるために

いかがでしたでしょうか。やってみると最初は結構難しく、煩雑に感じるかもしれません。
身についたといえる状態になっても、これをチーム全体に浸透させる自信はなかなか湧いてこないと思います。

結論から言うと、ペアプログラミングが最も効果的です。
テストファーストな手順を身につけるには、ひたすら研鑽が必要になります。GitHub Copilot はタイピングの煩わしさを軽減してくれますが、頼り切ることはできません。
思うようにテストが書けないと、テスト自体を飛ばしたくなったりします。ついタイピングが乗って、テストを書く前に実装を一気に終えてしまうこともあります。
ペアで「今日はテスト駆動でやろう」という、ちょっとしたチャレンジ意識をもって挑むと、手順が身につきやすく、理解も素早く進みます。

個人的なプラクティス

ここから下は、テスト駆動に限らない、フロントエンドのテストの個人的なプラクティスになります。

コンポーネントのプロパティは、テストしたい項目以外デフォルト値を決めて埋めてしまう

コンポーネントには多くのプロパティが必要となる場合がありますが、それらを毎回 render 内に記述するのは面倒です。
行数も増え、テストしたい項目もぼやけてしまいます。そのためテストしたいプロパティだけを明示的に記述し、その他はデフォルト値で埋める方が簡単です。

describe(Race, () => {
  // デフォルトのPropsを生成する関数
  const generateDefaultProps = (): RaceProps => ({
    openDate: "2021-01-01",
    place: "東京",
    raceNumber: 1,
    raceName: "有馬記念",
  });
  it("should render open date", () => {
    // テストしたい箇所だけ明示的に値を変更し、確認する
    render(<Race {...generateDefaultProps()} openDate="2023-05-03" />);
    const time = screen.getByRole("time");
    expect(time).toHaveTextContent("2023-05-03");
    expect(time).toHaveAttribute("datetime", "2023-05-03");
  });
});

エレメントは基本的に getByRole で取得する

getByRole は要素の構造やスタイルに依存しないため、テストが壊れる可能性が低くなります。
さらに、アクセシビリティを意識しやすくなり、div の乱用などを防ぐ効果も期待できます。
また、getByRole はオプションで disabled や aria 属性の有無を指定できるため、使い勝手が優れています。
E2E テストツールである Playwright でも、getByRole に近い機能が実装されているため、覚えておくと便利なことが多いです。

コールバックのテスト

例えばボタンをクリックしたときに、onClick が呼ばれることを確認するなどのテストの場合、テストを async にし@testing-library/user-eventを使用して、await userEvent.click(button)を呼ぶと、ユーザーのクリックを再現することができます。
再現した後は、jest.fn で作成した mock のコールバック関数にたいしてアサーションを行います。
私の場合、コールバックの関数をテストするときは、このように書くことが多いです。

// テストしたいコールバックが、onClickならhandleClick
// onSendならhandleSendと、対応がわかる命名にする
const handleClick = jest.fn();

const generateDefaultProps = (): ButtonProps => ({
  onClick: handleClick,
});

afterEach(() => {
  // jest.clearAllMocks()であれば、コールバックを増やした場合でも処理を追加しなくて良い
  jest.clearAllMocks();
});

it("should call onClick when click button", async () => {
  render(<Button {...generateDefaultProps()} />);
  const button = screen.getByRole("button");
  await userEvent.click(button);
  expect(handleClick).toBeCalledTimes(1);
});

toBeCalledTimesで呼び出し回数を、加えて引数がある場合はtoBeCalledWithで引数をテストするのが良いと考えます。
内部に複雑な状態を持つコンポーネントの場合、呼び出し回数が意図しないものになっていたりするため、toBeCalledではテストが不足することがあります。

mock データの生成関数のおすすめ

API のレスポンスデータはプロパティが多くなりがちで、テストでの使用頻度も高くなります。
そのため型定義時にセットで、mock データの生成関数を作成することおすすめします。
hygen のテンプレート化しても便利。

// src/models/Horse/index.ts
export const HorseModelSchema = z.object({
  name: z.string(),
  code: z.string().numeric(),
});

export type HorseModel = z.infer<typeof HorseModelSchema>;
// src/models/Horse/index.mock.ts
// export const DEFAULT_HORSEのようにすると、誤ってミュータブルに値を変更してしまい、予期せぬ影響が出る可能性が高い。
// そのため関数を使ってデフォルトデータを都度生成する
export function generateMockHorseModel(
  // デフォルト値を上書きしたい場合に、引数で指定する。省略した場合は全てデフォルト値で返る
  override: Partial<HorseModel> = {}
): HorseModel {
  return HorseModelSchema.parse({
    name: "オルフェーヴル",
    code: "2008102636",
    ...override,
  });
}

通信を伴うコンポーネントのテスト

通信等を伴うコンポーネントをテストする場合、通信処理に相当する関数を Props として渡す、jest の spy 等を使用して、通信処理を mock 処理に置き換える、または MSW を使用して、レスポンスをモックする、などの手法が考えられます。
筆者は以下の理由から MSW を使った手法を使うことが多いです。

  • どの API を使うかの情報は必要になるが、実装を意識しないでテストすることができる
  • モックに使用する MSW のハンドラは、Storybook や動作確認等でも使用することができる

以下は通信処理を mock するテストの例です。

const path = "/horses";

export type CreateMockHorsesHandlerProps = {
  callback: (request: HorseRequest) => void;
  response: HorseModel;
};

// モックしたいAPIごとに、任意のレスポンスを返却するMSWのハンドラーを作る。テンプレート化したり、ジェネリクスを駆使してカリー化しても良い
export function createMockHorsesHandler({
  callback,
  response,
}: CreateMockHorsesHandlerProps): RequestHandler {
  return rest.get(path, async (req, res, ctx) => {
    try {
      const params = HorseRequestSchema.parse(req.params);
      callback(params);
    } catch (error) {
      return res(ctx.status(400));
    }
    return res(ctx.json(response));
  });
}
import { setupServer } from "msw/node";

describe(SomeFetchComponent, () => {
  // サーバーに渡されるパラメータの検証に使う
  const handleParams = jest.fn((_: HorseRequest) => void);
  const server = setupServer(
    createMockHorsesHandler({
      callback: handleParams,
      response: generateMockHorseModel(),
    }),
  );
  beforeAll(() => {
    // mockの開始
    server.listen();
  })
  afterEach(() => {
    // テスト毎にハンドラとmock関数を初期状態にリセットする
    jest.clearAllMocks();
    server.resetHandlers();
  })
  afterAll(() => {
    // mockの終了
    server.close()
  })
  // 以下テストを書く。
})

GitHub Copilot の活用

GitHub Copilot は面倒なテスト記述を楽にしてくれます。ヒントが多いほど、予測の精度が上がるため、以下の点に気を付けると良いでしょう。

過信しない

中身は必ず確認しましょう。AI の書いたコードがわからなければ、保守ができません。
タイピングの時間を節約する程度の活用方法が、ちょうどいいのではないかと思います。

テストに使用すると思われる import を先に済ませておく

withinuserEventが必要だと考えられる場合、import を済ませておくと GitHub Copilot はそれを使ったテストを生成しようとします。

テストしたい内容を簡潔にコメントで書いて Copilot を誘導する

// click のようなコメントを入力すると、次の行にit("should call onClick when click button", async () => { といったテスト項目を、サジェストしてくれます。

Discussion