0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

35日でフロントエンドの基礎を完全に理解する #21 - コンポーネントテスト(Testing Libraryの思想)

0
Posted at

この記事で学ぶこと

#20 で、コンポーネントに埋まったロジックは「ただの関数」に切り出せばテストできる、と学びました。では切り出した残りの部分、つまりコンポーネントそのものはどうでしょう。「ボタンを押したら表示が変わる」はロジックの関数だけでは確かめられません。今回はコンポーネントを丸ごとテストする方法と、その道具 Testing Library が持つ「ユーザーの目線でテストする」という思想を学びます。

  • コンポーネントのテストが何を確かめるものか、関数のテストとの違いを説明できる
  • Testing Library で「探す・操作する・確かめる」の3ステップのテストを書ける
  • 実装の内側を見るテストの何が問題かを、リファクタリングと絡めて説明できる

迷ったら🔴だけ確実に(🔴 覚える、🟡 理屈、⚪ 流す)。

この記事のコードと実行結果は、すべて Node.js 20 + Vitest 4.1 + Vue 3.5 + Testing Library(@testing-library/vue 8.1)+ happy-dom 21 で実際に動かして確認したものです。ブラウザは起動しません。ターミナルだけで最後まで試せます。


🔴 コンポーネントのテストは「入力と出力」が違う

#20 でテストした calcShippingFee は、引数を渡せば戻り値が返る関数でした。コンポーネントにも同じように入力と出力がありますが、その中身が違います。入力は props とユーザーの操作(クリック、文字入力)、出力は画面に表示されるものと、親へ飛ばすイベント(#9 の emit)です。つまりコンポーネントのテストとは、「この操作をしたら、画面がこう変わる(またはこのイベントが飛ぶ)」を確かめることです。

ここで流儀が2つに分かれます。レストランの品質調査にたとえると分かりやすいです。1つは厨房に入って、レシピの手順どおりに作っているかをチェックするやり方。もう1つは覆面調査員として客席に座り、実際に注文して出てきた料理を食べるやり方です。前者は「実装の内側」を、後者は「客に見えるもの」を調べています。

テストも同じです。コンポーネントの内側の変数(count が 1 になったか)を覗く書き方と、ユーザーに見える画面(「いいね 1 件」と表示されたか)を確かめる書き方があります。Testing Library は後者、覆面調査員の流儀に全振りした道具です。

コンポーネントテストの2つの流儀を対比した図。左は実装の内側を見るテストで、LikeButton の内側にある const count = ref(0) や const add = () => { count.value++ } という変数名・関数名をテストが直接覗き、「count は 1 か?」を確認する。何が起きるかとして、変数名を count から likes に変えただけで動きは同じなのにテストが落ちる、逆に画面に表示されたかは見ていないので画面が壊れていてもテストは通ることがある、名前を変えるたびに直すので整理の邪魔になる、を挙げている。右はユーザーの目線のテスト(Testing Library)で、LikeButton の画面というユーザーに見える面に対して、1. getByRole でボタンを探す(ユーザーが目で見つけるのと同じ探し方)、2. fireEvent.click で押す(ユーザーの操作を再現する)、3. 「いいね 1 件」が画面に出たか expect(ユーザーに見える結果だけを確かめる)、という3ステップを踏む。内側を書き換えても画面の仕様が同じなら緑のまま、テストが落ちた=ユーザーに見える動きが壊れたという意味になる、という利点を挙げている

なぜ内側を見てはいけないのかは、実際にテストを書いたあとで、リファクタリングを使って実演します。まずは書いて動かします。

🔴 環境を整える(ブラウザなしで DOM を動かす)

コンポーネントのテストには1つ課題があります。コンポーネントは DOM(#1)に描画されて初めて意味を持つのに、テストは Node.js で動くのでブラウザがありません。これを埋めるのが happy-dom で、**DOM の動きだけを JavaScript で再現した、軽量な「書き割りのブラウザ」**です。画面は描きませんが、要素を作る・クリックイベントを飛ばすといった DOM の挙動はひととおり動きます。

#20 の Vitest に3つ追加します。

# happy-dom: ブラウザなしでDOMを再現する環境
# @testing-library/vue: ユーザー目線でコンポーネントをテストする道具
npm install -D vitest happy-dom @testing-library/vue

設定ファイル vitest.config.ts をプロジェクトのルートに作ります(Vue の部品をテスト中に変換するため、#6 の Vite プロジェクトに入っている @vitejs/plugin-vue を使います)。

vitest.config.ts
import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: "happy-dom", // テスト中のDOMを happy-dom が提供する
    globals: true,            // 後述。テスト後の自動後片付けに必要
  },
});

globals: true は省略しがちですが必須です。私は最初これを入れずに実行して、2つ目のテストが「Found multiple elements with the role "button"」という謎のエラーで落ちました。Testing Library はテストが終わるたびに描画した DOM を自動で片付けるのですが、その仕掛けの登録に globals: true が要ります。無いと前のテストの画面が残ったまま次の render が積み重なり、同じボタンが2個見つかってしまうのです。

🔴 最初のテスト。探す、操作する、確かめる

テスト対象は、押すといいねが増えるボタンです。

src/components/LikeButton.vue
<script setup lang="ts">
import { ref } from "vue";

const count = ref(0);
</script>

<template>
  <p>いいね {{ count }} 件</p>
  <button @click="count++">いいね</button>
</template>

テストはこうなります。

src/components/LikeButton.test.ts
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/vue";
import LikeButton from "./LikeButton.vue";

describe("LikeButton", () => {
  it("ボタンを押すと、いいねの数が1増える", async () => {
    render(LikeButton);

    expect(screen.getByText("いいね 0 件")).toBeTruthy();

    await fireEvent.click(screen.getByRole("button", { name: "いいね" }));

    expect(screen.getByText("いいね 1 件")).toBeTruthy();
  });
});

流れは3ステップです。render(LikeButton) がコンポーネントを happy-dom の画面に描画します。screen.getByRole("button", { name: "いいね" }) が「いいね」というラベルのボタンをユーザーが目で探すのと同じやり方で探します。fireEvent.click(...) がクリックを再現します。そして最後に getByText で、画面の文言が「いいね 1 件」に変わったことを確かめます。内側の count には一切触っていません。見ているのは画面だけです。

2つ補足します。1つ目、getByText や getByRole は、見つからなければその場でエラーを投げてテストを落とします。だから答え合わせは toBeTruthy()(見つかった要素が存在する)で十分です。2つ目、fireEvent.click に await が付いています。#7 で学んだとおり、Vue は状態の変化を即座には画面に反映せず、まとめて効率よく反映します。await を忘れるとクリック直後のまだ古い画面を見てしまうので、操作には必ず await を付けると覚えてください。

実行します。

 ✓ src/components/LikeButton.test.ts > LikeButton > ボタンを押すと、いいねの数が1増える 22ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

わざと壊してみます。<button @click="count++"> から @click="count++" を消して(ボタンを置いただけで何も起きない状態にして)実行すると、こう落ちます。

 FAIL  src/components/LikeButton.test.ts > LikeButton > ボタンを押すと、いいねの数が1増える
TestingLibraryElementError: Unable to find an element with the text: いいね 1 件.
This could be because the text is broken up by multiple elements. In this case,
you can provide a function for your text matcher to make your matcher more flexible.

Ignored nodes: comments, script, style
<body>
  <div>
    <p>
      いいね 0 件
    </p>
    <button>
      いいね
    </button>
  </div>
</body>

「いいね 1 件が見つからない」に続けて、失敗した瞬間の画面の中身(DOM)をそのまま印刷してくれます。 <p> が「いいね 0 件」のままなのが見えるので、クリックしても数が増えていないと一目で分かります。#20 の「期待値と実際の差分」に加えて、コンポーネントのテストでは「そのときの画面」まで見せてくれるわけです。

🟡 なぜ内側を見ないのか。リファクタリングで実演する

図の左側、「内側を見るテスト」の何が問題かを実演します。いま LikeButton のロジックを、#11 で学んだ composable に切り出すリファクタリングをしたとします。

src/components/useLike.ts
import { ref } from "vue";

export function useLike() {
  const count = ref(0);
  const add = () => {
    count.value++;
  };
  return { count, add };
}
src/components/LikeButton.vue
<script setup lang="ts">
import { useLike } from "./useLike";

const { count, add } = useLike();
</script>

<template>
  <p>いいね {{ count }} 件</p>
  <button @click="add">いいね</button>
</template>

コンポーネントの中身は大きく変わりました。ref は composable の中へ移り、クリックの処理は count++ から add という関数呼び出しに変わっています。でもユーザーに見える動き(押したら1増える)は同じです。この状態でさきほどのテストを実行すると、書き換えなしでそのまま通ります(実際に通ることを確認済みです)。

もしテストが「内側の count という変数が 1 になるか」を覗く書き方だったら、どうなっていたでしょう。count の持ち主が composable に変わった時点で、動きは何も壊れていないのにテストは落ちます。すると「リファクタリングのたびにテストを直す」羽目になり、テストが整理の邪魔者になります。さらに悪いことに、内側の変数だけ見るテストは「その値が本当に画面に表示されたか」を確かめていないので、template 側のバグは素通りします。

Testing Library のテストが守っているのはユーザーに約束した仕様であって、実装の書き方ではありません。だから中身をどれだけ書き換えても、仕様が守られている限り緑のままです。逆に落ちたときは「ユーザーに見える動きが壊れた」という意味になります。この性質があるから、#20 の最後に書いた「怖がらずにコードを直せる」がコンポーネントにも効くのです。

🟡 要素の探し方には優先順位がある

Testing Library には要素を探すクエリが何種類もありますが、どれを使ってもいいわけではなく、推奨の優先順位があります。

優先度 クエリ 探し方
1 getByRole("button", { name: "検索" }) 役割とラベルで探す(ボタン、見出し、入力欄など)
2 getByLabelText("キーワード") フォームの <label> の文言で入力欄を探す
3 getByText("いいね 0 件") 表示されている文言で探す
最後の手段 getByTestId("like-area") テスト用に埋め込んだ目印で探す

上に行くほど「ユーザーの探し方」に近いから、という理由です。ユーザーは「検索と書いてあるボタン」を目で探して押します。getByRole はそれと同じ探し方です。role(役割)という言葉は耳慣れないかもしれませんが、<button> なら button、<input> なら textbox のように、HTML の要素が持つ「何者か」の情報のことです。スクリーンリーダー(画面を音声で読み上げる支援技術)も同じ情報を頼りに画面をたどるので、getByRole で見つけられる画面はスクリーンリーダーでも操作しやすい画面、ということになります。この話は #25 のアクセシビリティ回で本格的に扱います。

もう1つ、getBy と queryBy の違いが重要です。getBy は見つからないと即エラーになるので、「表示されていないこと」の確認には使えません。 無いことを確かめるときは、見つからないと null を返す queryBy を使います。

// エラー文言が「表示されていない」ことを確かめる
expect(screen.queryByText("通信に失敗しました")).toBeNull();

🟡 フォームと emit のテスト

もう1段実務に近い部品として、検索フォームをテストします。#9 で学んだとおり、子の部品は親へ emit でイベントを報告します。この「外への報告」もコンポーネントの出力なので、テストで確かめます。

src/components/SearchForm.vue
<script setup lang="ts">
import { ref } from "vue";

const emit = defineEmits<{ search: [keyword: string] }>();
const keyword = ref("");

function submit() {
  if (keyword.value.trim() === "") return;
  emit("search", keyword.value);
}
</script>

<template>
  <label for="keyword">キーワード</label>
  <input id="keyword" v-model="keyword" />
  <button @click="submit">検索</button>
</template>
src/components/SearchForm.test.ts
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/vue";
import SearchForm from "./SearchForm.vue";

describe("SearchForm", () => {
  it("キーワードを入れて検索ボタンを押すと、search イベントが飛ぶ", async () => {
    const { emitted } = render(SearchForm);

    await fireEvent.update(screen.getByLabelText("キーワード"), "vue");
    await fireEvent.click(screen.getByRole("button", { name: "検索" }));

    expect(emitted().search).toEqual([["vue"]]);
  });
});

新顔は2つです。fireEvent.update(入力欄, "vue") が「入力欄に vue と打つ」操作で、入力欄は getByLabelText でラベルの文言から探しています。そして render の戻り値から取れる emitted() が、この部品が外へ飛ばしたイベントの記録です。emitted().search は「search イベントの発生履歴」の配列で、1回の発生につき引数の配列が1つ入ります。[["vue"]] は「search が1回、引数 "vue" で飛んだ」という意味です。配列なので、比べるのは toBe ではなく toEqual です(#20)。

このテストも実行して通ることを確認済みです。submit 関数の中身や keyword という ref の存在には触れず、「打って、押したら、正しい中身の報告が飛ぶ」という仕様だけを見ていることを確かめてください。

⚪ どこまでをコンポーネントテストでやるか

「ログインして検索して結果ページに遷移する」のような画面をまたぐ流れまでコンポーネントテストでやるべきか、という疑問が出ますが、それは守備範囲の外です。ページをまたぐ一連の流れは本物のブラウザで動かす E2E テスト(#23 の Playwright)の仕事で、コンポーネントテストは「部品1つの仕様」を数十ミリ秒で確かめる層です。粒度の小さいテストほど速くて数を増やせるので、土台は #20 の関数テスト、その上にコンポーネントテスト、てっぺんに少数の E2E、という積み方が定石です。

もう1つ、今回のフォームが本物の API に検索を投げる部品だったら、テスト中の通信はどうするのかという問題が残ります。これが次回のテーマです。


【手を動かす】部品のテストを書いて、リファクタで緑を確かめる

#6 で作った Vite + Vue プロジェクト(@vitejs/plugin-vue 入り)で試します。冒頭の3パッケージを入れ、vitest.config.ts を作ってください。

次に本文の LikeButton.vue と LikeButton.test.ts を src/components/ に置き、実行します。

npx vitest run

1 passed の緑を確認したら、2つの実験をします。

1つ目は壊す実験です。LikeButton.vue の @click="count++" を削って実行し、本文で見た「画面の中身つき」の失敗出力を読んでください。<p>いいね 0 件</p> のまま止まっているのが見えるはずです。確認したら戻します。

2つ目がこの記事の核心、リファクタの実験です。本文の useLike.ts を作り、LikeButton.vue を composable を使う形に書き換えて、テストには一切触れずに再実行してください。緑のまま通ります。「中身を入れ替えたのにテストが保証を続けてくれる」感覚は、一度自分の手で味わうと忘れません。

余力があれば SearchForm も写して、emitted() の中身を console.log(emitted()) で覗いてみてください。イベントがどう記録されているか、形が見えて理解が固まります。


確認テスト

知識問題

Q1. コンポーネントの内側の変数を直接覗くテスト(例:「count が 1 になったか」)には、どんな問題がありますか。2つ挙げて説明してください。

解答を見る

1つ目は、リファクタリングに弱いことです。変数をリネームしたり、ロジックを composable に切り出したりすると、ユーザーに見える動きは何も変わっていないのにテストが落ちます。すると整理のたびにテストを直すことになり、テストがリファクタリングの邪魔者になります。

2つ目は、肝心の「画面に表示されたか」を確かめていないことです。内側の count が 1 になっても、それを template が正しく表示しているかは別問題です。template 側のバグはこのテストを素通りします。

ユーザーに見えるもの(画面の文言、ボタン、飛んだイベント)だけで書いたテストなら、この2つの問題は両方起きません。中身を書き換えても仕様が同じなら緑のままで、落ちたときは「ユーザーに見える動きが壊れた」ことを意味します。これが Testing Library の思想です。

Q2. 「エラーメッセージが表示されていないこと」を確かめたいとき、getByText と queryByText のどちらを使いますか。理由もあわせて説明してください。

解答を見る

queryByText を使います。 getByText は要素が見つからない時点でエラーを投げてテストを落とすので、「無いこと」を期待値にできません。queryByText は見つからないと null を返すだけなので、expect(screen.queryByText("通信に失敗しました")).toBeNull() のように「無いこと」をそのまま答え合わせできます。

使い分けの感覚は「あるはずのものは getBy(無ければ即落ちてくれるのが便利)、無いはずのものは queryBy」です。

Q3. fireEvent.click(...) には await を付けます。付け忘れるとテストがどう壊れますか。理由から説明してください。

解答を見る

クリックで状態は変わったのに、まだ書き換わる前の古い画面に対して答え合わせをしてしまい、テストが誤って落ちます(または逆に、壊れているのに通る偽の緑になり得ます)。

Vue は状態が変わった瞬間に DOM を書き換えるのではなく、変化をまとめて少しあとに反映します(#7 で見た「計算結果はすぐ、反映は少しあと」の性質です)。fireEvent の戻り値はこの反映を待つ Promise なので、await することで「画面が書き換わったあと」に次の行へ進めます。操作には必ず await を付ける、で覚えてください。

実技問題

Q4. 次のコンポーネントのテストを書いてください。「最初は本文が表示されていない」「ボタンを押すと本文が表示される」「もう一度押すと本文が隠れる」の3つを確かめること。

src/components/SpoilerBox.vue
<script setup lang="ts">
import { ref } from "vue";

const open = ref(false);
</script>

<template>
  <button @click="open = !open">
    {{ open ? "ネタバレを隠す" : "ネタバレを見る" }}
  </button>
  <p v-if="open">犯人は執事です</p>
</template>
解答を見る
src/components/SpoilerBox.test.ts
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/vue";
import SpoilerBox from "./SpoilerBox.vue";

describe("SpoilerBox", () => {
  it("最初はネタバレ本文が表示されていない", () => {
    render(SpoilerBox);

    expect(screen.queryByText("犯人は執事です")).toBeNull();
  });

  it("ボタンを押すと本文が表示され、ボタンの文言が変わる", async () => {
    render(SpoilerBox);

    await fireEvent.click(screen.getByRole("button", { name: "ネタバレを見る" }));

    expect(screen.getByText("犯人は執事です")).toBeTruthy();
    expect(screen.getByRole("button", { name: "ネタバレを隠す" })).toBeTruthy();
  });

  it("もう一度押すと本文が隠れる", async () => {
    render(SpoilerBox);

    await fireEvent.click(screen.getByRole("button", { name: "ネタバレを見る" }));
    await fireEvent.click(screen.getByRole("button", { name: "ネタバレを隠す" }));

    expect(screen.queryByText("犯人は執事です")).toBeNull();
  });
});

3つとも実行して通ることを確認済みです。ポイントは3つあります。1つ目、「表示されていない」の確認は queryByText + toBeNull() です。getByText だと見つからない時点でエラーになります。2つ目、2回目のクリックではボタンの文言が「ネタバレを隠す」に変わっているので、getByRole の name もそれに合わせています(文言が変わること自体も仕様なので、2つ目のテストで確かめています)。3つ目、内側の open という ref には一切触れていません。v-if の結果ユーザーに見えるものだけで判定しています。

Q5. 本文の SearchForm には「キーワードが空のままなら search イベントを飛ばさない」という仕様があります(submit の先頭の return)。この仕様を確かめるテストを書いてください。

解答を見る
it("キーワードが空のままなら、search イベントは飛ばない", async () => {
  const { emitted } = render(SearchForm);

  await fireEvent.click(screen.getByRole("button", { name: "検索" }));

  expect(emitted().search).toBeUndefined();
});

実行して通ることを確認済みです。ポイントは2つです。1つ目、何も入力せずにいきなり検索ボタンを押す、という操作でユーザーの行動を再現しています。2つ目、一度も飛んでいないイベントは emitted() の記録にキー自体が存在しないので、emitted().search は undefined になります。だから toBeUndefined() が「飛ばなかったこと」の答え合わせになります。「空配列 [] では?」と思いがちなところが引っかけで、記録はイベントが初めて飛んだときに作られます。

このテストは異常系(#20 でいう「異常な入力」)の守りです。空検索で全件取得が走ってしまう、のような事故を仕様として塞いでいることを機械で保証できます。

まとめ

  • コンポーネントのテストは「この操作をしたら、画面がこう変わる/このイベントが飛ぶ」を確かめる。入力は props と操作、出力は画面と emit
  • Testing Library は覆面調査員の流儀。render で描画し、getByRole などユーザーと同じ探し方で要素を見つけ、fireEvent で操作し、画面に見えるものだけで答え合わせする
  • 内側の変数を覗くテストはリファクタリングで落ち、画面のバグを素通りする。仕様を守るテストは実装を書き換えても緑のまま(composable への切り出しで実演した)
  • クエリは getByRole > getByLabelText > getByText > getByTestId の優先順位。「無いこと」は queryBy + toBeNull()
  • 操作には必ず await。Vue の画面反映は少しあとに来る(#7)
  • 環境は happy-dom + globals: true。後片付けの自動登録を忘れると、前のテストの画面が残って「Found multiple elements」で落ちる

次回は #22 MSWでAPIをモックする です。今日のフォームが本物の API に検索を投げる部品だったら、テスト中の通信はどうすればいいのか。通信だけを偽物に差し替えて、部品は本物のままテストする仕組みを学びます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?