Testing Trophy(テスティングトロフィー。「テストトロフィー」とも表記)は、Testing Libraryの作者Kent C. Dodds氏が2018年2月6日にX(旧Twitter)で公開した図です。JavaScriptアプリのテストを静的解析・単体・結合・E2Eの4種類に分け、どこに力を割くと投資対効果が高いかを示しています。最も太いのは結合テストの層です。
この記事では、本人のブログ記事3本を一次情報として、4層の定義、テストピラミッドとの違い、「理想の配分」の決め方を整理します。後半では、Vitest 5.0.2・React Testing Library 16.3.3・MSW 2.15.0で書いた各層のテストを、実行結果つきで示します(バージョンは2026年9月28日時点のnpm最新版)。
まとめ:Testing Trophyの要点
- Kent C. Dodds氏が2018年2月6日に示した、テストの種類ごとの投資対効果(ROI)の目安です。下から順に静的解析(Static)・単体(Unit)・結合(Integration)・E2E(End to End)の4層で、結合テストの層が最も太く描かれています。
- 起点は2016年12月10日のGuillermo Rauch氏のツイート「Write tests. Not too many. Mostly integration.」です。Kent氏は2017年10月16日に同名のブログ記事を書き、その後この図を作りました。
- テストピラミッドとの違いは、コストと速度に「確信度(confidence)」という軸を加えたことと、JavaScriptでは型検査が当たり前ではないため静的解析を最下層に置いたことです。
- 各層の比率は、公式には数値で示されていません。トロフィーの大きさは「力を割く量の相対的な目安」で、Kent氏自身がカバレッジ70%という数字は「作った数字で科学的根拠はない」と書いています。
- Kent氏は「マイクロサービスやサーバーレス関数には当てはめて考えたことがない」と明言しています。サービス単位で分割したバックエンドには、Spotifyが2018年に示したTesting Honeycombを検討します。
Testing Trophyの定義と提唱の経緯
Testing Trophyの定義は、Kent C. Dodds氏の2本のブログ記事にまとまっています。2021年6月3日の「The Testing Trophy and Testing Classifications」(成り立ちと用語の定義)と、同日付の「Static vs Unit vs Integration vs E2E Testing for Frontend Apps」(各層の例とトレードオフ)です。公式な出典はこの2本と元のツイートで、「Testing Trophy公式サイト」のようなものはありません。
トロフィーの4層:Static・Unit・Integration・End to End
各層の定義は、Kent氏がブログで図の文言を書き起こしたものです。2018年のツイートでは、層ごとに当時の代表ツールも挙げていました。
| 層 | Kent氏の定義(要約) | 2018年の例示ツール | 2026年9月の代表例 |
|---|---|---|---|
| End to End | ユーザーのように操作するロボットで動作を確かめる | Cypress | Playwright 1.63・Cypress |
| Integration | 複数のユニットが協調して動くことを確かめる | Jest | Vitest 5・Jest 30+Testing Library |
| Unit | 個々の独立した部品が期待どおり動くことを確かめる | Jest | Vitest 5・Jest 30 |
| Static | 書いている最中に打ち間違いや型エラーを捕まえる | Flow・ESLint | TypeScript 7・ESLint 10 |
E2Eは「機能テスト」とも呼ばれるとKent氏は補足しています。層の大きさについては「テスト時に各形式へ割くべき注力量の相対的な大きさ(一般論として)」と説明しています。テストの本数そのものを指す図ではありません。E2Eテストのツール選定と運用はE2Eテストのベストプラクティスと結合テストとの違いで扱っています。
2016年のツイートから2021年の分類記事までの年表
Testing Trophyは1回の発表で完成したものではありません。ツイート、ブログ、ライブラリ、定義の補足と、段階的に形が決まっていきました。
| 日付(UTC) | 出来事 |
|---|---|
| 2016年12月10日 | Guillermo Rauch氏が元になる一文をツイート |
| 2017年10月16日 | Kent氏が同名のブログ記事を公開 |
| 2018年2月6日 | Kent氏がトロフィーの図をツイート |
| 2018年3月23日 | 「使われ方に似るほど確信が得られる」原則をツイート |
| 2018年4月2日 | react-testing-libraryを公開 |
| 2020年5月 | React Testing LibraryがThoughtWorks Technology Radarで「Adopt」 |
| 2021年6月3日 | 分類記事で用語の定義と適用範囲を補足 |
最初のツイートは、トロフィーを「JavaScriptアプリのテストにおける、各形式の投資対効果の一般的なガイド」と紹介していました。Google Driveで描いた手書き風の図で、現在よく見るイラストは、のちにMaggie Appleton氏がTestingJavaScript.com向けに描き直したものです。
「使われ方に似るほど確信が得られる」という原則
トロフィーの背後にある原則は、Testing LibraryのGuiding Principlesの冒頭にそのまま置かれています。
The more your tests resemble the way your software is used, the more confidence they can give you.
「テストがソフトウェアの使われ方に似ているほど、得られる確信は大きくなる」という意味です。Kent氏は分類記事の結びで、投資対効果の「リターン」は確信度、「投資」は時間だと言い換えています。時間が無限にあれば分類は要らず、限られた時間をどこに使うかを決めるための図だ、という位置づけです。
テストピラミッドとの違い:確信度という3本目の軸
テストピラミッドとテスティングトロフィーの違いは、形そのものより、何を比べて配分を決めるかにあります。
ピラミッドが前提にしたコストと速度
Martin Fowler氏のbliki「TestPyramid」(2012年5月1日)によると、テストピラミッドを広めたのはMike Cohn氏の2009年の著書『Succeeding with Agile』で、書籍内の名称は「Test Automation Pyramid」です(図そのものは2003〜2004年にLisa Crispin氏との会話で描かれ、2004年のScrumの集まりで紹介されたとFowler氏は補足しています)。要点は「GUIを通す高レベルのテストより、低レベルの単体テストをずっと多く持つ」ことでした。Google Testing Blogの「Just Say No to More End-to-End Tests」(2015年4月22日)は、最初の目安として単体70%・結合20%・E2E 10%を挙げています。ピラミッドの層構成と70:20:10の読み方はテストピラミッドとは?単体・結合・E2Eの配分と実装者向けテスト戦略設計で詳しく扱っています。
Fowler氏自身、同じ記事の脚注に「ピラミッドは、広い範囲のテストが焦点を絞ったテストより高価で遅く壊れやすい、という前提に立っている」「高レベルのテストが速く信頼でき、変更も安いなら、低レベルのテストは要らない」と書いています。Kent氏は2019年6月19日のツイートでこの記事の末尾の注記を引き、「今のツールは優秀で、この前提は成り立ちにくくなった」として、ピラミッドからトロフィーへ移る理由にしました。
トロフィーが加えた確信度の軸と静的解析の層
Kent氏の整理では、トロフィーを上に行くほど「コストが高い」「遅い」はピラミッドと同じです。違うのは3本目の軸で、上に行くほど1本のテストで得られる確信度(Kent氏の言う「confidence coefficient」)が上がります。コストと速度だけで判断するなら単体テストに100%寄せるのが正解になってしまう、というのがKent氏の指摘です。
| 比較軸 | テストピラミッド | Testing Trophy |
|---|---|---|
| 主な判断材料 | コスト・速度 | コスト・速度・確信度 |
| 最も厚い層 | 単体テスト | 結合テスト |
| 静的解析 | 層として置かない | 最下層に置く |
| 数値の目安 | Googleの70:20:10 | なし |
| 想定する対象 | 特に限定なし | 単一コードベース(主にフロントエンド) |
静的解析を層に加えた理由について、Kent氏は「JavaScriptの世界では、ピラミッドが登場した当時の主流言語のように型検査が当たり前ではなかったから」と書いています。TypeScriptを使う場合も、この層の厚さはstrictなどのコンパイラ設定と、型検査をCIで必ず実行するかどうかで変わります。静的解析と動的テストの守備範囲の違いは静的解析と動的テストの違いを参照してください。
Testing Trophyの理想のテスト配分:公式比率の不在と層の選び方
「テスティングトロフィーの理想の比率は何対何か」という問いには、一次情報の範囲では答えがありません。Kent氏のどの記事にも、層ごとの本数や割合の数値は出てきません。日本語の解説で見かける「結合50%」のような配分は、解説者が独自に置いた目安です。
数値について本人が書いているのはカバレッジだけです。「Write tests. Not too many. Mostly integration.」の記事で、カバレッジが70%を大きく超えると収穫逓減になると述べたうえで、その70%を「作った数字で、科学的根拠はない」と断っています。さらに、Kent氏の公開ライブラリの多くはカバレッジ100%です。多くの利用者に使われる小さなライブラリは壊れたときの影響が大きく、100%にするのも比較的簡単だから、という理由です。数値目標をチームで決めるときは、テストカバレッジの網羅率と目標設定の考え方と組み合わせてください。
Kent氏による単体・結合テストの分類とモックの範囲
比率の議論がかみ合わない一番の原因は、「単体」と「結合」の定義が人によって違うことです。Kent氏の定義は次のとおりです。
- 単体テスト:依存(協調するオブジェクト)を持たないか、依存をモックしたユニットのテスト
- 結合テスト:複数のユニットが連携する部分のテスト
Martin Fowler氏は2021年6月2日の「On the Diverse And Fantastical Shapes of Testing」で、トロフィーやハニカムの支持者が言う「単体テスト」は依存をテストダブルに置き換える「solitary」な単体テストのことで、彼らの「結合テスト」は依存を本物のまま動かす「sociable」な単体テストに近い、と分析しています。そのため、ピラミッドとの対立は「たぶん見かけだけ」だとしています。つまり、比率を決める前に「どこまでモックしたら単体と呼ぶか」をチームでそろえる必要があります。モックとスタブの区別はモックとスタブの違い・テストダブル5分類、単体・結合という呼び方自体を避けるGoogleの分類はGoogle流「テストサイズ」(Small/Medium/Large)にまとめています。
テスト層の選び方:不具合を検出できる最も下の層
本数の比率を目標にする代わりに、確かめたいことごとに、それを捕まえられる最も下の層を選ぶ方法があります。Kent氏は各層に「原理的に確かめられないこと」があると挙げ、逆向きの例も示しています。
| 確かめたいこと | 選ぶ層 | 下の層で足りない理由 |
|---|---|---|
| 数値の引数に文字列を渡していない | 静的解析 | 単体テストを書くまでもない |
| クーポン計算の境界値 | 単体 | 静的解析は業務ロジックを検証できない |
| 依存を正しい引数で呼んでいる | 結合 | 単体ではモックに対する呼び方しか見えない |
| バックエンドへ正しいデータを送る | E2E | UIの結合テストではAPI側を確かめられない |
Kent氏の例では、フォームとURL生成の連携の端のケースをE2Eで確かめるとバックエンドまで起動する準備が重すぎるので結合テストへ、クーポン計算の端のケースを結合テストで確かめるとコンポーネントの描画準備が重いので単体テストへ下ろします。配分は、この判断を積み重ねた結果として決まります。議論の最後にFowler氏とKent氏がそろって引用したのが、Justin Searls氏の次の言葉です。
People love debating what percentage of which type of tests to write, but it's a distraction.
「どの種類のテストを何%書くかの議論は本題から目をそらすものだ。境界が明確で、速く安定して動き、意味のある理由でだけ失敗するテストを書くことに集中せよ」という趣旨です。
Vitest・Testing Library・MSWで書く各層のテスト例
ここからは、静的・単体・結合の3層を実際に書いた例です。検証環境はNode.js v26.5.0、TypeScript 7.0.2、Vitest 5.0.2、@testing-library/react 16.3.3、@testing-library/user-event 14.6.7、MSW 2.15.0、React 19.3.0、jsdom 30.1.1です。E2Eの層はPlaywrightやCypressの担当なので、Playwrightの最新バージョンと更新方法を参照してください。追加の依存と実行コマンドは次のとおりです(@testing-library/jest-domはtoBeInTheDocumentなどのマッチャー、@vitejs/plugin-reactはJSXの変換に使います)。
npm i -D [email protected] @vitejs/plugin-react jsdom \
@testing-library/[email protected] @testing-library/[email protected] \
@testing-library/[email protected] @testing-library/jest-dom \
[email protected] [email protected] react react-dom @types/react @types/react-dom
npx tsc --noEmit # 静的解析
npx vitest run # 単体・結合テスト
静的解析:TypeScriptによる引数の型不一致の検出
割引後の金額を返す関数を題材にします。割引率を文字列で渡す誤りは、テストを書く前に型検査で止まります。
// src/price.ts
export function applyCoupon(price: number, rate: number): number {
if (rate < 0 || rate > 1) throw new RangeError('rate must be 0..1')
return Math.round(price * (1 - rate))
}
// src/static-bug.ts(誤りを含む例)
import { applyCoupon } from './price'
const rate = '0.1'
export const total = applyCoupon(1000, rate)
src/static-bug.ts(4,40): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
このエラーはエディタ上で入力中に出ます。単体テストで同じ誤りを捕まえようとすると、引数の型ごとにテストケースを書くことになります。
単体テスト:test.eachによる純粋関数の境界値検証
丸めや割引率の下限0・上限1、範囲外の値のような境界は、依存のない純粋関数として切り出して単体テストで並べます。結合テストでこれを網羅しようとすると、ケースの数だけ画面を描画することになります。
// src/price.test.ts
import { expect, test } from 'vitest'
import { applyCoupon } from './price'
test.each([
[1000, 0.1, 900],
[999, 0.15, 849],
[1000, 0, 1000],
[1000, 1, 0],
])('applyCoupon(%i, %f) = %i', (price, rate, expected) => {
expect(applyCoupon(price, rate)).toBe(expected)
})
test('割引率が範囲外なら例外', () => {
expect(() => applyCoupon(1000, 1.5)).toThrow(RangeError)
})
結合テスト:MSWによるHTTP応答の差し替え
Kent氏は、結合テストでモックするのはほぼ「ネットワーク通信(MSWで)」と「アニメーションを担うコンポーネント」だけだと書いています。検索フォームのコンポーネントで、この方針を試します。検証対象は「入力→送信→結果またはエラー表示」の流れだけで、通信自体の失敗(fetchの例外)の処理や、再検索時に前回の結果を消す処理は省いています。
// src/UserSearch.tsx
import { useState } from 'react'
type User = { id: number; name: string }
export function UserSearch() {
const [q, setQ] = useState('')
const [users, setUsers] = useState<User[] | null>(null)
const [error, setError] = useState('')
async function onSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
const res = await fetch(`https://api.example.com/users?q=${encodeURIComponent(q)}`)
if (!res.ok) {
setError('検索に失敗しました')
return
}
setUsers(await res.json())
}
return (
<form onSubmit={onSubmit}>
<label>
名前
<input value={q} onChange={(e) => setQ(e.target.value)} />
</label>
<button type="submit">検索</button>
{error && <p role="alert">{error}</p>}
{users && (
<ul>
{users.map((u) => <li key={u.id}>{u.name}</li>)}
</ul>
)}
</form>
)
}
テストではfetchをモックせず、MSWがHTTPリクエストを受けてレスポンスを返します。onUnhandledRequest: 'error'を付けると、ハンドラを定義していないリクエストはエラーとして扱われ、実際のサーバーへは送られません。この例のようにfetchの例外を握りつぶさないコードなら、テストも失敗として報告されます。
// src/UserSearch.test.tsx
import { afterAll, afterEach, beforeAll, expect, test } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { UserSearch } from './UserSearch'
const server = setupServer(
http.get('https://api.example.com/users', ({ request }) => {
const q = new URL(request.url).searchParams.get('q')
return HttpResponse.json(q === 'sato' ? [{ id: 1, name: '佐藤 花子' }] : [])
}),
)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('名前で検索すると結果が一覧に出る', async () => {
const user = userEvent.setup()
render(<UserSearch />)
await user.type(screen.getByLabelText('名前'), 'sato')
await user.click(screen.getByRole('button', { name: '検索' }))
expect(await screen.findByText('佐藤 花子')).toBeInTheDocument()
})
test('APIが500を返したらエラーを表示する', async () => {
server.use(http.get('https://api.example.com/users', () => new HttpResponse(null, { status: 500 })))
const user = userEvent.setup()
render(<UserSearch />)
await user.click(screen.getByRole('button', { name: '検索' }))
expect(await screen.findByRole('alert')).toHaveTextContent('検索に失敗しました')
})
✓ src/price.test.ts > applyCoupon(1000, 0.1) = 900 4ms
✓ src/price.test.ts > applyCoupon(999, 0.15) = 849 0ms
✓ src/price.test.ts > applyCoupon(1000, 0) = 1000 0ms
✓ src/price.test.ts > applyCoupon(1000, 1) = 0 0ms
✓ src/price.test.ts > 割引率が範囲外なら例外 1ms
✓ src/UserSearch.test.tsx > 名前で検索すると結果が一覧に出る 650ms
✓ src/UserSearch.test.tsx > APIが500を返したらエラーを表示する 72ms
Test Files 2 passed (2)
Tests 7 passed (7)
結合テストの価値が分かるのは、コンポーネント側のクエリパラメータ名をqからqueryに書き換えたときです。fetchをモックして「呼ばれたかどうか」だけを確かめる単体テストなら通ってしまいます。この結合テストは画面に結果が出ないことで、MSWのハンドラが期待するクエリ名とずれたことを検出して失敗します。ただし照合相手は手書きのハンドラなので、ハンドラと実APIの仕様が一致しているかは別途確かめる必要があります。
× 名前で検索すると結果が一覧に出る 1651ms
TestingLibraryElementError: Unable to find an element with the text: 佐藤 花子. 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.
Tests 1 failed | 6 passed (7)
ラベル名やロールで要素を探すクエリの選び方はReact Testing Libraryのuser-eventとクエリ実践、MSWを含むモックサーバーの方式選定はAPIモッキングの方式選定と契約ドリフト対策で扱っています。
Vitestのテスト間分離:cleanupの明示的な登録
上の結合テストは、最初は2本目が次のエラーで失敗しました。
TestingLibraryElementError: Found multiple elements with the role "button" and name "検索"
Tests 1 failed | 6 passed (7)
1本目で描画したフォームがDOMに残り、「検索」ボタンが2つになったためです。Testing Libraryのドキュメントには、cleanupは「テストフレームワークがグローバルなafterEach()を注入する場合に自動で呼ばれる」とあります。Vitestは既定でglobals: falseのため、自動では呼ばれません。Vitestのドキュメントも、@testing-library/reactのように自動cleanupをグローバルAPIに頼るライブラリがあると注意しています。セットアップファイルで明示的に登録すると解消します(設定でglobals: trueにする方法もあります)。
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'] },
})
// vitest.setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => cleanup())
Jestは既定でグローバルなafterEach()を注入するので、この登録が無くても自動でcleanupされます。そのため、JestからVitestへ移行したときに結合テストだけが落ちる原因になります。Vitestの導入手順はVitestの使い方とReact Testing Libraryでのコンポーネントテストを参照してください。
Testing Trophyを当てはめない方がよい場面
Testing Trophyはフロントエンドの単一コードベースを前提に作られた図です。Kent氏は分類記事で、次の2点をはっきり書いています。
- 作った当時、マイクロサービスはもちろん、バックエンドのサービスに当てはまるかどうかも考えていなかった
- バックエンドにも適用できるが、検討したのはモノリスだけで、マイクロサービスやサーバーレス関数は対象にしていない
サービスを細かく分けたバックエンドに、トロフィーの「結合を厚く」をそのまま持ち込むのはおすすめしません。サービス間の結合を厚くすると、他チームのサービスの状態でテストが落ちるようになります。Spotifyが2018年1月11日に公開した「Testing of Microservices」のTesting Honeycombは、1サービス内の結合テストを中心にし、実装詳細のテストを少なく、他システムの正しさに依存する「Integrated Test」は理想的にはゼロにする、という形です。他サービスへの依存を切り離して1サービスずつ検証したい構成ならハニカム、画面とロジックが1つのコードベースに収まるフロントエンドならトロフィー、という条件で選んでください。
多くのプロジェクトから使われるライブラリも例外です。前述のとおりKent氏自身が公開ライブラリではカバレッジ100%を取っており、利用者の多い部品ほど単体テストを厚くする判断は、トロフィーの考え方と矛盾しません。
Testing Trophyに関するよくある質問
テスティングトロフィーとテストピラミッドの違いは何ですか?
最も厚い層が違います。ピラミッドは単体テスト、トロフィーは結合テストを最も厚くします。判断材料もコスト・速度に確信度を加えた3軸になり、最下層に静的解析が加わります。
Testing Trophyの理想的なテスト配分の比率は何対何ですか?
公式の比率はありません。層の大きさは注力量の相対的な目安です。数値を持つのはテストピラミッド側のGoogleの目安(単体70%・結合20%・E2E 10%)です。確かめたいことごとに、捕まえられる最も下の層を選ぶ方法をおすすめします。
Testing Trophyの提唱者Kent C. Dodds氏はどんな人ですか?
Testing Libraryの作者で、現在は独立した教育者です。本人の経歴ページによると、2015〜2019年にPayPalでWeb基盤のエンジニアを務め、PayPalの代表としてTC39にも参加しました。2021〜2022年はRemixのDirector of Developer Experienceです。2019年からはTestingJavaScriptやEpic Reactなどの講座を運営しています。
Testing TrophyではE2Eテストは不要になりますか?
不要にはなりません。E2Eはトロフィーの最上層で、最も確信度が高い層です。ただし実行コストが高く壊れやすいため、本数は絞ります。Kent氏の例でも、ユーザー登録の画面を通すE2Eは1本だけにし、ほかのテストは同じエンドポイントを直接呼んで登録の画面操作を省いています。
Testing Trophyはバックエンドにも使えますか?
モノリスのバックエンドなら使えるとKent氏は書いています。マイクロサービスやサーバーレス関数は検討の対象外と明言しているので、サービス単位のテストにはSpotifyのTesting Honeycombを参考にしてください。