フロントエンドのテストコードを書くときに大切にしていること

フロントエンドのテストコードを書くときに大切にしていること

こんにちは、フロントエンドエキスパートチームの @mugi_unoです!

kintone では フロントエンドの刷新プロジェクト(通称フロリア)が進行中です。

blog.cybozu.io

kintone の開発では E2E 主体の自動テストを整備していましたが、 フロントエンドの刷新に合わせて、新たにフロントエンド側でのテストコードを積極的に書いています。

テストを書くことに不慣れなメンバーもいるため、日々 Pull Request 上でのレビューやペア・モブ作業を通じて、知見の共有が行われています。今回はフロントエンド刷新のテストを書いてきた中から、筆者が有用だと感じた知見やノウハウを紹介したいと思います。

目次

前提

フロントエンド刷新では主に React と Testing Library を利用しており、記事内で紹介するサンプルコードについても同様の技術スタックを前提としています。

React と Testing Library を利用したフロントエンドでのテスト実装例を知りたい場合は、以前に @nus3_ が投稿した記事が参考になるため、併せてご覧頂ければと思います。

blog.cybozu.io

💡「実はそれ最初からパスしてるかもしれない」

テストコード上のアサーションの実行タイミングによっては、「実はちゃんと検証できていなかった」というケースが起こりえます。

📝 例: ダイアログ上での保存失敗時のエラー表示を確認したいテストコード

test("保存失敗時にエラーを表示する", async () => {
  // ダイアログを開く
  await userEvent.click(screen.getByRole("button", { name: "open" }));

  // 保存操作をする
  await userEvent.click(screen.getByRole("button", { name: "save" }));

  // エラー表示を確認
  expect(screen.getByRole("alert")).toBeInTheDocument();
});

一見すると問題なさそうです。

しかし、最後に alert の表示を検証していますが、これが本当に保存操作によって表示されたものかの判別がつきません。

仮に "ダイアログ表示時にエラーが起きても alert が表示されて操作が続行できる" という挙動だった場合、"保存操作をする" の段階でのエラー表示実装が漏れていたとしても、このテストはパスしてしまいます。

test("保存失敗時にエラーを表示する", async () => {
  // ダイアログを開く
  await userEvent.click(screen.getByRole("button", { name: "open" }));

  // ★ 実はこの時点でエラーが表示されている ★

  // 保存操作をする
  await userEvent.click(screen.getByRole("button", { name: "save" }));

  // ★ 実は保存操作ではエラー実装が漏れている ★

  // エラー表示を確認
  expect(screen.getByRole("alert")).toBeInTheDocument();

  // ★ 最初に表示されたエラーが存在するためテストはパスする ★
});

期待する操作で期待する結果になることを厳密に検証する

期待結果を引き起こす操作が明確である場合、その操作の前段階で、期待結果と反転した状態であることを検証しておくと確実です。

今回の例であれば、エラー表示を引き起こすきっかけは "保存操作" ですので、その直前のタイミングでエラーの表示がないことを確認するのがよいでしょう。

📝 保存操作によってエラーが表示されることを厳密に検証した場合

test("保存失敗時にエラーを表示する", async () => {
  // ダイアログを開く
  await userEvent.click(screen.getByRole("button", { name: "open" }));

  // ★ エラー表示がないことを確認 ★
  expect(screen.getByRole("alert")).not.toBeInTheDocument();

  // 保存操作をする
  await userEvent.click(screen.getByRole("button", { name: "save" }));

  // エラー表示を確認
  expect(screen.getByRole("alert")).toBeInTheDocument();
});

他のテストケースによって前提条件を担保する

上記は単一のテストケース内で閉じた場合の例となりますが、前段階の状態を他のテストケースの存在によって担保することもできます。

仮に次のようなテストケースが別途存在していれば問題ないと判断できそうです。

test("ダイアログを開ける", async () => {
  // ダイアログを開く
  await userEvent.click(screen.getByRole("button", { name: "open" }));

  // エラー表示がなくダイアログが表示される
  expect(screen.getByRole("alert")).not.toBeInTheDocument();
  expect(screen.getByRole("dialog")).toBeInTheDocument();
});

この問題の厄介なところは、テストが普通にパスするため非常に気付きづらい点です。

体感ですが、単一のテストケースが巨大だったり、セットアップ処理が複雑だったりするとより発生しやすい傾向にあると思います。テストコードの作成・レビュー時に気を払うとともに、テストコード自体が整理された状態を維持しておくのも根本的な対策のひとつかもしれません。

💡「テストコード上のロジックを避ける」

テストコード上では、if for のような制御構文や複雑な関数制御といった、独自のロジック制御はできるだけ控えています。テストコード上にロジックが入ってきた場合、「そのロジックが期待通りかは誰が検証するのか?」という問題が生じます。

そのため、特別な理由がない限りはテストコード上からはロジックを排除しています。

実際に Pull Request 上で議論されたコード例を紹介します。

📝 例: ドロップダウン内の選択肢が期待通り表示されるかを確認するテストコード

const options = screen.getAllByRole("option");

for (let index = 0; index < 3; index++) {
  expect(options[index]).toHaveTextContent(items[index].name);
}

画面上から option を取得し items 配列の同じ index の name が表示されていることを確認しています。しかし、この for 構文に書かれた初期値やループ条件が誤っていた場合、意図せずテストがパスする恐れがあります。

仮に index < 3index > 3 に Typo すると、何も検証されずにすり抜けてしまいますね。

ロジックを避けて期待結果をそのまま書く

変更後の形では、ロジックでの検証は避け愚直に期待結果を並べる形にしました。

📝 ロジックを排除した場合

const options = screen.getAllByRole("option");
expect(options.length).toBe(3);
expect(options[0]).toHaveTextContent("user1");
expect(options[1]).toHaveTextContent("user2");
expect(options[2]).toHaveTextContent("user3");

もちろん一概に禁止すればいいわけではありません。仮に「10,000 件の表示を全件確認したい」というテストが必要になると、さすがに 10,000 件のアサーションを並べるのは厳しいものがあります。

基本的にはロジックは回避する方向で考え、著しく可読性に影響を与えてしまうようなケースなどでは許容していく形がよいかもしれません。

※ただ実際には、まず 10,000 を定数として定義し、テスト時には仮の小さい値に差し替えて検証できないかも検討できそうです。

参考: テストコードの期待値はDRYを捨ててベタ書きする ~テストコードの重要な役割とは?~ - Qiita

💡「"正しい"とは何か」

これはテストのノウハウとして言われることも多いため「知ってる〜!」という方もいるかもしれません。

テストタイトルに、安易に "正しい" という言葉を使うのは避けるようにしています。

少し極端な例ですが、次のコードを見てみます。

📝 例: ラベル上の日付文字列の表示を確認するテストコード

test("正しく表示される", async () => {
  expect(screen.getByRole("label")).toHaveTextContent("2020-01-02");
});

label 上に日付が文字列で 2020-01-02 と "正しく" 表示されているかを見ています。書いた当人の書いた時点では仕様も把握しているため、このテストコードでも問題ないかもしれません。

では半年後、CI 上でこのテストコードが Fail したとします。

その時にこのテストコードに直面すると、様々なことを考える必要が生じます。

  • どこに表示されるのが "正しい" のか
    • ラベルに表示されるっぽいが、表示箇所は本当にラベルでよいのか
  • 何が表示されるのが "正しい" のか
    • もとの期待結果は日付っぽいが、本当にここは日付なのか
    • 日付だとしても、フォーマットはこれであってるのか
  • そもそもテストコードは本当に "正しい" のか
    • もともと間違ったテストコードでパスしてて、本体側の修正で正しく落ちるようになった可能性はないか?

対処するには、"正しい" の正体を追うための調査が発生しそうです。

"正しい" という言葉を避け具体的に期待結果を記述する

テストコードが価値を発揮する大きいタイミングの1つが Fail したときです。その際に適切なテストタイトルが付与されていることで、より開発を加速させる手助けをしてくれるようになります。

テストタイトルには誰が見てもわかる形で検証内容を具体的に記述しておきましょう。

📝 テストタイトルを具体化した場合

test("ラベル上に日付がYYYY-MM-DD形式で表示される", async () => {
  expect(screen.getByRole("label")).toHaveTextContent("2020-01-02");
});

💡「テストタイトルには期待するふるまいを書く」

テストタイトルでは "期待するふるまい" を言語化して書くようにしています。

例を参考に考えてみましょう。

📝 例: 特定の画面での保存機能のコンポーネントについてのテストタイトル

test.todo("id=save のボタンをクリックすると validate メソッド実行後に onSave ハンドラが実行される");

実装の詳細な Input/Output がそのままテストタイトルとして記述されています。利用箇所が限定的で再利用される可能性が低く、明確な仕様が存在するのであれば、このテストタイトルは少し実装に踏み込みすぎているかもしれません。

リファクタした結果 id 以外での要素取得が適切と判断されたり、メソッド名が別の名称に変更されることはよくあります。結果としてテストが Fail するようになりますが、その際にテストタイトルも追従して変更していく必要があります。(タイトル側の変更が漏れて内容と矛盾することもあります)

実装の詳細ではなく、期待するふるまいを言語化してテストタイトルに付与してみます。

📝 期待するふるまいを記述したテストタイトル

test.todo("保存ボタンをクリックすると、バリデーション後に保存用ハンドラが実行される");

すべての変更を見越したテストタイトルを付与することはできませんが、少なくとも、実装都合で内部を変更しても、このテストタイトルが影響を受けることはありません。

実装の詳細が期待するふるまいと一致するパターン

一方で、テスト対象の位置付けといった前提条件が異なると、期待するふるまい自体が "onSave ハンドラが実行される" のような実装の詳細と一致し、そのままテストタイトルに入るほうが妥当なケースも考えられます。

たとえば、汎用的なコンポーネントでドキュメントとの整合性を保つ必要がある場合では、"onSave" という名称のコールバックが呼ばれること自体が大きな意味を持ちます。

すべてにおいて実装詳細を避けて日本語でそれっぽく書けば OK ではないのが難しいところです。

テスト対象はどういった役割であり、どういった挙動を期待しているのかを考えてテストタイトルを付与していくと、本質とは異なる部分での変更に耐えやすいテストコードになるかと思います。

💡「Testing Library のセレクタをラベリングして整理する」

Testing Library を利用すると、アクセシビリティを考慮しつつ、期待するふるまいに沿った形での要素取得や操作が可能です。

しかし、テスト対象の粒度が大きい場合、Testing Library を利用したセレクタ記述が複雑化していく傾向にあります。

フロリアの Mira チームの例では、QA が作成した試験のシナリオを Integration Test という形でテストコード化していますが、これにはページ全体の描画が必要になり、細部の操作のためにネストの深い要素取得が繰り返し発生します。

実際に直近で頻発した例を見てみます。

📝 例: ダイアログ上で項目を編集・保存するテストコード

テスト対象の機能は次のような特徴があります。

  • テーブルスタイルでデータが一覧表示される
  • 各行に編集ボタンが存在し、クリックすると編集用のダイアログが開く
  • ダイアログ上では複数の項目を編集できる
  • ダイアログ上の保存ボタンをクリックすると保存できる

一方テストコードでは次の確認を行います。

  • ダイアログ上で "名前" を編集し保存できる
  • ダイアログ上で "住所" を編集し保存できる
test("ダイアログ上で名前を編集し保存できる", async () => {
  const rows = within(screen.get("table")).getAllByRole("row");
  const editButton = within(rows[0]).getByRole("button", { name: "edit" });

  await userEvent.click(editButton);

  const dialog = screen.getByRole("dialog");

  const nameInput = within(dialog).getByLabelText("name");
  await userEvent.type(nameInput, "名前");

  const saveButton = within(dialog).getByRole("button", { name: "save" });

  await userEvent.click(saveButton);
});

test("ダイアログ上で住所を編集し保存できる", async () => {
  const rows = within(screen.get("table")).getAllByRole("row");
  const editButton = within(rows[0]).getByRole("button", { name: "edit" });

  await userEvent.click(editButton);

  const dialog = screen.getByRole("dialog");

  const addressInput = within(dialog).getByLabelText("address");
  await userEvent.type(addressInput, "住所");

  const saveButton = within(dialog).getByRole("button", { name: "save" });

  await userEvent.click(saveButton);
});

少し極端な例ですが、ダイアログ上の一要素に対して入力 → 保存を行うまでに、多くの要素の選択が必要となっており、重複も多いのがわかります。

これだけであればまだ読めますが、似たようなケースがさらに増えてくると、個々のテストケースで特に注目したい点が目立ちづらくなってきます。

Testing Library のセレクタを関数化する

こういったケースに遭遇した場合のひとつの対策として、Mira チームでは頻発する操作を別途関数化して整理しています。

重複していた Testing Library のセレクタが関数で共通化されることで、各テストケース内の記述が簡潔になります。各セレクタの関数名から、何を取得しているかも説明的になります。

また、各テストケースを見比べたときに、差分になっている箇所が小さいため、「このテストで重要な観点はここなんだな」というのが一目でわかりやすくなります。

📝 Testing Library のセレクタを関数化し共通化した場合

const getRowAt = (at: number) => within(screen.get("table")).getAllByRole("row")[at];
const getEditButtonAt = (at: number) => within(getRowAt(at)).getByRole("button", { name: "edit" });
const getDialog = () => screen.getByRole("dialog");
const getSaveButton = () => within(getDialog()).getByRole("button", { name: "save" });

test("ダイアログ上で名前を編集し保存できる", async () => {
  await userEvent.click(getEditButtonAt(0));

  const nameInput = within(getDialog()).getByLabelText("name");
  await userEvent.type(nameInput, "名前");

  await userEvent.click(getSaveButton());
});

test("ダイアログ上で住所を編集し保存できる", async () => {
  await userEvent.click(getEditButtonAt(0));

  const addressInput = within(getDialog()).getByLabelText("address");
  await userEvent.type(addressInput, "住所");

  await userEvent.click(getSaveButton());
});

Testing Library を使ったコードで重複や可読性にお悩みの場合には使えるかもしれません。

なお、これはあくまでもテストコード整理におけるひとつの手段でしかなく、次に挙げるような他の選択肢で解決するほうがよいケースも考えられます。

  • data-testid を付与し Testing Library でショートカットして要素選択できるようにする
  • テストコードを複数ファイルに分割して対象スコープを狭める
  • beforeEach, afterEach などのライブラリが提供するセットアップ機構を使う
  • テスト対象の粒度自体を小さくする

テスト対象やチームの好みなどにも左右される部分のため、一概に「全部セレクタは関数にするんだ!」と決めてしまうのではなく、複数の選択肢を持っておき、もっとも体験の良い書き方を探っていくのが大事な部分かと思います。

💡「テストコードを読むのは誰か」

フロリアならではの視点になりますが、テストコードの設計・実装は QA とエンジニアが深く協力して行っています。

blog.cybozu.io

QA が考える期待結果をあらかじめ エンジニアとすり合わせたうえでテストコードを実装し、かつ QA は Pull Request の内容も確認し、期待する検証が含まれているかをチェックしています。

しかし、QA は品質保証のプロではありますが、日頃から JavaScript でテストコードをバリバリ書いているわけではないため、エンジニアが書いたテストコードを読むためには一定のコストが発生します。

サイボウズの QA チームメンバーは察する能力が高く、事前補足がなくても都度確認を取りつつバッチリ内容を見てくれたりするのですが、それに甘えるのではなく、エンジニア側としてもコード理解を助ける努力はしておきたいところです。

Mira では、QA とすり合わせたテストについては Integration Test あるいは E2E としてテストコードを書いており、その中では、細かめに「何をやってるか?」がわかるようにコードコメントを残すようにしています。

次のコードは、実際の Integration Test のサンプルです (一部掲載用に内容は変更しています)

📝 例: コードコメントを細かめに記述した Integration Test

test("入力文字が空文字だとインクリメンタルサーチが実行されず、検索結果が表示されない", async () => {
  await renderDialog();

  // 初期値を入力し、一度検索する
  const combobox = getSearchCombobox();
  await userEvent.click(combobox);
  await userEvent.type(combobox, "a");
  // インクリメンタルサーチが実行
  expect(api.search).toBeCalledWith("a");
  expect(api.search).toBeCalledTimes(1);
  // 検索結果の表示を確認
  expect(withinSearchResultPopup().getAllByRole("option").length).toBe(6);

  // 入力をクリア
  await userEvent.clear(combobox);

  // インクリメンタルサーチが実行されない
  expect(api.search).toBeCalledTimes(1);
  // 検索結果が表示されない
  expect(screen.queryByTestId("result")).not.toBeInTheDocument();
});

エンジニア視点では「読めばわかることをコメントに書いてる」と気になる箇所かもしれませんが、QA とエンジニアで息を合わせて品質を確保していくほうが大事なので、意図的にコメントを残しています。

後日改めて QA に確認したところ、コメントがありコードの意図がひと目でわかるようになったことで、都度確認するコストが下がり助かっている、との感想を得られたため、一定の効果は出ているようです。

"誰のため・何のためのテストコードなのか?" を掘り下げていくと、品質を得るためには通常とは異なる判断でコードを記述していくことも選択肢としてはあり得る、という学びでした。

まとめ

今回はテストコードに関して実施してきた知見やノウハウを紹介させていただきました。

私達としてもテストコードについてはまだまだ検討の余地があると思っており、日々 QA & エンジニアで試行錯誤しつつ、より良いテストコードを追求しています。

今回ご紹介した内容はあくまでもフロリア内における事例であるため、チーム体制や好みによってはマッチしない部分もあるかもしれませんが、少しでも参考になったのであれば幸いです!