この記事で学ぶこと
今回から Part 5「品質」に入ります。#19 では Storybook で部品の「見た目」を1つずつ確認できるようにしました。今回はもう1つの確認、「ロジックが正しく動くか」を自動化します。このシリーズでは毎回「実際に動かして確認した」と書いてきましたが、その確認を人の目ではなく機械にやらせる仕組み、それがテストコードです。道具には Vitest を使います。
- テストコードが何者で、なぜ手の確認だけでは足りないかを説明できる
- Vitest でテストを書いて実行し、失敗したときの出力を読める
- 「どんな入力をテストすべきか」と「テストしやすいコードの形」を判断できる
迷ったら🔴だけ確実に(🔴 覚える、🟡 理屈、⚪ 流す)。
この記事のコードと実行結果は、すべて Node.js 20 + Vitest 4.1 で実際に動かして確認したものです。ブラウザは使いません。ターミナルだけで最後まで試せます。
🔴 「動くことの確認」は、人がやると必ず漏れる
コードを変更したら、ブラウザで画面を開いて、目で見て確認する。これまで私たちがやってきた確認です。小さいうちはこれで回りますが、アプリが育つと破綻します。理由は単純で、人の確認には3つの癖があるからです。
1つ目、確認は「いま変更した場所」に偏ります。送料の計算を直したら送料の表示は見ますが、同じ関数を使っている別の画面までは見に行きません。2つ目、急いでいるときほど確認を省略します。「この程度の修正なら大丈夫」と思った変更ほど、よく壊れます。3つ目が一番厄介で、前に直した場所がまた壊れたこと(デグレ、退行)に気づく手段がありません。 3か月前に苦労して直したバグは、今日の変更でひっそり復活していても、誰も見張っていないのです。
テストコードは、この確認を機械に任せる仕組みです。学校の計算ドリルを思い浮かべてください。問題と模範解答のセットが先にあって、答案と突き合わせれば誰でも採点できます。テストコードはこの「模範解答つきの採点」をプログラムにしたものです。「この入力ならこの結果になるはず」という期待値を先に書いておき、コードを変更するたびに、機械が全ページを数秒で採点し直します。
図の右側の世界では、確認の量がどれだけ増えても、かかる時間は数秒のままです。そして一度書いた期待値は消えないので、デグレの見張り番がずっと立ち続けてくれます。
🔴 テストコードの正体は「答え合わせの自動化」
まず一番小さいテストを見ます。テストされる側はただの関数です。
export function sum(a: number, b: number): number {
return a + b;
}
これに対するテストコードがこれです。ファイル名は sum.test.ts のように、対象の名前に .test を挟んで付けます。Vitest はこの名前のファイルを自動で見つけて実行します。
import { describe, it, expect } from "vitest";
import { sum } from "./sum";
describe("sum", () => {
it("2つの数を足した結果を返す", () => {
expect(sum(1, 2)).toBe(3);
});
});
登場人物は3つだけです。describe は「何のテストか」をまとめる見出し、it が1つのテスト(「〜する」という文になる名前を付ける)、そして expect が答え合わせ本体です。
核心は expect(sum(1, 2)).toBe(3) の1行です。読み方はそのまま「sum(1, 2) の結果が 3 であることを期待する」。ドリルでいえば、sum(1, 2) が答案で、3 が模範解答です。一致すればテストは通り(緑)、違えば失敗します(赤)。「期待値を先に決めて、実際の結果と突き合わせる」、テストコードがやっているのはこれだけです。
🔴 動かしてみる
Vitest は Vite と同じ作者たちが作っているテスト実行ツールで、TypeScript のファイルをそのまま動かせます。インストールして実行します。
# テスト実行ツール Vitest を開発用の依存として追加する
npm install -D vitest
# テストを1回だけ実行する(run を付けないと監視モードになる。後述)
npx vitest run
実行すると、こう出ます。
RUN v4.1.12 /tmp/f35-20-check
✓ src/utils/sum.test.ts > sum > 2つの数を足した結果を返す 2ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 23:27:57
Duration 172ms (transform 23ms, setup 0ms, import 37ms, tests 3ms, environment 0ms)
✓ が付いて 1 passed。sum(1, 2) の結果が期待値 3 と一致した、つまり採点が全問正解だったという意味です。0.2秒で終わっています。
🔴 テストが光るのは「境界」と「異常な入力」
sum のテストはただの顔見せです。テストが本当に効くのは、人が目で確認しにくい場所、つまり条件が切り替わる境界と、異常な入力です。
「小計3000円以上は送料無料、未満は500円」という、ECサイトによくある仕様を実装します。
export function calcShippingFee(subtotal: number): number {
if (subtotal < 0) {
throw new Error("小計が負の値です");
}
return subtotal >= 3000 ? 0 : 500;
}
この関数で一番壊れやすいのはどこでしょうか。2000円や5000円ではありません。ちょうど3000円です。「以上」を > と書き間違えるミスは本当によくあって、そのとき壊れるのは境界の1点だけ。目視の確認では「3000円ぴったりのカート」をわざわざ作らない限り見逃します。だからテストは境界の両側を狙って書きます。
import { describe, it, expect } from "vitest";
import { calcShippingFee } from "./shipping";
describe("calcShippingFee", () => {
it("3000円未満なら送料500円", () => {
expect(calcShippingFee(2999)).toBe(500);
});
it("ちょうど3000円なら送料無料", () => {
expect(calcShippingFee(3000)).toBe(0);
});
it("0円(空のカート)でも送料500円", () => {
expect(calcShippingFee(0)).toBe(500);
});
it("負の値はエラーを投げる", () => {
expect(() => calcShippingFee(-100)).toThrow("小計が負の値です");
});
});
2999円と3000円。境界を挟んだ2点をテストしておけば、>= と > の取り違えを機械が必ず捕まえます。最後のテストだけ形が違うことにも注目してください。エラーを期待するときは expect(() => ...) と関数で包みます。包まずに書くと、expect に届く前にエラーが投げられてテスト自体が壊れてしまうためです。
本当に捕まえられるのか、わざと壊して確かめます。shipping.ts の >= を > に書き換えて実行すると、こうなります。
❯ src/utils/shipping.test.ts (4 tests | 1 failed) 11ms
× ちょうど3000円なら送料無料 6ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
FAIL src/utils/shipping.test.ts > calcShippingFee > ちょうど3000円なら送料無料
AssertionError: expected 500 to be +0 // Object.is equality
- Expected
+ Received
- 0
+ 500
❯ src/utils/shipping.test.ts:10:35
8|
9| it("ちょうど3000円なら送料無料", () => {
10| expect(calcShippingFee(3000)).toBe(0);
| ^
11| });
12|
Test Files 1 failed | 1 passed (2)
Tests 1 failed | 4 passed (5)
この出力の読み方を押さえてください。どのテストが落ちたか(ちょうど3000円なら送料無料)、期待値は何で(Expected: 0)実際は何だったか(Received: 500)、テストのどの行か(10行目の ^ 印)まで、全部書いてあります。バグの場所を探す前に、もう答えが出ているようなものです。これが「壊した瞬間に場所まで教えてくれる」の正体です。
🟡 toBe と toEqual の使い分け
答え合わせの書き方(マッチャーと呼びます)は toBe 以外にもあり、最初に必ずつまずくのが「オブジェクトや配列の比較」です。次のテストは、直感に反して失敗します。
expect({ name: "みかん" }).toBe({ name: "みかん" });
AssertionError: expected { name: 'みかん' } to be { name: 'みかん' } // Object.is equality
If it should pass with deep equality, replace "toBe" with "toStrictEqual"
Expected: { name: 'みかん' }
Received: serializes to the same string
「見た目は同じ文字列になるのに失敗した」と出力自体が言っています。JavaScript では、オブジェクトの変数に入っているのは中身そのものではなく「本体のありか(参照)」です。toBe はこの参照が同一か(同じ本体を指しているか)を比べるので、別々に作った2つのオブジェクトは中身が同じでも不一致です。中身で比べたいときは toEqual を使います。
expect({ name: "みかん" }).toEqual({ name: "みかん" }); // 通る
使い分けはこう覚えてください。数値・文字列・真偽値は toBe、オブジェクトと配列は toEqual。 よく使うマッチャーを一覧にしておきます。
| マッチャー | 何を確かめるか | 例 |
|---|---|---|
toBe(x) |
値そのものの一致(プリミティブ向け) | expect(1 + 2).toBe(3) |
toEqual(x) |
オブジェクト・配列の中身の一致 | expect(list).toEqual([1, 2]) |
toContain(x) |
配列や文字列に含まれるか | expect(tags).toContain("sale") |
toThrow(msg) |
エラーが投げられるか | expect(() => f(-1)).toThrow() |
toBeNull() など |
null / undefined / 真偽の判定 | expect(result).toBeNull() |
🟡 何をテストするか。「全部」ではない
テストを学ぶと「全パターン書かなければ」と思いがちですが、テストにも書くコスト・直すコストがかかります。凄腕のエンジニアは、壊れたときの被害と壊れやすさで濃淡を付けます。
| 優先度 | 対象 | 例 |
|---|---|---|
| 高 | お金・権限など、間違うと被害が大きい計算 | 送料、割引、消費税、表示可否の判定 |
| 高 | 条件の境界と異常な入力 | ちょうど3000円、0件、負の値、空文字 |
| 中 | 分岐が多く、目視で全分岐を踏めないロジック | 検索条件の組み合わせ、状態の変換 |
| 低 | 書いたとおりにしか動かない部分 | 定数、見た目だけの調整 |
もう1つ大事なのがテストの名前です。it("test1", ...) ではなく「ちょうど3000円なら送料無料」のように、仕様を日本語の文としてそのまま書きます。 こうしておくと、テストが落ちたときに「どの仕様が壊れたか」が一目で分かり、テストファイル自体が仕様書の代わりになります。
🟡 テストしやすいコードは「切り出された」コード
ここまでのテスト対象は全部ただの関数でした。「でも自分のロジックは Vue コンポーネントの中にあるんだけど」と思ったはずです。たとえばこういう書き方です。
<script setup lang="ts">
// 絞り込みロジックがコンポーネントの中に埋まっている
const visibleItems = computed(() =>
items.value.filter((item) => item.stock > 0 && item.price <= budget.value)
);
</script>
この filter の条件が正しいかをテストしたくても、コンポーネントの中に埋まったままでは、コンポーネントごと動かさないと確認できません。そこで、ロジックをただの関数として外に切り出します。
type Product = { name: string; price: number; stock: number };
export function filterAffordable(items: Product[], budget: number): Product[] {
return items.filter((item) => item.stock > 0 && item.price <= budget);
}
コンポーネント側は computed(() => filterAffordable(items.value, budget.value)) と呼ぶだけになり、切り出した関数は今日やってきた方法でそのままテストできます。入力(引数)を渡せば出力(戻り値)が決まる、画面もブラウザも関係ないただの関数だからです。
これは #11 の composables で学んだ「ロジックを UI から引き剥がす」のテスト版です。テストを書き始めると「この設計だとテストしにくい」に必ずぶつかりますが、それは設計への警報でもあります。テストしにくいコードは、たいていロジックと表示が絡まっているコードです。テストのために切り出した結果、コード自体も読みやすくなる。この好循環が、テストが設計を鍛えると言われる理由です。
なお「切り出せない部分」、つまりコンポーネントそのもの(クリックしたら表示が変わる、など)のテストは次回 #21 で扱います。今日の守備範囲は、切り出した先のロジックです。
⚪ 監視モードとテストの置き場所
run を付けずに npx vitest と実行すると監視モードになり、ファイルを保存するたびに関係するテストだけが自動で再実行されます。コードを書きながらテストが緑に変わるのを横目で見る、という開発スタイルができます。package.json の scripts に "test": "vitest" を足しておけば npm test で起動できます。
テストファイルの置き場所は、今回のように対象ファイルの隣に置く流儀と、tests/ ディレクトリにまとめる流儀があります。最近のフロントエンドでは隣に置くのが主流です。実装とテストが並ぶので、どちらかだけ直して片方を忘れる事故が減ります。チームの既存の流儀があればそれに従えば十分です。
【手を動かす】テストを書いて、わざと壊す
Node.js さえあれば試せます。新しいフォルダで始めます。
# 練習用フォルダを作って移動する
mkdir vitest-practice && cd vitest-practice
# package.json を作り、Vitest を開発用依存として入れる
npm init -y
npm install -D vitest
src/utils/ フォルダを作り、本文の shipping.ts と shipping.test.ts をそのまま書いてください。書けたら実行します。
npx vitest run
4つのテストが ✓ で通り、Tests 4 passed (4) と出れば成功です。
次が今日一番大事な体験です。shipping.ts の subtotal >= 3000 を subtotal > 3000 に書き換えて、もう一度 npx vitest run を実行してください。本文で見た赤い出力が出ます。「ちょうど3000円なら送料無料」だけが落ち、他の3つは通っているはずです。境界の1点だけを壊すバグを、境界を狙ったテストだけが捕まえた、という状況を自分の手で再現したことになります。確認したら >= に戻し、緑に戻るのを見届けてください。
最後に npx vitest(run なし)で監視モードを起動し、shipping.test.ts に「10000円なら送料無料」のテストを1つ足して保存してみてください。保存した瞬間にテストが走ります。終了は q キーです。
確認テスト
知識問題
Q1. ブラウザで目視確認しているのに、なぜテストコードを書くのですか。目視確認だけでは防げないことを挙げて説明してください。
解答を見る
目視確認には、確認が「いま変更した場所」に偏る、急ぐと省略される、という癖があります。そして一番の問題は、過去に直した場所が今回の変更で再び壊れたこと(デグレ)に気づく手段がないことです。
テストコードは「この入力ならこの結果」という期待値をプログラムとして残すので、コードを変更するたびに過去のぶんも含めた全部の確認を数秒で繰り返せます。確認の量がアプリの成長に比例して増えても実行時間はほぼ変わらず、壊れたときはどのファイルの何行目で期待値とどう食い違ったかまで表示されます。つまり目視確認の3つの癖(偏り・省略・デグレの見逃し)をすべて機械で塞げます。
Q2. 次のテストは失敗します。理由と、どう直せば通るかを説明してください。
expect({ name: "みかん" }).toBe({ name: "みかん" });
解答を見る
toBe は「同じ値(オブジェクトなら同じ参照)か」を比べるマッチャーだからです。オブジェクトの変数には中身そのものではなく本体のありか(参照)が入っているので、別々に作った2つの { name: "みかん" } は、中身が同じでも別の本体を指しており、toBe では不一致になります。
中身で比べる toEqual に直せば通ります。
expect({ name: "みかん" }).toEqual({ name: "みかん" });
使い分けは「数値・文字列・真偽値は toBe、オブジェクト・配列は toEqual」です。
Q3. 「会員ランクがゴールド以上なら手数料無料」という仕様の関数をテストします。どんな入力を選んでテストすべきですか。考え方とあわせて説明してください。
解答を見る
狙うべきは条件が切り替わる境界の両側です。ゴールドちょうど(無料になるはず)と、その1つ下のランク(有料のはず)の2つは必ずテストします。「以上」を「より上」と実装し間違えるバグは境界の1点でしか現れず、目視確認では見逃しやすいためです。
加えて、端の入力と異常な入力も選びます。一番下のランク、ランクが未設定(null や undefined)など、仕様が曖昧になりがちな入力です。逆に「ゴールドよりずっと上」「ずっと下」のような境界から遠い入力は、境界の両側が通れば同じ分岐を通るだけなので、たくさん書いても検出力は増えません。
実技問題
Q4. 次の関数のテストを Vitest で書いてください。普通の入力だけでなく、入力の端や受け付けない入力も含めて4つ以上のテストを書くこと。
export function applyDiscount(price: number, rate: number): number {
if (rate < 0 || rate > 1) {
throw new Error("割引率は0〜1で指定してください");
}
return Math.floor(price * (1 - rate));
}
解答を見る
import { describe, it, expect } from "vitest";
import { applyDiscount } from "./discount";
describe("applyDiscount", () => {
it("20%引きで端数は切り捨てる", () => {
expect(applyDiscount(999, 0.2)).toBe(799);
});
it("割引率0なら元の価格のまま", () => {
expect(applyDiscount(1000, 0)).toBe(1000);
});
it("割引率1なら0円になる", () => {
expect(applyDiscount(1000, 1)).toBe(0);
});
it("割引率が1を超えたらエラーを投げる", () => {
expect(() => applyDiscount(1000, 1.1)).toThrow("割引率は0〜1で指定してください");
});
});
4つとも実行して通ることを確認済みです。ポイントは3つあります。1つ目、applyDiscount(999, 0.2) は 999 × 0.8 = 799.2 なので、切り捨て仕様を確かめる期待値は 799 です。端数が出る価格をわざと選ぶと Math.floor の有無を検出できます。2つ目、rate の有効範囲の端である 0 と 1 を両方テストしています(境界の考え方)。3つ目、エラーを期待するテストは expect(() => ...) と関数で包みます。包まないと expect に届く前にエラーで止まります。
Q5. あるコンポーネントに、次のような絞り込みロジックが埋まっています。これをテストできる形にして、テストを書いてください。「予算ちょうどの価格の商品が含まれるか」は必ず確かめること。
const visibleItems = computed(() =>
items.value.filter((item) => item.stock > 0 && item.price <= budget.value)
);
解答を見る
まずロジックをただの関数として切り出します。引数を受け取って戻り値を返すだけの形にすれば、コンポーネントもブラウザも無しでテストできます。
type Product = { name: string; price: number; stock: number };
export function filterAffordable(items: Product[], budget: number): Product[] {
return items.filter((item) => item.stock > 0 && item.price <= budget);
}
コンポーネント側は computed(() => filterAffordable(items.value, budget.value)) に置き換えます。テストはこうです(実行して3つとも通ることを確認済み)。
import { describe, it, expect } from "vitest";
import { filterAffordable } from "./filterAffordable";
const items = [
{ name: "りんご", price: 300, stock: 5 },
{ name: "メロン", price: 1800, stock: 2 },
{ name: "いちご", price: 800, stock: 0 },
];
describe("filterAffordable", () => {
it("予算内かつ在庫ありの商品だけ残る", () => {
expect(filterAffordable(items, 1000)).toEqual([
{ name: "りんご", price: 300, stock: 5 },
]);
});
it("価格が予算ちょうどの商品は含まれる", () => {
expect(filterAffordable(items, 1800)).toEqual([
{ name: "りんご", price: 300, stock: 5 },
{ name: "メロン", price: 1800, stock: 2 },
]);
});
it("全部予算オーバーなら空配列", () => {
expect(filterAffordable(items, 100)).toEqual([]);
});
});
ポイントは3つです。1つ目、ロジックを UI から切り出すことでテスト可能になりました(#11 の composables と同じ発想です)。2つ目、「予算ちょうど」は <= と < の取り違えを捕まえる境界のテストで、予算1800円のときメロン(1800円)が含まれることを確かめています。ついでに在庫0のいちごが800円でも除外されることも、1つ目のテストが兼ねています。3つ目、戻り値は配列なので toBe ではなく toEqual で中身を比べています。
まとめ
- テストコードは「この入力ならこの結果」という期待値を先に書いておく答え合わせの自動化。目視確認の偏り・省略・デグレの見逃しを塞ぐ
- Vitest では
describe(見出し)、it(1つのテスト、名前は仕様の文で書く)、expect(実際).toBe(期待)(答え合わせ)の3つで書く - テストが光るのは条件の境界と異常な入力。「ちょうど3000円」の両側を狙う
- 数値・文字列は
toBe、オブジェクト・配列はtoEqual。エラーはexpect(() => ...)で包んでtoThrow - コンポーネントに埋まったロジックはただの関数に切り出すとテストできる。テストしにくさは設計への警報
次回は #21 コンポーネントテスト(Testing Libraryの思想) です。今日は切り出した「ただの関数」をテストしましたが、次回はコンポーネントそのもの、「ボタンを押したら表示が変わる」をユーザーの目線でテストする方法を学びます。
