Reactテスト駆動開発に一度挫折した人が、生成AIの力を借りて再挑戦する話

はじめに

Insight EdgeのLead Engineerの日下です。 弊社ではフロントエンドのスクラッチ開発にReactを採用することが多いのですが、フロントエンドの保守性はしばしば課題となっています。 というのも、要求仕様が曖昧なPoC(Proof of Concept)の段階からMVP(Minimum Viable Product)として開発を進めることも多く、 ビジネス側ユーザの意見にも左右されながらアプリを改善していくため、画面のレイアウトやデザイン、画面遷移の変更が多発するためです。 こうした状況の中でスピードと品質を両立するためには良質なテストコードが不可欠なのはもちろん、 プロダクトコードとテストコードの双方とも、変化に対応しやすく作る必要があります。

過去にテスト駆動開発を挫折した要因

実は、筆者は過去にReactのテスト駆動開発の実践、および社内普及活動に挑戦したことがあったものの、以下のような理由で挫折してしまいました。

要因① テストコードの書き方を調べるのに時間がかかり、開発のスピードが落ちた

Reactのテスト駆動開発に挑戦した際、最初に直面した大きな壁はテストコードの書き方を理解することでした。 React Testing LibraryのWebサンプルや書籍を参考にしながらテストコードを書いていましたが、 複雑なDOM構造のテストや非同期処理のテストをしようとすると初歩的な教材では対応できず、 プロダクトコードは書けてもテストコードをスムーズに書けないという状況に陥ってしまいました。 特に、UIの操作が関係するテストの書き方は複雑になることが多く、 テストコードの書き方を調べるのに時間がかかり、開発のスピードが落ちてしまいました。

要因② 画面の仕様変更が頻発し、テストコードの保守が重荷になった

前述の通り、弊社の開発では画面の仕様変更が頻発するため、テストコードの保守性が重要です。 しかし、ReactテストプログラムのWebサンプルや書籍では、ロジックと画面レイアウトが密結合になっている入門的なコンポーネントの例が多く紹介されていました。 それを参考にしたことで画面の仕様変更に対してテストコードの修正箇所が多くなってしまい、テストコードの保守が重荷になってしまいました。

要因③ ロジックとビューの分離を難しく考えすぎた

上記の問題は、ロジックとビューが密結合だったことに起因するため、ロジックとビューの分離を試みました。 Reactの場合、ロジックはカスタムフックとしてコンポーネントの外に切り出すことが一般的です。 しかし、どういう固まりで切り出して1つのカスタムフックにするかの考え方に悩んだり、 ロジック部分をhooksディレクトリに移動したことでコーディング時のファイル切り替えの手間が増えたりしてしまいました。 その結果、テストは書きやすくなったものの開発効率は上がりませんでした。

再挑戦への道筋

はてさて、挫折した当時から時代は移り変わり、今ではGitHub CopilotやChatGPTなどの生成AIを活用できるようになりました。弊社ではGitHub Copilot Businessを導入しており、全エンジニアが利用できます。 今なら、、、今ならあの頃のリベンジを果たせるかもしれない! というわけで、今回はAIの力を借りてReactのテスト駆動開発に再挑戦してみることにしました。 リベンジに向けての方針は以下の通りです。

方針① はじめの一歩をAIに書いてもらう

生成AIを活用すれば、プロダクトコードからテストコードを生成できます。 仕様を網羅する完璧なテストケースとまではいかなくとも、典型的なテストケースを生成してもらえれば、 あとはそれを参考にして入出力値のバリエーションを変えたテストを追加するだけで良いため、 テストコード作成にかかる時間を大幅に短縮できるはずです。

方針② 完璧なテスト駆動にこだわらない

テスト駆動開発は本来、テストコード→プロダクトコードの順に開発を進めますが、 そもそもプロダクトコードは書けるけどテストコードを書けないことが当初の挫折要因だったので、 テストコード作成で生成AIの力をフル活用したいところです。 そこで、まずは最低限の単純なプロダクトコードを書いて、AIに初期テストコードを書いてもらうことにします。 その上で、仕様を追加する際には生成されたテストコードを参考にすれば、テストコードの書き方に悩むことなくテスト駆動で開発を進められます。

また、画面レイアウトやUIコンポーネント等は動くモックアップとしてコーディングしながら仕様を固めていくことも多いため、テスト駆動で開発する対象はロジック部分に注力します。 ビュー部分については仕様が固まってからスナップショットテストを追加することとし、回帰テストを主目的とします。 (本記事ではビューのテストの詳細は割愛します。)

方針③ ロジックとビューの分離を単純化する

ロジックとビューを分離してReactコンポーネントを記述しようとすると、そのコンポーネントでしか使われない(であろう)カスタムフックが多くなります。再利用性の高いロジックであれば単独のカスタムフックに切り出すことも重要ですが、一度しか使わないロジックまでhooksディレクトリに切り出すのは過剰な分離となり、凝集度が下がってしまいます。 そこで、再利用可能ロジックの部品化という観点とは無関係に、同一ファイル内でもできる単純なロジックとビューの分離方法をパターン化することで、デメリットを減らしつつコンポーネントの保守性を高めることを目指します。

コーディング例

前置きが長くなりましたが、ここからはサンプルコードを交えて上記内容の実践例を紹介します。

テスト対象コンポーネント(Before)

ここではサンプルのコンポーネントとして、ごく簡易なユーザ登録フォームのUIを例にして説明します。 主な仕様は以下のとおりです。

  • メールアドレス、パスワード、パスワード再入力の3つの入力項目と、登録ボタンがある。
  • 各入力項目は全て必須項目とする。
  • パスワード再入力の値がパスワードと一致しない場合はエラーメッセージを表示する。
  • 入力内容にエラーがある場合、登録ボタンは非活性とする。
  • 登録処理の結果をメッセージ領域に表示する。

まずはテストしにくいコードの例として、ロジックとビューが密結合になっているコンポーネントのコードを示します。 実際には、フォームを作るときはフォーム向け特化のhooksライブラリを使ったりしますが、今回はサンプルのため基本的なライブラリのみを使っています。

// import文は省略

export type ComponentProps = {
  onSubmit: (data: { email: string; password: string }) => void;
  signUpErrorMessage?: string;
};

export default function SignUpForm(props: ComponentProps) {
  const { onSubmit, signUpErrorMessage } = props;
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [passwordToConfirm, setPasswordToConfirm] = useState("");

  // パスワード再入力不一致のエラーメッセージ
  // メッセージ文字列の外部定義化は今回の論点ではないので、ここでは直接記述
  const passwordToConfirmErrorMessage =
    password === passwordToConfirm ? "" : "Passwords do not match.";

  // 入力エラーの有無(Submitボタンの活性/非活性に利用)
  const hasError = [
    // 未入力チェック
    !email,
    !password,
    !passwordToConfirm,
    // エラーチェック
    passwordToConfirmErrorMessage,
  ].some((b) => b);

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    onSubmit({ email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <TextInput
          label="Your Email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
      </div>
      <div>
        <TextInput
          label="Your Password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      <div>
        <TextInput
          label="Confirm Password"
          type="password"
          value={passwordToConfirm}
          onChange={(e) => setPasswordToConfirm(e.target.value)}
          error={passwordToConfirmErrorMessage}
        />
      </div>
      <Button type="submit" disabled={hasError}>
        Sign Up
      </Button>
      {signUpErrorMessage && <Alert color="red">{signUpErrorMessage}</Alert>}
    </form>
  );
}

このコードに対してCopilot Chatでテストを生成してもらうと、以下のようなテストコードが生成されました。

// import文は省略

describe("SignUpForm", () => {
  let mockSubmit: jest.Mock<void, [{ email: string; password: string }]>;

  beforeEach(() => {
    mockSubmit = jest.fn();
  });

  it("フォームのレンダリング", () => {
    const { getByLabelText } = render(<SignUpForm onSubmit={mockSubmit} />);
    expect(getByLabelText(/your email/i)).toBeInTheDocument();
    expect(getByLabelText(/your password/i)).toBeInTheDocument();
    expect(getByLabelText(/confirm password/i)).toBeInTheDocument();
  });

  it("パスワードが一致しない場合、送信されない", async () => {
    const { getByLabelText, getByText } = render(<SignUpForm onSubmit={mockSubmit} />);
    fireEvent.change(getByLabelText(/your email/i), {
      target: { value: "test@example.com" },
    });
    fireEvent.change(getByLabelText(/your password/i), { target: { value: "password" } });
    fireEvent.change(getByLabelText(/confirm password/i), {
      target: { value: "different" },
    });

    fireEvent.click(getByText(/sign up/i));

    await waitFor(() => {
      expect(mockSubmit).not.toHaveBeenCalled();
    });
  });

  it("パスワードが一致する場合、送信される", async () => {
    const { getByLabelText, getByText } = render(<SignUpForm onSubmit={mockSubmit} />);
    fireEvent.change(getByLabelText(/your email/i), {
      target: { value: "test@example.com" },
    });
    fireEvent.change(getByLabelText(/your password/i), { target: { value: "password" } });
    fireEvent.change(getByLabelText(/confirm password/i), {
      target: { value: "password" },
    });

    fireEvent.click(getByText(/sign up/i));

    await waitFor(() => {
      expect(mockSubmit).toHaveBeenCalledWith({
        email: "test@example.com",
        password: "password",
      });
    });
  });
});

画面のDOM構造や入力項目のラベル文字列に強く依存したテストが生成されているため、画面レイアウトやラベル文字列が変更されるとテストコードも修正が必要になります。 また、このテストコードを参考にしながらロジックに対してテスト駆動開発をしようとすると、テストコードを書くときにビューのDOM構造を意識する必要があるため、 画面デザインが先に決まっていないとロジックの開発も進めづらいという問題があります。

テスト対象コンポーネント(After)

次に、ロジックとビューを分離したコンポーネントのコードを示します。 同一ファイルの中で、コンポーネントのロジックであるカスタムフック関数と、ビューを描画する関数に分割します。

// import文は省略

export type ComponentProps = {
  onSubmit: (data: { email: string; password: string }) => void;
  signUpErrorMessage?: string;
};

export function useComponent(props: ComponentProps) {
  const { onSubmit } = props;
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [passwordToConfirm, setPasswordToConfirm] = useState("");

  // パスワード再入力不一致のエラーメッセージ
  const passwordToConfirmErrorMessage =
    password === passwordToConfirm ? "" : "Passwords do not match.";

  // 入力エラーの有無(Submitボタンの活性/非活性に利用)
  const hasError = [
    // 未入力チェック
    !email,
    !password,
    !passwordToConfirm,
    // エラーチェック
    passwordToConfirmErrorMessage,
  ].some((b) => b);

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    onSubmit({ email, password });
  };

  return {
    email,
    setEmail,
    password,
    setPassword,
    passwordToConfirm,
    setPasswordToConfirm,
    passwordToConfirmErrorMessage,
    hasError,
    handleSubmit,
  };
}

type ComponentHookReturnType = ReturnType<typeof useComponent>;

function ComponentView({
  props,
  hook,
}: {
  props: ComponentProps;
  hook: ComponentHookReturnType;
}) {
  const { signUpErrorMessage } = props;
  const {
    email,
    setEmail,
    password,
    setPassword,
    passwordToConfirm,
    setPasswordToConfirm,
    passwordToConfirmErrorMessage,
    hasError,
    handleSubmit,
  } = hook;

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <TextInput
          label="Your Email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />
      </div>
      <div>
        <TextInput
          label="Your Password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      <div>
        <TextInput
          label="Confirm Password"
          type="password"
          value={passwordToConfirm}
          onChange={(e) => setPasswordToConfirm(e.target.value)}
          error={passwordToConfirmErrorMessage}
        />
      </div>
      <Button type="submit" disabled={hasError}>
        Sign Up
      </Button>
      {signUpErrorMessage && <Alert color="red">{signUpErrorMessage}</Alert>}
    </form>
  );
}

// 親コンポーネントから呼ばれる、このコンポーネントのエントリポイント関数
export default function SignUpForm(props: ComponentProps) {
  const hook = useComponent(props);
  return <ComponentView hook={hook} props={props} />;
}

このコードのカスタムフック関数である useComponent に対してCopilot Chatでテストを生成してもらうと、以下のようなテストコードが生成されました。

// import文は省略

import {
  useComponent,
  ComponentProps,
} from "@/components/features/file-upload/elements/SignUpFormAfter";

describe("useComponent", () => {
  let props: ComponentProps;
  let onSubmitMock: jest.Mock;

  beforeEach(() => {
    onSubmitMock = jest.fn();
    props = {
      onSubmit: onSubmitMock,
    };
  });

  it("初期値が正しい", () => {
    const { result } = renderHook(() => useComponent(props));
    expect(result.current.email).toBe("");
    expect(result.current.password).toBe("");
    expect(result.current.passwordToConfirm).toBe("");
  });

  it("email, password, passwordToConfirmが更新される", () => {
    const { result } = renderHook(() => useComponent(props));
    act(() => {
      result.current.setEmail("test@example.com");
      result.current.setPassword("password");
      result.current.setPasswordToConfirm("password");
    });
    expect(result.current.email).toBe("test@example.com");
    expect(result.current.password).toBe("password");
    expect(result.current.passwordToConfirm).toBe("password");
  });

  it("passwordToConfirmErrorMessageが設定される", () => {
    const { result } = renderHook(() => useComponent(props));
    act(() => {
      result.current.setPassword("password");
      result.current.setPasswordToConfirm("password2");
    });
    expect(result.current.passwordToConfirmErrorMessage).toBe(
      "Passwords do not match."
    );
  });

  it("hasErrorが設定される", () => {
    const { result } = renderHook(() => useComponent(props));
    act(() => {
      result.current.setEmail("");
      result.current.setPassword("");
      result.current.setPasswordToConfirm("");
    });
    expect(result.current.hasError).toBe(true);
  });

  it("handleSubmitでonSubmitが呼ばれる", async () => {
    const { result } = renderHook(() => useComponent(props));
    act(() => {
      result.current.setEmail("test@example.com");
      result.current.setPassword("password");
    });
    act(() => {
      result.current.handleSubmit({ preventDefault: jest.fn() } as any);
    });
    expect(onSubmitMock).toHaveBeenCalledWith({
      email: "test@example.com",
      password: "password",
    });
  });
});

ロジックのみを抽出したことによってフックの戻り値の各変数に対してのテストとなり、画面のDOM構造に依存しなくなったためテストコードの保守性が高まりました。 AIが生成したテストケースだけでは入力条件の網羅性は十分ではないものの、生成されたコードを参考にして入力値を変えるだけならば、イチからテストコードを書くよりもずっと容易にテストケースを追加できます。

テスト駆動で機能追加

さて、ここで新たな仕様として以下の機能を追加することを考えます。

  • メールアドレスの入力値がメールアドレス形式でない場合はエラーメッセージを表示する。
  • パスワードは8文字以上、英数記号の全てを含むことを必須とし、満たさない場合はエラーメッセージを表示する。

まず、フックの戻り値としてエラーメッセージ用の変数を追加し、固定値として空文字を返すようにしておきます(これだけ先に書いておかないとテストコードが型チェックにひっかかるため)。

// useComponent関数の戻り値
return {
  email,
  setEmail,
  emailErrorMessage: "", // 追加
  password,
  setPassword,
  passwordErrorMessage: "", // 追加
  passwordToConfirm,
  setPasswordToConfirm,
  passwordToConfirmErrorMessage,
  hasError,
  handleSubmit,
};

次に、テストコードを追加します。 参考にすべきテストコードが既にある状態なので、テストケースの追加は容易です。 簡単なので自分でコーディングしても良いし、AIに書いてもらうこともできるでしょう。 筆者が以下のサンプルコードを書くときは自分で書き始めましたが、GitHub Copilotによる補完が良い感じに効いていました。 追加するテストケースの例を以下に示します。

it("Emailフォーマット正常", () => {
  const { result } = renderHook(() => useComponent(props));
  act(() => {
    result.current.setEmail("test@example.com");
  });
  expect(result.current.emailErrorMessage).toBe("");
});

it("Emailフォーマットエラー", () => {
  const { result } = renderHook(() => useComponent(props));
  act(() => {
    result.current.setEmail("invalid-email-format");
  });
  expect(result.current.emailErrorMessage).toBe("Invalid email address format.");
});

it("パスワードが基準を満たす", () => {
  const { result } = renderHook(() => useComponent(props));
  act(() => {
    result.current.setPassword("P@ssw0rd");
  });
  expect(result.current.passwordErrorMessage).toBe("");
});

it("パスワードが基準を満たさない(長さ)", () => {
  const { result } = renderHook(() => useComponent(props));
  act(() => {
    result.current.setPassword("P@ssw0r");
  });
  expect(result.current.passwordErrorMessage).toBe(
    "Password must be at least 8 characters, and contain upper-case and lower-case alphabet, and number."
  );
});

it("パスワードが基準を満たさない(数字なし)", () => {
  const { result } = renderHook(() => useComponent(props));
  act(() => {
    result.current.setPassword("SimpleLongPassword!");
  });
  expect(result.current.passwordErrorMessage).toBe(
    "Password must be at least 8 characters, and contain upper-case and lower-case alphabet, and number."
  );
});

it("パスワードが基準を満たさない(使えない文字を含む)", () => {
  const { result } = renderHook(() => useComponent(props));
  act(() => {
    result.current.setPassword("P@ss W0rd");
  });
  expect(result.current.passwordErrorMessage).toBe(
    "Password must be at least 8 characters, and contain upper-case and lower-case alphabet, and number."
  );
});

この時点では、エラーメッセージが固定値なのでテストはもちろん通りません。 フックのロジックを修正して、メールアドレスの入力値がメールアドレス形式でない場合はエラーメッセージを表示するようにします。 たとえば以下のように実装してみました。

// ====== サンプルコードのため同一ファイルに記述 ======
function isValidPassword(password: string) {
  // !から~までの文字コードからなる8文字以上で、大文字、小文字、数字をそれぞれ1つ以上含む
  const conditions = [/^[!-~]{8,}$/, /[A-Z]/, /[a-z]/, /\d/];
  return conditions.every((regex) => regex.test(password));
}

function isValidEmail(email: string) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// ======================================================

export type ComponentProps = {
  onSubmit: (data: { email: string; password: string }) => void;
  signUpErrorMessage?: string;
};

export function useComponent(props: ComponentProps) {
  const { onSubmit } = props;
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [passwordToConfirm, setPasswordToConfirm] = useState("");

  // メールアドレスのエラーメッセージ
  const emailErrorMessage = isValidEmail(email) ? "" : "Invalid email address format.";
  // パスワードのエラーメッセージ
  const passwordErrorMessage = isValidPassword(password)
    ? ""
    : "Password must be at least 8 characters, and contain upper-case and lower-case alphabet, and number.";

  const passwordToConfirmErrorMessage =
    password === passwordToConfirm ? "" : "Passwords do not match.";

  const hasError = [
    // 未入力チェック
    !email,
    !password,
    !passwordToConfirm,
    // エラーチェック
    emailErrorMessage,
    passwordErrorMessage,
    passwordToConfirmErrorMessage,
  ].some((b) => b);

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    onSubmit({ email, password });
  };

  return {
    email,
    setEmail,
    emailErrorMessage,
    password,
    setPassword,
    passwordErrorMessage,
    passwordToConfirm,
    setPasswordToConfirm,
    passwordToConfirmErrorMessage,
    hasError,
    handleSubmit,
  };
}

// ビューの描画関数は省略

これでテストが通るようになり、テスト駆動で機能追加ができました。

まとめ

本記事では、Reactのテスト駆動開発に再挑戦するための方針と、生成AIを活用した実践例を紹介しました。 生成AIを活用してテストコード作成の負担を軽減することでテスト駆動開発を導入するハードルが低くなり、 品質や保守性の高いプロダクトコードおよびテストコードを維持しやすくなることが期待できます。 まだ試行し始めたばかりですが、この方法でプロダクトリリースまで回してみたら、改めて振り返りの記事も書いてみたいと思います。