この記事で学ぶこと
#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 は後者、覆面調査員の流儀に全振りした道具です。
なぜ内側を見てはいけないのかは、実際にテストを書いたあとで、リファクタリングを使って実演します。まずは書いて動かします。
🔴 環境を整える(ブラウザなしで 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 を使います)。
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個見つかってしまうのです。
🔴 最初のテスト。探す、操作する、確かめる
テスト対象は、押すといいねが増えるボタンです。
<script setup lang="ts">
import { ref } from "vue";
const count = ref(0);
</script>
<template>
<p>いいね {{ count }} 件</p>
<button @click="count++">いいね</button>
</template>
テストはこうなります。
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 に切り出すリファクタリングをしたとします。
import { ref } from "vue";
export function useLike() {
const count = ref(0);
const add = () => {
count.value++;
};
return { count, add };
}
<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 でイベントを報告します。この「外への報告」もコンポーネントの出力なので、テストで確かめます。
<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>
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つを確かめること。
<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>
解答を見る
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 に検索を投げる部品だったら、テスト中の通信はどうすればいいのか。通信だけを偽物に差し替えて、部品は本物のままテストする仕組みを学びます。
