テスト講座 #1 なぜテストなのか — ユニット/インテグレーション/E2E の居場所
React トラック と TypeScript トラック を終えると自然にぶつかるテーマ — それが テスト です。ツールは多く(Vitest、Jest、Cypress、Playwright)、パラダイムも分かれています(behavior vs implementation)。最初の記事では、ツールではなく 全体像 を先につかみましょう。
このシリーズは テスト講座 全 6 編です。
- #1 なぜテストなのか — ユニット/インテグレーション/E2E の居場所 ← 今回の記事
- #2 Vitest のセットアップと最初のユニットテスト
- #3 React Testing Library — コンポーネントテスト
- #4 非同期とネットワークのモック — MSW
- #5 ユーザーイベントとフォームのテスト
- #6 Playwright で E2E と CI 統合
今回の記事はコードがほとんどありません。次回以降のすべての判断は、この全体像を土台に展開します。
テストが進まない本当の理由 #
「忙しいから」が一番よくある答えです。しかし一歩踏み込むと、別の層が見えてきます。
- 何をテストすればいいのか分からない — コンポーネント 1 つに対してテストを 5 つ書かないといけない気がするプレッシャー。結局どこで止めればいいのか分かりません。
- テストが壊れる方が怖い — コードを少し変えただけでテストが 20 件赤くなる経験。「こんなことなら書かない方がマシ」
- CI が赤くて誰も見ない — 一度壊れ始めると感覚が麻痺します。
この 3 つが重なると、「テストはいいものだけど、うちのプロジェクトには合いません」という結論にたどり着きます。実際には どこにどのテストを使うか の分かれ道で一度誤った判断をした結果なんです。その分かれ道が今回の記事のテーマです。
テストピラミッド — 最も古い図 #
まずは見慣れた図から。
/\
/ \
/ E2E\ ← 적게, 비싸게, 느리게
/------\
/ 통합 \ ← 적당히
/--------- \
/ 단위 \ ← 많이, 싸게, 빠르게
/--------------\3 層の意味:
- ユニット (unit) — 関数 / クラス / コンポーネント 1 つを他のものから分離してテスト。通常は ms 単位。数百〜数千個を抱えていても負担になりません。
- インテグレーション (integration) — 複数のモジュールが一緒に動く領域です。DB と一緒に動くサーバーハンドラ、子コンポーネントと親コンポーネントの相互作用など。
- E2E (end-to-end) — 実際のブラウザ / 実際のサーバー / 実際の DB まで一緒に。ユーザーシナリオそのまま。
ピラミッドの形が勧めているのは明らかです。ユニットを最も多く、E2E を最も少なく。 理由:
- コスト — ユニットは ms、E2E は秒単位。100 個の E2E と 100 個のユニットテストでは時間差が圧倒的。
- 安定性 — E2E はネットワーク/タイミング/ディスプレイのような変数に敏感。同じテストがあるときは通り、あるときは失敗する flaky がひどい。
- デバッグ — E2E が壊れると「どこで」壊れたかの追跡が難しい。
しかし — ユニットだけでは捉えられない部分がある #
ピラミッドをそのまま受け取りすぎると罠にはまります。代表的なアンチパターン:
test('useCounter sets state correctly', () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.setCount(5));
expect(result.current.count).toBe(5);
});このテストが捉えているものは何でしょうか? setCount(5) を呼ぶと count が 5 になる — つまり React の useState が動作するという事実。私たちのコードの問題はほとんど捉えません。それでいて useCounter の内部を変えると壊れます。
こういうテストが積み重なると、リファクタリングが怖くなる 状況に到着します。「機能は同じで実装だけ変えた」のにテストが 50 個赤くなれば、二度とリファクタリングをしなくなります。
解決の方向は 2 つ。
- behavior をテストする — ユーザー/呼び出し側が見る結果だけを検証。内部が変わっても結果が同じなら通る。
- ユニットの境界を狭く取りすぎない —
useCounter1 つではなく、それを使うコンポーネントと一緒にテスト。意外にもより安定。
テスティングトロフィー — よりモダンな図 #
Kent C. Dodds が提案した図がこのニュアンスを捉えています。
┌──────────────┐
│ E2E │ ← 핵심 시나리오 몇 개
├──────────────┤
│ │
│ 통합 │ ← 가장 많이
│ │
├──────────────┤
│ 단위 │ ← 복잡한 로직만
├──────────────┤
│ 정적 │ ← TS, ESLint, 타입 체크
└──────────────┘ピラミッドと 2 つ違います。
- 静的解析 (型チェッカー、リンター) が一番下。TypeScript / ESLint が捉えるものをテストで捉えようとしないこと。無料で得られる最も強い安全網。
- インテグレーションが最も厚い層。「複数のコンポーネントが一緒に動く領域」がユーザーの視点に最も近く、リファクタリングに耐えられるくらい安定しています。
この記事のシリーズはトロフィー寄りの視点に従います。React/Next.js のようなフロントエンド領域では、「ユニット」と「インテグレーション」の境界が曖昧で、実はほぼすべての RTL テストはインテグレーションに近いです。
何をテストするか — 決定木 #
役に立つ 1 つの木。
이 코드가 깨지면 사용자가 알아챌까?
├─ 아니오 → 테스트하지 마라 (또는 정적 분석에 맡겨라)
└─ 예 → 테스트해라
↓
어떤 층에서?
├─ 한 함수의 복잡한 알고리즘 → 단위
├─ 여러 모듈/컴포넌트의 상호작용 → 통합
└─ 핵심 사용자 시나리오 (회원가입, 결제 등) → E2E「ユーザーが気づくか」が大きなフィルターです。内部ヘルパー関数の変数名のようなものは気にしないこと。外部から観察可能な結果だけをテストします。
「behavior」をテストするとはどういうことか #
同じコンポーネントを 2 通りの方法でテストしてみます。
function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSubmit({ email, password });
};
return (
<form onSubmit={handleSubmit}>
<label>
이메일
<input value={email} onChange={(e) => setEmail(e.target.value)} />
</label>
<label>
비밀번호
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
<button type="submit">로그인</button>
</form>
);
}implementation に縛られたテスト(アンチパターン):
test('email state updates', () => {
const { container } = render(<LoginForm onSubmit={jest.fn()} />);
const input = container.querySelectorAll('input')[0];
fireEvent.change(input, { target: { value: 'a@b.com' } });
// 내부 state 를 검증하려는 시도 ...
});querySelectorAll('input')[0] のインデックスはコンポーネントが少し変わっただけで壊れます。label の追加、順序の変更、別の input の追加。
behavior に集中したテスト:
test('사용자가 폼을 채워 제출하면 onSubmit 이 값과 함께 호출된다', async () => {
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await userEvent.type(screen.getByLabelText('이메일'), 'a@b.com');
await userEvent.type(screen.getByLabelText('비밀번호'), 'secret');
await userEvent.click(screen.getByRole('button', { name: '로그인' }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'a@b.com',
password: 'secret',
});
});違いは明らかです。
- 入力をインデックスではなく ラベル で探す → ユーザーが画面でフォームを埋める方法と同じ。
- ボタンをテキストではなく role + name で探す → アクセシビリティに優しい。
- 検証は外部に公開された
onSubmitコールバックだけ → 内部 state の名前が変わっても壊れません。
このシリーズのすべてのコンポーネントテストは 2 番目の形でいきます。
モックの居場所と限界 #
テストでは外部依存(API、DB、time、ランダム)は通常 モック(mocking) します。なぜ必要か:
- 外部呼び出しは遅く、ネットワークに依存し、コストがあります。
- 外部レスポンスが変わる可能性があり、テストが flaky になります。
- 「特定のエラーレスポンス」のようなシナリオは実際のシステムでは作りにくいです。
しかしモックは 危険なツール です。
- モックしすぎると — テストが実際のシステムから乖離します。「テストはすべて通ったのに本番は壊れた」の最もよくある原因。
- 深すぎるところをモックすると — モックと実際の実装が別々に進化します。一方が変わってももう一方が追いつけない事故。
原則:
- 外部システムの境界 だけモック — HTTP 呼び出し、DB 接続、ファイルシステム。
- 内部モジュールは ほぼモックしない。ユニットテストの中で他のモジュールの結果をそのまま使うのが、インテグレーションテストへ自然に流れていきます。
- HTTP モックは #4 MSW で — fetch をモックするのではなく ネットワークレイヤー で横取りします。コードの立場では本物の fetch を呼んだのと変わりません。
「テストを先に」か「テストを後で」か #
TDD 論争は終わりがないテーマです。ここでは肌触りだけ押さえます。
TDD がうまくはまるケース:
- 明確なアルゴリズム、入出力がはっきりした関数 (parser、validator、calculator)。
- バグ修正 — まず壊れるテストを書いてから直せば、そのバグが二度と戻ってきません。
TDD が違和感のあるケース:
- UI がどう仕上がるか決まっていないコンポーネント。
- 外部 API の実際のレスポンスの形をよく知らないインテグレーションコード。
- 探索的コーディング (このライブラリを使うのが正しいか試している段階)。
このシリーズは TDD を強要しません。「テストがあるべきところにあるか」 がより重要な問いです。
カバレッジの罠 #
カバレッジ (coverage) は「テストがコードの何 % を実行したか」を示す数字です。90% のような目標を立てたくなりますが、罠が 2 つ。
- カバレッジ 90% が品質 90% を意味するわけではない — ただ実行しただけで、検証したわけではありません。
expectが 1 行もないテストでもカバレッジは高い。 - 最後の 10% が最もコストが高い — error path、エッジケース、めったに通らない分岐。ここに時間を注ぎ込むうちにテストがアンチパターンに流れていく。
おすすめの視点:
- 新しいコードは 自然に 80% くらいカバーされるように 書く。届かない部分は通常、意味のないコード(デッドコード、不要な分岐)。
- 分岐/行単位のカバレッジよりシナリオカバレッジ を見る。「会員登録 → 認証メール → ログイン」のような中核フローがすべてテストされているか。
- カバレッジ 100% を目標にしないこと。主要フローが押さえられたなら、最後の 1 行までカバーしようとする試みはむしろ大きな損失。
#6 で CI のカバレッジレポートをもう一度押さえます。
時間の配分 — 結局このトラックの結論 #
テストは 時間の配分 に関する話です。無限の時間があればすべての行をユニット/インテグレーション/E2E ですべて覆えますが、現実はそうではありません。では、どこに時間を使うか:
- 静的解析 (型、リンター) — ほぼ無料。オンにしておくこと。
- インテグレーションテスト (RTL + MSW) — コスパが最もよい領域です。時間の半分以上。
- ユニットテスト — 複雑なロジックだけ (calculator、parser、validator)。
- E2E — 中核ユーザーフロー 5〜10 個。それ以上増やすと保守が負担。
この配分が 6 編トラックの骨格を組みます。
#1 — 그림 잡기 (이번 글)
#2 — 단위 테스트 도구 (Vitest)
#3, #4, #5 — 통합 테스트 (RTL + MSW + userEvent)
#6 — E2E (Playwright) + CIシリーズ開始 — 何を持っているべきか #
次回から手を動かします。持っているべきもの:
- Node 22+ (Vitest がモダンな ESM/型をきれいに扱うため)
- pnpm または npm
- TypeScript に慣れているとよいですが必須ではありません — TS トラック で十分な踏み台になります。
ツールのセットアップと最初のテストは #2 Vitest で。
まとめ #
- テストが進まない理由は通常「忙しいから」ではなく、何を/どこで/どう の全体像がないから。
- ピラミッドより トロフィー — 静的解析 → インテグレーション → ユニット → E2E の順に比重を配分。インテグレーションが最も厚い層。
- behavior をテストすること。implementation に縛られるとリファクタリングが怖くなります。
- モックは システムの境界 でだけ。内部モジュールをモックするとテストと実物が別々に進化します。
- カバレッジの数字にしがみつかないこと。中核シナリオが押さえられているかがより重要。
- トラックは 静的解析 → Vitest ユニット → RTL+MSW インテグレーション → Playwright E2E の流れ。
次の記事 (#2 Vitest のセットアップと最初のユニットテスト) では、ツールを導入する段階に手をつけます。Vitest をプロジェクトに付けて、最も単純な関数に最初のテストを書きます。describe / it / expect の意味と watch モードの使いどころまで。