どのチームもテストは書きます。けれども、不具合を未然に防ぎ、リファクタリングを生き延び、金曜の午後にリリースする度胸を与えてくれるテストを書けているチームばかりではありません。役に立つテストと足を引っぱるテストの違いは、フレームワークでもカバレッジの数字でもありません。戦略——何を、どの層で、どんな代償を払って確かめるかを知っていること——の違いです。
多くのチームの既定のやり方は、あらゆるものにユニットテストを書いて一件落着とすることです。カバレッジは 90 パーセント、CI は緑、みんな安心します——ある関数の変更が、どのユニットテストも捕まえられなかった三つの機能を壊すまでは。問題は規律の不足ではなく、間違った層でテストしていることです。五つのサービスを束ねるコンポーネントは、ユニットカバレッジが 100 パーセントでも本番で落ちます。それらのサービス同士のやり取りが一度も検証されていないからです。
この記事は、現代のテスト戦略を端から端まで扱います——速く独立したユニットテストから、遅いが頼りになるエンドツーエンドまで——そして、それぞれの戦略がいつ価値を生み、いつ無駄な重みになるのかを説明します。狙いは、もっとテストを書くよう説得することではありません。存在を正当化できるテストを書く手助けをすることです。つまり、重要な不具合を捕まえ、頻繁に回せるだけ速く、コードベースの避けられない再編にも耐えるテストです。
ユニットテストの作法と、何を確かめるか
ユニットテストが多くの戦略の土台になるのは、速く、決定的で、独立しているからです。よく書かれたユニットテストはミリ秒で終わり、一つの論理的なふるまいだけを覆い、データベースやファイルシステム、ネットワークの窓口といった外部の仕組みに依存しません。落ちたとき、どの論理が壊れ、なぜ壊れたのかがはっきり分かります。
いちばんよくある誤りは、ふるまいではなく実装の細部を確かめてしまうことです。ある関数が特定の内部メソッドを呼ぶこと、ある私的フィールドを設定することを確かめると、テストは脆くなります——外からのふるまいを保ったリファクタリングでも壊れてしまうからです。そうなればテストは安全網ではなく負債です。関数の観測できる出力や副作用を確かめ、そこへ至る道筋は確かめないでください。
向いているのは、純粋な補助関数、入出力を伴わない業務ロジック、検証と構文解析、データの変換、アルゴリズム的な計算、そして状態機械です。向いていないのは、ネットワークを呼ぶ関数、画面を描くコンポーネント、フレームワークの内部と固く結びついたコードです。これらは統合層かエンドツーエンド層で確かめるほうが適しています。
import { describe, it, expect } from "vitest";
import { calculateDiscount, type Order } from "./pricing";
describe("calculateDiscount", () => {
it("returns 0 for orders below the threshold", () => {
const order: Order = {
items: [{ price: 30, quantity: 1 }],
customerSince: new Date("2025-01-01"),
};
expect(calculateDiscount(order)).toBe(0);
});
it("applies 10 percent for orders over $100", () => {
const order: Order = {
items: [
{ price: 80, quantity: 1 },
{ price: 40, quantity: 1 },
],
customerSince: new Date("2025-01-01"),
};
expect(calculateDiscount(order)).toBe(12);
});
it("applies loyalty bonus for customers over 2 years", () => {
const order: Order = {
items: [{ price: 100, quantity: 1 }],
customerSince: new Date("2022-06-01"),
};
expect(calculateDiscount(order)).toBe(15);
});
it("does not apply discount when items are on sale", () => {
const order: Order = {
items: [{ price: 200, quantity: 1, onSale: true }],
customerSince: new Date("2025-01-01"),
};
expect(calculateDiscount(order)).toBe(0);
});
});上の例は、一つの関数の四つの異なるふるまいを、別々の入力と期待される出力で確かめています。どのテストも独立し、決定的で、10 ミリ秒とかかりません。価格計算の論理が変われば、テストはどの筋書きが影響を受けたかを正確に教えてくれます。これがユニットテストの値打ちです。純粋な論理に対する素早い手応え。
- ふるまいを確かめ、実装は確かめない。出力と副作用を見て、内部の呼び出しや私的な状態は見ないこと。
- 一つの事例につき論理的な主張は一つ。テストはちょうど一つの理由で落ちるべきで、そうすれば何が壊れたかがすぐ分かります。
- 文として読める説明的な名前を付けること。「しきい値未満の注文には 0 を返す」は「test_discount_low_amount」より優れています。
- テスト間で可変の状態を共有しないこと。各テストが自分でデータを用意し、後片づけをします。
- テスト値は簡潔で意味のあるものに。実際のドメインの対象に近い、現実味のあるデータを使ってください。
本物の依存を使う統合テスト
統合テストは、速さと範囲の面でユニットテストとエンドツーエンドの中間にあります。複数の部品がどう協調するかを確かめます——リポジトリが本物のデータベースを叩き、サービスが実在の窓口と話し、コンポーネントが本物の状態ストアとともに描かれる。ユニットテストとの決定的な違いは、代役ではなく本物の、あるいはコンテナ化された依存を使う点です。
もっとも価値のある統合テストは、あなたのコードが外部の仕組みと正しくやり取りできているかを確かめます。データベース層を代役に置き換えたユニットテストは、問い合わせの論理が単体として正しいかは教えてくれますが、実際の SQL が実際のスキーマで通るかは教えてくれません。Testcontainers やテスト用データベースを使う統合テストは、スキーマの食い違い、制約違反、トランザクション境界の不具合、直列化の問題を捕まえます——ユニットテストがまったく見落とすものです。
統合テストが光るのは、リポジトリ層、API のルートハンドラ、メッセージキューの受け手、データベースの移行、そして境界をまたいでデータを直列化・復元するあらゆるコードです。代償は速度で、Postgres のコンテナを起動するテストはミリ秒ではなく秒を要します。しかし捕まえる不具合は、本番で見つかれば相応に高くつくものです。
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createDatabase, teardownDatabase } from "./test-utils";
import { OrderRepository } from "./order-repository";
describe("OrderRepository", () => {
let db: Awaited<ReturnType<typeof createDatabase>>;
let repo: OrderRepository;
beforeAll(async () => {
db = await createDatabase();
repo = new OrderRepository(db);
});
afterAll(async () => {
await teardownDatabase(db);
});
it("persists and retrieves an order", async () => {
const order = {
id: "ord_001",
customerId: "cus_001",
total: 1250,
status: "pending" as const,
createdAt: new Date("2025-06-01"),
};
await repo.save(order);
const retrieved = await repo.findById("ord_001");
expect(retrieved).toEqual(order);
});
it("returns null for a non-existent order", async () => {
const result = await repo.findById("ord_nonexistent");
expect(result).toBeNull();
});
it("updates order status", async () => {
await repo.save({
id: "ord_002",
customerId: "cus_001",
total: 500,
status: "pending",
createdAt: new Date(),
});
await repo.updateStatus("ord_002", "shipped");
const updated = await repo.findById("ord_002");
expect(updated?.status).toBe("shipped");
});
});統合テストの肝は、実行と実行のあいだでデータベースの状態を隔離することです。各テスト群は自分のスキーマかデータベースを作り、移行を当て、テストを走らせ、すべてを片づけるべきです。共有のデータベースで走らせると、あるテストのデータが別のテストの判定に染み出す、当てにならないテストが生まれます。Testcontainers や Docker Compose、あるいはフレームワークのテスト用データベース機能を使い、毎回きちんと分かった状態から始めてください。
本物のデータベースを使う統合テスト一本は、データベースを代役に置き換えたユニットテスト百本に値します。代役は、あなたがデータベースに何を期待しているかを教えます。本物は、それが実際に何をするかを教えます。最初のスキーマ移行を過ぎれば、この二つはめったに一致しません。
Playwright によるエンドツーエンドテスト
エンドツーエンドテストは、ブラウザ、フロントエンド、API、データベース、外部サービスという積み重ね全体を通して、実際の利用者の操作を再現します。もっとも遅く、もっとも高くつく種類ですが、いちばん現実味のある不具合を捕まえます。壊れた遷移、欠けた入力検証、誤って表示された API の応答、認証の流れの失敗、ブラウザ間の描画の違いです。
複数ブラウザへの対応、自動で待つ判定、ネットワークの横取り、そして開発者にとっての扱いやすさから、Playwright はウェブアプリのエンドツーエンドテストの主役になりました。明示的な待機と再試行を要した以前の道具と違い、Playwright は要素が本当に操作できる状態になるまで待ってから触れます。これが、当てにならないテストのいちばんの原因を取り除いています。
import { test, expect } from "@playwright/test";
test("user completes a purchase flow", async ({ page }) => {
await page.goto("/products");
await page.getByRole("link", { name: "Wireless Headphones" }).click();
await page.getByRole("button", { name: "Add to Cart" }).click();
await page.getByRole("button", { name: "View Cart" }).click();
await expect(page.getByText("Wireless Headphones")).toBeVisible();
await expect(page.getByText("$89.99")).toBeVisible();
await page.getByRole("button", { name: "Checkout" }).click();
await page.getByLabel("Email").fill("test@example.com");
await page.getByLabel("Card Number").fill("4242424242424242");
await page.getByLabel("Expiry").fill("12/28");
await page.getByLabel("CVC").fill("123");
await page.getByRole("button", { name: "Pay Now" }).click();
await expect(page.getByText("Order confirmed")).toBeVisible();
await expect(page.getByText("ord_001")).toBeVisible();
});エンドツーエンドテストで犯しがちな最大の誤りは、書きすぎることです。一本ごとに CI の時間が数分伸び、200 本ともなれば平気で 30 分かかります。この経済が選別を求めます。決定的な利用者の道筋——登録、ログイン、購入、検索、決済——に集中し、境界の場合や誤りの状態は下の層に任せてください。
- 売上に直結する流れを優先してください。会計、購読の切り替え、決済処理です。
- テストは互いに独立させてください。各テストが自分でデータを作り、後片づけをします。
- 準備段階では画面操作ではなく API 呼び出しを使い、開始状態に早くたどり着いてください。
- 本番ではなく、検証用またはプレビュー環境で走らせてください。テストデータの見え方は機能フラグで制御します。
- テストの可観測性に投資してください。失敗したテストのスクリーンショット、トレース、録画は何時間もの調査を節約します。
テスト駆動開発の実際
テスト駆動開発はテストの話ではなく、設計の話です。赤・緑・リファクタリングの循環は、実装する前にコードの窓口を考えることを強います。先にテストを書けば、この関数は何を受け取り何を返すべきか、という問いに答えざるをえません。その制約が、よりよい API、より緩い結合、より部品らしいコードを生みます。
抵抗はたいてい二つのどちらかから来ます。向いていない問題(画面のコード、探索的な作業)で試してしまったか、教条的に当てはめて、役に立つコードを書くより試験の枠組みと格闘する時間のほうが長くなったか、です。これは道具であって宗教ではありません。アルゴリズム的な論理、データの変換、API の設計、そして入出力の約束が着手前から明らかなコードで、もっともよく働きます。
実際の流れはこうです。次に実装したいふるまいを述べるテストを一本書きます。走らせて落ちるのを見ます——これでテストが妥当で、機能の不在を検知できることが確かめられます。通すための最小限のコードを書きます。凝りすぎないこと。テストを満たすいちばん単純な実装が正解です。それからふるまいを変えずに構造を整え、通っているテストを頼りに後戻りを見張ります。
テスト駆動開発は、あなたにより多くのテストを書かせるのではありません。よりよいコードを書かせるのです。テストは設計という営みの副産物であって、目的ではありません。実装のあとにテストを書くなら、あなたは試験をしています。実装の前に書くなら、あなたは設計をしています。
不具合の修正にはとりわけよく効きます。報告を受けたら、まずそれを再現するテストを書き(赤)、コードを直し(緑)、既存のふるまいを壊していないことを確かめます(すべてのテストが通ったまま)。書いたテストは再発を防ぐ恒久の守りになります。時とともに、その積み重ねはプロジェクトの実際の不具合の歴史を映す集まりになります。カバレッジの目標のために作られた集まりより、はるかに値打ちがあります。
性質ベースのテストとファジング
伝統的な例に基づくテストは、特定の入力を期待される出力と照らします。性質ベースのテストは別の道を行きます。すべての入力について成り立つはずの性質を定め、それを確かめるために何百、何千という無作為の入力を生み出すのです。この手法は、誰も例として書こうと思いつかない境界の場合を捕まえます。
たとえば、並べ替えの関数に具体例を五つ書く代わりに、性質を定めます。比較可能な要素のどんな一覧についても、結果は同じ要素を非減少の順で含むべきだ、と。枠組みは大きさのまちまちな無作為の一覧を——重複つき、空、極端な値つきで——生み出し、そのすべてで性質を確かめます。
import { describe, it, expect } from "vitest";
import { faker } from "@faker-js/faker";
describe("sortByPrice", () => {
it("returns items in ascending price order for any input", () => {
for (let i = 0; i < 100; i++) {
const items = Array.from(
{ length: faker.number.int({ min: 0, max: 50 }) },
() => ({
name: faker.commerce.productName(),
price: faker.number.int({ min: 1, max: 1000 }),
})
);
const sorted = sortByPrice(items);
for (let j = 1; j < sorted.length; j++) {
expect(sorted[j - 1].price).toBeLessThanOrEqual(sorted[j].price);
}
}
});
it("preserves all original elements", () => {
for (let i = 0; i < 100; i++) {
const items = Array.from(
{ length: faker.number.int({ min: 0, max: 50 }) },
() => ({
name: faker.commerce.productName(),
price: faker.number.int({ min: 1, max: 1000 }),
})
);
const sorted = sortByPrice(items);
expect(sorted).toHaveLength(items.length);
expect(sorted.map((i) => i.name).sort()).toEqual(
items.map((i) => i.name).sort()
);
}
});
});fast-check は TypeScript でよく使われる性質ベースのテストのライブラリで、Vitest や Jest と噛み合います。よくあるデータ型の生成器、複雑な構造のための組み合わせ子、そして自動縮小を備えます。落ちる事例が見つかると、なお落ちる最小の入力まで縮めようとするので、原因を追うのがずっと楽になります。
この手法がとりわけ値打ちを持つのは、直列化と復元、並べ替えと絞り込みの算法、検証規則、状態機械の遷移、そして入力の取りうる範囲が広いあらゆる関数です。壊れたデータや想定外のデータを流し込むファジングと組み合わせれば、本番でしか見つからないはずの脆弱性やクラッシュを表に引き出せます。
視覚回帰テスト
機能のテストは、アプリが正しくふるまうことを確かめます。視覚回帰テストは、正しく見えることを確かめます。CSS の変更、依存の更新、作り直したコンポーネントは、わずかな配置のずれ、色の食い違い、書体の変化、折り返し点の不具合を招きますが、機能のテストはそれを捕まえません。視覚回帰は、コンポーネントやページの画像を撮り、基準の画像と照らし、違いがあれば人の目に委ねます。
Chromatic、Percy、そして Playwright に組み込まれた撮影機能が、それぞれの層でこれを実現します。Chromatic は Storybook と噛み合い、アンチエイリアスやサブピクセル描画の差を無視する賢い比較で画素ごとに照らします。Percy は同様の能力を、より広いフレームワーク対応とともに備えます。Playwright の toHaveScreenshot は、追加の道具なしにエンドツーエンドのテストへ直接、視覚的な判定を書かせてくれます。
import { test, expect } from "@playwright/test";
test("product page renders correctly", async ({ page }) => {
await page.goto("/products/wireless-headphones");
await expect(page).toHaveScreenshot("product-page.png", {
maxDiffPixelRatio: 0.01,
threshold: 0.2,
fullPage: true,
});
});
test("checkout form is visually stable across states", async ({ page }) => {
await page.goto("/checkout");
await expect(page).toHaveScreenshot("checkout-empty.png");
await page.getByLabel("Email").fill("invalid-email");
await page.getByRole("button", { name: "Continue" }).click();
await expect(page).toHaveScreenshot("checkout-validation-error.png");
});視覚回帰でいちばん難しいのは基準画像の管理です。意図した見た目の変更のたびに基準を更新する必要があり、画面をよく触るチームでは承認の工程が詰まりがちです。答えは、基準の更新を開発の流れの一部として扱うこと。つまり、コードと同じレビューの周回のなかで見た目の差分を承認し、プルリクエストの流れに噛み合う仕組みを使うことです。
- 重要なページから始めてください。トップ、会計、ログイン、商品詳細、そして込み入った配置のページです。
- 適切なしきい値を置き、アンチエイリアスの跡や OS ごとの書体描画の差を無視させてください。
- あらゆる変更に反応する全画面の撮影ではなく、Storybook を通じたコンポーネント単位の撮影で狙いを定めた網を張ってください。
- 視覚のレビューをプルリクエストの流れに組み込み、変更のあったページの差分には人の承認を求めてください。
- 視覚テストは揃った環境(Docker)で走らせ、開発者の機械ごとの描画の差をなくしてください。
React サーバーコンポーネントと Next.js のテスト
React のサーバーコンポーネントは、テストの風景を変えました。サーバーだけで動き、クライアントへ JavaScript を一切送りません。ブラウザにその論理をさらすことなく、データベースやファイルシステム、裏側のサービスへ直接手を伸ばせます。そのため、クライアント側のコンポーネントとは違うやり方でテストできます。
サーバーコンポーネントは要するに JSX を返す非同期関数です。ほかの非同期関数と同じように、プロパティを渡して呼び、結果を待ち、描かれた出力を確かめればよいのです。ブラウザの環境は要りませんし、本物のテスト用データベースを相手にするなら fetch やデータベース呼び出しの代役も要りません。この簡潔さは見落とされがちな利点です。DOM の環境を必要とするクライアントコンポーネントに比べ、準備が格段に軽くなります。
import { describe, it, expect } from "vitest";
import { ProductList } from "./product-list";
describe("ProductList (Server Component)", () => {
it("renders products from the database", async () => {
// No mocking needed - the component calls the database directly
const { props } = await ProductList({ category: "electronics" });
expect(props.products.length).toBeGreaterThan(0);
expect(props.products.every((p) => p.category === "electronics")).toBe(
true
);
});
it("shows empty state when no products match", async () => {
const { props } = await ProductList({ category: "nonexistent" });
expect(props.products).toHaveLength(0);
});
});useState や useEffect、ブラウザの窓口、イベントの受け手を使うクライアントコンポーネントには、DOM のテスト環境が要ります。Vitest は happy-dom や jsdom でブラウザを模します。Testing Library は、描かれた出力を探し、利用者の操作を模すための道具を備えます。サーバーコンポーネントとの違いは、コンポーネントを描き、副作用の実行を待ち、その結果の DOM の状態を確かめる必要がある点です。
Next.js のアプリはさらに、経路制御、データ取得、サーバーアクションを加えます。getServerSideProps や generateStaticParams を使うページでは、データ取得の関数をコンポーネントとは切り離して確かめてください。サーバーアクションはただの非同期関数として確かめれば十分です。App Router のレイアウトとページでは、データの層を表示の層と分けて確かめ、テストがフレームワークの描画の内部と結びつかないようにしてください。
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CheckoutForm } from "./checkout-form";
describe("CheckoutForm", () => {
it("shows validation errors for empty required fields", async () => {
const user = userEvent.setup();
render(<CheckoutForm />);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(screen.getByText("Email is required")).toBeVisible();
expect(screen.getByText("Card number is required")).toBeVisible();
});
it("submits the form with valid data", async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<CheckoutForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "test@example.com");
await user.type(screen.getByLabelText("Card Number"), "4242424242424242");
await user.type(screen.getByLabelText("Expiry"), "12/28");
await user.type(screen.getByLabelText("CVC"), "123");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(onSubmit).toHaveBeenCalledWith({
email: "test@example.com",
cardNumber: "4242424242424242",
expiry: "12/28",
cvc: "123",
});
});
});新しい React の枠組みでいちばん大切な線引きは、サーバーコンポーネントのテストとクライアントコンポーネントのテストを分けることです。前者は純粋な非同期関数——DOM の環境なしで確かめてください。後者は利用者の操作を模す必要があります——Testing Library と userEvent で確かめてください。二つを混ぜると、テストは必要以上に遅く、込み入ったものになります。
代役の使いどころと、使わないほうがよい場面
代役はテストでもっとも議論の多い話題のひとつです。代役は本物の依存を制御できる身代わりに置き換え、データベースを立てず、外部の窓口を呼ばず、時計を待たずにコードを独立して確かめさせてくれます。引き換えに、あなたが確かめているのは依存の模した姿であって、本物ではありません。代役が実際のふるまいを正しく写していなければ、テストは通り、本物のコードは落ちます。
目安は、系の境界で代役を立てることです。自分の手に負えない外部サービス(他社の窓口、決済事業者、メール配信)と、テストに含めるには高くつくか結果が定まらない基盤(データベース、ファイルシステム、時計)を置き換えます。内部の部品、補助関数、自分のドメインの対象は代役にしないでください。それらは統合テストで本物の実装のまま確かめるべきです。
Vitest には vi.mock の仕組みがあり、代役の宣言をファイルの先頭へ引き上げ、テストが走る前にモジュールの実装を差し替えます。環境変数や他社のキット、大域の対象には便利です。危ういのは、モジュール単位の差し替えが目に見えず、単体では通るのに一緒に走らせると互いに干渉して落ちるテストを生みかねない点です。
import { describe, it, expect, vi } from "vitest";
import { PaymentService } from "./payment-service";
const mockStripe = {
charges: {
create: vi.fn(),
},
};
vi.mock("stripe", () => ({
default: vi.fn(() => mockStripe),
}));
describe("PaymentService", () => {
it("creates a charge successfully", async () => {
mockStripe.charges.create.mockResolvedValue({
id: "ch_001",
status: "succeeded",
});
const service = new PaymentService();
const result = await service.charge(5000, "tok_visa");
expect(result.status).toBe("succeeded");
expect(mockStripe.charges.create).toHaveBeenCalledWith({
amount: 5000,
currency: "usd",
source: "tok_visa",
});
});
it("throws on failed payment", async () => {
mockStripe.charges.create.mockRejectedValue(
new Error("card_declined")
);
const service = new PaymentService();
await expect(service.charge(5000, "tok_visa")).rejects.toThrow(
"card_declined"
);
});
});代役より頼れるのは、本物と同じ窓口を備えつつ、記憶の中で簡素に実装したテスト用の身代わりです。データベースのモジュールを代役にするのではなく、データを Map に置くリポジトリの記憶内版を書いてください。この身代わりは軽いのに誠実で、本物のデータベースと同じ経路を通りながら、立ち上げの手間を要しません。
代役はひとつ立てるたびに、テストと現実のあいだに隙間を開けます。不具合はその隙間に潜みます。代役へ手を伸ばす前に問うてください。これは制御できる環境で、本物の依存を使って確かめられないか。答えが「はい」なら、本物を使ってください。「いいえ」なら——遅すぎる、高すぎる、結果が定まらない——そのときは代役を、ただし狭く、系の境界だけで立ててください。
- 系の境界で代役を立ててください。他社の窓口、開発キット、決済の入口、ローカルで動かせない外部サービスです。
- 自分の抽象(リポジトリ、キャッシュ、キュー)には代役ではなく、記憶内の身代わりを使ってください。
- 自分のものでないものを代役にしないこと。ライブラリの内部を模すと、テストが実装の細部に縛られます。
- モジュール単位の差し替えより、依存の注入を選んでください。明示して渡せば、テストの準備が見通せます。
- 代役の及ぶ範囲は、それを必要とするテストだけに限ってください。テストごとにすべての代役を戻します。
CI/CD の流れの中でテストする
テストの集まりは、安定して走り、素早く手応えを返してこそ値打ちがあります。遅く、当てにならず、開発者の機械でしか走らないテストは負債です。CI/CD の流れに組み込めば、プルリクエストごと、主枝への取り込みごと、そして重要な経路については配信のたびに走ります。
ピラミッドは流れの段階へ自然に写ります。ユニットテストが最初、押し込みのたびに走ります。速く、基本的な論理の誤りを捕まえるからです。次に統合テストが、できるかぎり並行で、コンテナ化した依存とともに。エンドツーエンドは最後、プルリクエストと配信の前だけ。遅く高くつくからです。視覚回帰テストはその傍らで走り、撮影を主枝の基準と照らします。
流れの速さは大切です。45 分かかる集まりは、手元でテストを走らせずに壊れたコードを押し込む習慣を招きます。速さを保つ策としては、今回の変更が及ぶテストだけを走らせる、集まりを並行の実行機へ分ける、node_modules と Playwright のブラウザを実行のあいだで蓄えておく、そして遅いエンドツーエンドはコミットごとではなく時間を決めて走らせる、といったものがあります。
# .github/workflows/test.yml - example pipeline structure
name: test
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npx vitest run --project unit
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: test
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx vitest run --project integration
e2e:
needs: [unit, integration]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright test当てにならないテストは、CI への信頼にとって最大の脅威です。コードの変更と関わりなく時折落ちるテストは、流れ全体への信頼を削ります。チームは失敗を見過ごすようになり、赤いまま取り込み、やがて集まりはただの雑音になります。見つける仕組みに投資してください。印をつけ、隔離し、直すか外すかを優先します。信じられないテストは、テストがないよりたちが悪い。失敗を無視する習慣を育ててしまうからです。
結果の見せ方も見落とされがちです。「3 件失敗」とだけ出る出力は、本当の誤りの文言を探して記録をたどらせます。よい報告は、落ちた判定、期待した値と実際の値、ファイルと行番号、そしてスクリーンショットや呼び出しの跡といった文脈を示します。Vitest や Playwright の HTML 報告、GitHub Actions の注記は、結果をプルリクエストの流れの中でそのまま見せてくれます。
トロフィーとピラミッドの論争
伝統的なテストピラミッド——土台にユニットテスト、中ほどに統合テスト、頂にエンドツーエンド——は二十年にわたり戦略を導いてきました。伝える考えは単純です。速く独立したテストをたくさん、遅い統合テストは少なめに、遅いエンドツーエンドはごくわずかに。配信の単位が入出力の境目のはっきりした関数やクラスである裏側のサービスには、よく馴染む模型です。
その対案として示されたのがテストトロフィーで、現代のフロントエンド開発をよりよく映します。ピラミッドを菱形に組み替え、統合テストを最も厚い層とし、ユニットとエンドツーエンドを減らします。言い分はこうです。フロントエンドのアプリでは、コンポーネントがどう協調するかを確かめることから最大の価値が生まれる——それこそ統合テストがすること——のであって、孤立した関数や利用者の全行程を確かめることからではない、と。
トロフィーは実務の実感を映しています。React のアプリでもっとも値打ちのあるテストは、コンポーネントを描き、触り、結果を確かめるものです。こうしたテストは、コンポーネント本体、その子、そのフック、その状態管理、その API 呼び出しをまとめて働かせます。エンドツーエンドより速く、ユニットより現実に近い。フロントエンドの文脈では、よく書かれた統合テストの集まりのほうが、テスト一本あたりの安心をより多く与えます。
実のところ、どちらの模型も、より込み入った真実を単純にしたものです。正しい戦略はあなたの構えによって変わります。業務ロジックの重い裏側のマイクロサービスはピラミッドに向きます。ドメインの論理に厚いユニットの網、API 層には統合テストを少し、外部サービスとの取り決めには契約テストを一握り。込み入った操作を伴うフロントエンドはトロフィーに向きます。コンポーネントとページに広い統合の網、補助的な論理に絞ったユニット、そして重要な経路にエンドツーエンドです。
ピラミッドもトロフィーも、教条的に従えばどちらも誤りです。正しい戦略とは、あなたの不具合を捕まえ、あなたのチームにとって十分に速く、あなたの構えを生き延びるものです。それはピラミッドに見えるかもしれないし、トロフィーに見えるかもしれないし、まだ誰も名づけていない形かもしれません。形の議論はやめて、自分のテストが実際に何を捕まえているかを見てください。
両方の模型の背後にある大事な洞察は、層ごとに費用と効き目の釣り合いが違うということです。ユニットテストは書くのも走らせるのもミリ秒。統合テストは秒。エンドツーエンドは分。あなたの具体的なアプリにとって各層がもたらす価値に応じて、テストの予算を割り振ってください。データが重く業務ロジックの込み入った管理画面なら、ユニットと統合に投資を。操作の少ない読み物中心のサイトなら、重要な流れのエンドツーエンドと配置のための視覚回帰が、いちばん報われる投資でしょう。
テストの文化をつくる
チームがテストを信じていなければ、どんなに優れた戦略も倒れます。テストの文化は、カバレッジの下限を命じたり、テスト駆動開発を強いたりして生まれるものではありません。テストを書くことが、いちばん抵抗の少ない自然な道になったときに生まれます。書きやすく、走らせやすく、値打ちがはっきりしていれば、人はテストを書きます。脆く、遅く、偽の失敗ばかりなら、避ける道を探します。
まずテストの体験を極上にしてください。基盤に投資を——速い実行機、頼れるテスト用データベース、当てにならないテストを見つける仕組み。よくある判定を短く書ける道具を用意してください。作法をチームの手引きに書き残し、新しい API の窓口、React のコンポーネント、データベースの移行をどう確かめるか、誰もがゼロから始めずに済むようにしてください。よい基盤を一度こしらえる費用は、毎日ひどいテストと格闘する費用の積み重ねよりずっと安いのです。
コードのレビューにはテストのレビューも含めてください。レビューする人はこう問うべきです。このテストは本当に、覆うと言っているふるまいを覆っているか。実装が誤っていても通ってしまわないか。正しいものを正しい層で確かめているか。読みやすく、手入れしやすいか。テストを一級のコードとして扱い、本番のコードと同じレビュー基準、書き方の指針、品質への期待を当ててください。
カバレッジの数値は診断の道具としては有用で、目標としては最悪です。90 パーセントを掲げれば 90 パーセントは手に入ります——よいテストは手に入りません。数値に届かせるために、取得メソッドや空のコンストラクタに意味の薄いテストが書かれ、安全網は少しも厚くなりません。カバレッジの報告は、確かめられていない経路を見つけるために使い、恣意的な下限を押しつけるために使わないでください。要所に置かれた統合テストによる 70 パーセントは、浅いユニットテストによる 95 パーセントより値打ちがあります。
- テストを書きやすくしてください。どのテストファイルでも決まり文句を減らす道具、生成器、補助を用意します。
- テストの改善を、機能の出荷と同じように讃えてください。ひと群れの不具合を根こそぎにするテストは、それ自体が機能です。
- テスト基盤の持ち主を回り持ちにし、知識が一人に偏らずチームへ行き渡るようにしてください。
- 当てにならないテストを仕分ける会を定期的に開いてください。その数をチームの健康の指標として追い、修正を優先します。
- チームのテスト宣言を書いてください。どの層で何を確かめ、何を確かめないか、どんな引き換えに合意したかを定めます。
テストの文化が最後に目指すのは、特定のカバレッジの数字でも、完璧な流れでもありません。安心です。リファクタリングが本番を壊さないという安心。金曜の午後に配信しても大丈夫だという安心。不具合が報告され直されたら、直ったままでいるという安心。チームにその安心を与える戦略は、よい戦略です。ピラミッドに見えようと、トロフィーに見えようと、その中間に見えようと。
すべてをつなぎ合わせる
効き目のあるテスト戦略をこしらえるとは、ある手法を別の手法より選ぶことではありません。引き換えを理解し、場面ごとに適した道具を当てることです。ユニットテストはミリ秒で論理の誤りを捕まえます。統合テストは本物の依存とのやり取りの不具合を捕まえます。エンドツーエンドは積み重ね全体にわたる、利用者に見える失敗を捕まえます。性質ベースのテストは、あることすら知らなかった境界の場合を捕まえます。視覚回帰は、意図しない見た目の変化を捕まえます。
安定して届けるチームは、カバレッジの数字がいちばん高いチームではありません。各層が何をもたらすかを吟味し、自分たちの危うさに合わせて投資を配り、規則が求めるからではなく値打ちがあるからテストを書く文化をこしらえたチームです。まずは今あるテストの集まりを点検してください。一本ごとに問うのです。これは存在を正当化しているか、と。答えがはっきり「はい」でなければ、外すか置き換えてください。最良のテストの集まりは、いちばん大きいものではありません。走らせる一分あたり、いちばん多くの安心を返してくれるものです。