テスト講座 #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 が壊れると「どこで」壊れたかの追跡が難しい。

しかし — ユニットだけでは捉えられない部分がある #

ピラミッドをそのまま受け取りすぎると罠にはまります。代表的なアンチパターン:

implementation 에 묶인 단위 테스트
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 つ。

  1. behavior をテストする — ユーザー/呼び出し側が見る結果だけを検証。内部が変わっても結果が同じなら通る。
  2. ユニットの境界を狭く取りすぎないuseCounter 1 つではなく、それを使うコンポーネントと一緒にテスト。意外にもより安定。

テスティングトロフィー — よりモダンな図 #

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 モードの使いどころまで。

X