Skip to content
Published on

TypeScriptテスト完全ガイド: Jest, Vitest, MSW, Mock, Stub実践解説

Authors

TypeScriptテスト完全ガイド: Jest, Vitest, MSW, Mock, Stub実践解説

テストはコード品質を保証する最も効果的な方法です。TypeScript環境で適切なテストを書くには、テストフレームワークの設定、モッキング戦略、モックAPIサーバーなど様々なツールを理解する必要があります。本ガイドでは2026年現在の実務で使われているテスティングスタックを一から解説します。


1. TypeScriptテスト環境のセットアップ

1.1 Jest + ts-jestの設定

npm install -D jest ts-jest @types/jest
// jest.config.ts
import type { Config } from 'jest'

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/__tests__/**/*.ts', '**/*.test.ts', '**/*.spec.ts'],
  transform: {
    '^.+\\.tsx?$': [
      'ts-jest',
      {
        tsconfig: 'tsconfig.json',
      },
    ],
  },
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/**/*.stories.ts'],
  coverageThreshold: {
    global: {
      branches: 70,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
  setupFilesAfterFramework: ['<rootDir>/jest.setup.ts'],
}

export default config
// jest.setup.ts
import '@testing-library/jest-dom'

beforeEach(() => {
  jest.clearAllMocks()
})

1.2 Vitestの設定(ESMサポート)

VitestはViteベースでESMをネイティブサポートし、Jest互換のAPIを提供します。

npm install -D vitest @vitest/coverage-v8
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import path from 'path'

export default defineConfig({
  test: {
    globals: true, // describe, it, expectをグローバルで使用
    environment: 'node', // ブラウザライクなテストには'jsdom'
    setupFiles: ['./vitest.setup.ts'],
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      exclude: ['node_modules', 'dist', '**/*.d.ts'],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 70,
      },
    },
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
})

1.3 @types/jest vs @jest/globals

// 方法1: @types/jest(グローバル型注入 - tsconfig.jsonに "types": ["jest"] が必要)
describe('example', () => {
  it('works', () => {
    expect(true).toBe(true)
  })
})

// 方法2: @jest/globals(明示的インポート - より安全)
import { describe, it, expect, jest } from '@jest/globals'

describe('example', () => {
  it('works', () => {
    expect(true).toBe(true)
  })
})

// Vitestではglobals: trueの場合インポート不要
// または明示的に:
import { describe, it, expect, vi } from 'vitest'

2. 単体テストの書き方

2.1 基本的な構造

// src/utils/calculator.ts
export function add(a: number, b: number): number {
  return a + b
}

export function divide(a: number, b: number): number {
  if (b === 0) throw new Error('ゼロで割ることはできません')
  return a / b
}

// src/utils/__tests__/calculator.test.ts
import { add, divide } from '../calculator'

describe('Calculator', () => {
  describe('add', () => {
    it('2つの正数を足す', () => {
      expect(add(2, 3)).toBe(5)
    })

    it('負の数を足す', () => {
      expect(add(-1, -2)).toBe(-3)
    })

    it('ゼロを足す', () => {
      expect(add(5, 0)).toBe(5)
    })
  })

  describe('divide', () => {
    it('割り算を実行する', () => {
      expect(divide(10, 2)).toBe(5)
    })

    it('ゼロで割るとエラーを投げる', () => {
      expect(() => divide(10, 0)).toThrow('ゼロで割ることはできません')
    })

    it('小数の結果を返す', () => {
      expect(divide(1, 3)).toBeCloseTo(0.333, 2)
    })
  })
})

2.2 ライフサイクルフック

describe('データベーステスト', () => {
  let db: MockDatabase

  beforeAll(async () => {
    // テストスイート全体の開始前に1回だけ実行
    db = await MockDatabase.connect('test-db')
    await db.migrate()
  })

  afterAll(async () => {
    // テストスイート全体の終了後に1回だけ実行
    await db.disconnect()
  })

  beforeEach(async () => {
    // 各テストの実行前
    await db.seed()
  })

  afterEach(async () => {
    // 各テストの実行後
    await db.clean()
  })

  it('ユーザーを作成する', async () => {
    const user = await db.users.create({ name: 'Alice', email: 'alice@example.com' })
    expect(user.id).toBeDefined()
    expect(user.name).toBe('Alice')
  })
})

2.3 非同期テスト

// async/await方式(推奨)
it('非同期データを取得する', async () => {
  const data = await fetchData()
  expect(data).toEqual({ id: 1, name: 'Alice' })
})

// Promiseを返す方式
it('Promiseを返す', () => {
  return fetchData().then((data) => {
    expect(data).toEqual({ id: 1, name: 'Alice' })
  })
})

// エラーテスト
it('ネットワークエラーを処理する', async () => {
  await expect(fetchData()).rejects.toThrow('Network Error')
})

// タイムアウト設定
it('遅い処理', async () => {
  const result = await slowOperation()
  expect(result).toBeDefined()
}, 10000) // 10秒タイムアウト

// only, skip, todo
it.only('このテストだけ実行する', () => {
  /* ... */
})
it.skip('このテストはスキップする', () => {
  /* ... */
})
it.todo('後で書くテスト')

2.4 Matcherの活用

describe('Matcherの例', () => {
  it('等値チェック', () => {
    expect(1 + 1).toBe(2) // プリミティブ値の比較(===)
    expect({ a: 1 }).toEqual({ a: 1 }) // 深い比較
    expect({ a: 1, b: 2 }).toMatchObject({ a: 1 }) // 部分一致
  })

  it('真偽値チェック', () => {
    expect(true).toBeTruthy()
    expect(null).toBeNull()
    expect(undefined).toBeUndefined()
    expect('value').toBeDefined()
  })

  it('数値比較', () => {
    expect(5).toBeGreaterThan(3)
    expect(5).toBeLessThanOrEqual(5)
    expect(0.1 + 0.2).toBeCloseTo(0.3, 5)
  })

  it('配列・文字列チェック', () => {
    expect([1, 2, 3]).toContain(2)
    expect([1, 2, 3]).toHaveLength(3)
    expect('hello world').toMatch(/world/)
    expect('hello world').toContain('hello')
  })

  it('エラーチェック', () => {
    expect(() => {
      throw new Error('oops')
    }).toThrow()
    expect(() => {
      throw new Error('oops')
    }).toThrow('oops')
    expect(() => {
      throw new TypeError('type error')
    }).toThrow(TypeError)
  })
})

3. Jestモッキング完全解説

3.1 jest.fn() - 関数のモッキング

import { jest } from '@jest/globals'

// 基本のモック関数
const mockFn = jest.fn()
mockFn('hello')
expect(mockFn).toHaveBeenCalledWith('hello')
expect(mockFn).toHaveBeenCalledTimes(1)

// 戻り値の設定
const mockAdd = jest
  .fn()
  .mockReturnValue(10) // 常に10を返す
  .mockReturnValueOnce(5) // 1回目の呼び出しで5を返す
  .mockReturnValueOnce(7) // 2回目の呼び出しで7を返す

console.log(mockAdd()) // 5
console.log(mockAdd()) // 7
console.log(mockAdd()) // 10(以降はデフォルト値)

// 非同期戻り値
const mockFetch = jest
  .fn()
  .mockResolvedValue({ data: 'success' }) // Promise.resolve
  .mockRejectedValueOnce(new Error('Network Error')) // Promise.reject(1回のみ)

// 実装の置き換え
const mockCalculate = jest.fn().mockImplementation((a: number, b: number) => {
  return a * b
})

// 呼び出し引数の検査
const spy = jest.fn()
spy(1, 'hello', { key: 'value' })
expect(spy.mock.calls[0]).toEqual([1, 'hello', { key: 'value' }])

3.2 jest.spyOn() - スパイパターン

// 実際の実装を維持しながら呼び出しを監視
const mathSpy = jest.spyOn(Math, 'random')
mathSpy.mockReturnValue(0.5)

Math.random() // 0.5を返す
expect(mathSpy).toHaveBeenCalled()

// テスト後に元の実装を復元
mathSpy.mockRestore()

// オブジェクトメソッドのスパイ
class UserService {
  async getUser(id: number) {
    return fetch(`/api/users/${id}`).then((r) => r.json())
  }
}

const service = new UserService()
const spy = jest.spyOn(service, 'getUser').mockResolvedValue({ id: 1, name: 'Alice' })

const user = await service.getUser(1)
expect(user.name).toBe('Alice')
expect(spy).toHaveBeenCalledWith(1)

3.3 jest.mock() - モジュールモッキング

// モジュール全体をモック
jest.mock('../api/userApi')
import { fetchUser } from '../api/userApi'

const mockFetchUser = fetchUser as jest.MockedFunction<typeof fetchUser>
mockFetchUser.mockResolvedValue({ id: 1, name: 'Alice' })

// ファクトリ関数でモック(より細かい制御)
jest.mock('../api/userApi', () => ({
  fetchUser: jest.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
  createUser: jest.fn().mockResolvedValue({ id: 2, name: 'Bob' }),
}))

// モジュールの一部だけモック
jest.mock('../utils/date', () => ({
  ...jest.requireActual('../utils/date'), // 残りは実際の実装を使用
  now: jest.fn().mockReturnValue(new Date('2026-01-01')),
}))

3.4 モックリセットメソッドの違い

describe('モックリセットの違い', () => {
  const mockFn = jest.fn().mockReturnValue(42)

  afterEach(() => {
    // jest.clearAllMocks(): mock.calls, mock.instances, mock.resultsをリセット
    // ただしmockReturnValueなどの実装は維持
    jest.clearAllMocks()

    // jest.resetAllMocks(): clearAllMocks + 実装もリセット
    // jest.resetAllMocks()

    // jest.restoreAllMocks(): spyOnで作成したスパイを元の実装に復元
    // jest.restoreAllMocks()
  })

  it('clearAllMocks後も実装は維持される', () => {
    jest.clearAllMocks()
    expect(mockFn()).toBe(42) // 実装は残っている
    expect(mockFn).toHaveBeenCalledTimes(1) // 呼び出し履歴はリセット
  })
})

// 使い分けガイド:
// clearAllMocks: 基本的にafterEachで使用
// resetAllMocks: モッキングを完全に初期状態に戻したい場合
// restoreAllMocks: spyOn使用後に元の実装に戻す必要がある場合

3.5 自動モッキング(mocksディレクトリ)

src/
  services/
    __mocks__/
      emailService.ts   <- 自動モックファイル
    emailService.ts     <- 実際のファイル
// src/services/__mocks__/emailService.ts
export const emailService = {
  send: jest.fn().mockResolvedValue({ success: true }),
  verify: jest.fn().mockResolvedValue(true),
}

export default emailService

// テストファイルでは jest.mock('../services/emailService') の1行だけ追加
import { emailService } from '../services/emailService'
jest.mock('../services/emailService')

it('メールを送信する', async () => {
  await emailService.send({ to: 'test@example.com', subject: 'Hello' })
  expect(emailService.send).toHaveBeenCalledTimes(1)
})

4. Mock Service Worker (MSW) - モックAPIサーバー

MSWはサービスワーカーまたはNode.jsインターセプターを通じてHTTPリクエストをインターセプトする最新のAPIモッキングツールです。

4.1 インストールと基本設定

npm install -D msw
// src/mocks/handlers.ts
import { http, HttpResponse, delay } from 'msw'

export interface User {
  id: number
  name: string
  email: string
}

const users: User[] = [
  { id: 1, name: 'Alice', email: 'alice@example.com' },
  { id: 2, name: 'Bob', email: 'bob@example.com' },
]

export const handlers = [
  // GET 全件取得
  http.get('/api/users', () => {
    return HttpResponse.json(users)
  }),

  // パスパラメーター付きGET
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params
    const user = users.find((u) => u.id === Number(id))
    if (!user) {
      return HttpResponse.json({ error: 'ユーザーが見つかりません' }, { status: 404 })
    }
    return HttpResponse.json(user)
  }),

  // POST
  http.post('/api/users', async ({ request }) => {
    const body = (await request.json()) as Omit<User, 'id'>
    const newUser: User = { id: Date.now(), ...body }
    users.push(newUser)
    return HttpResponse.json(newUser, { status: 201 })
  }),

  // PUT
  http.put('/api/users/:id', async ({ params, request }) => {
    const { id } = params
    const body = (await request.json()) as Partial<User>
    const index = users.findIndex((u) => u.id === Number(id))
    if (index === -1) {
      return HttpResponse.json({ error: 'ユーザーが見つかりません' }, { status: 404 })
    }
    users[index] = { ...users[index], ...body }
    return HttpResponse.json(users[index])
  }),

  // DELETE
  http.delete('/api/users/:id', ({ params }) => {
    const { id } = params
    const index = users.findIndex((u) => u.id === Number(id))
    if (index === -1) {
      return HttpResponse.json({ error: 'ユーザーが見つかりません' }, { status: 404 })
    }
    users.splice(index, 1)
    return new HttpResponse(null, { status: 204 })
  }),
]

4.2 Node.js環境(Jest/Vitest)

// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

// jest.setup.ts または vitest.setup.ts
import { server } from './src/mocks/server'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

4.3 レスポンス遅延のシミュレーション

import { http, HttpResponse, delay } from 'msw'

export const handlers = [
  http.get('/api/users', async () => {
    await delay(500) // 500msの遅延
    return HttpResponse.json(users)
  }),

  http.get('/api/slow-endpoint', async () => {
    await delay('real') // リアルなネットワーク遅延をシミュレート
    return HttpResponse.json({ data: 'ok' })
  }),
]

4.4 エラーレスポンスのモッキング

// 特定のテストでのみエラーレスポンスを返すよう上書き
it('サーバーエラーを処理する', async () => {
  server.use(
    http.get('/api/users', () => {
      return HttpResponse.json({ error: 'Internal Server Error' }, { status: 500 })
    })
  )

  await expect(fetchUsers()).rejects.toThrow()
})

it('ネットワークエラーを処理する', async () => {
  server.use(
    http.get('/api/users', () => {
      return HttpResponse.error() // ネットワークレベルのエラーをシミュレート
    })
  )

  await expect(fetchUsers()).rejects.toThrow('Network Error')
})

4.5 ブラウザ環境(setupWorker)

// src/mocks/browser.ts
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

export const worker = setupWorker(...handlers)

// 開発環境のみMSWを有効化(例: main.tsx)
if (import.meta.env.DEV) {
  const { worker } = await import('./mocks/browser')
  await worker.start({
    onUnhandledRequest: 'bypass',
  })
}

4.6 GraphQLのモッキング

import { graphql, HttpResponse } from 'msw'

export const handlers = [
  graphql.query('GetUser', ({ variables }) => {
    const { id } = variables
    return HttpResponse.json({
      data: {
        user: { id, name: 'Alice', email: 'alice@example.com' },
      },
    })
  }),

  graphql.mutation('CreateUser', ({ variables }) => {
    const { name, email } = variables
    return HttpResponse.json({
      data: {
        createUser: { id: Date.now(), name, email },
      },
    })
  }),
]

4.7 完全な例: Next.js APIのモッキング

// src/services/userService.ts
export async function getUsers(): Promise<User[]> {
  const response = await fetch('/api/users')
  if (!response.ok) throw new Error('ユーザーの取得に失敗しました')
  return response.json()
}

// src/services/__tests__/userService.test.ts
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import { getUsers } from '../userService'

const server = setupServer(
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'Alice', email: 'alice@example.com' },
      { id: 2, name: 'Bob', email: 'bob@example.com' },
    ])
  })
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

describe('UserService', () => {
  it('ユーザー一覧を取得する', async () => {
    const users = await getUsers()
    expect(users).toHaveLength(2)
    expect(users[0].name).toBe('Alice')
  })

  it('サーバーエラー時に例外を投げる', async () => {
    server.use(
      http.get('/api/users', () => {
        return HttpResponse.json({ error: 'Server Error' }, { status: 500 })
      })
    )
    await expect(getUsers()).rejects.toThrow('ユーザーの取得に失敗しました')
  })
})

5. Stubパターン

5.1 手動Stub(インターフェース実装)

// インターフェース定義
interface EmailService {
  send(to: string, subject: string, body: string): Promise<boolean>
  verify(email: string): Promise<boolean>
}

// テスト用Stub
class StubEmailService implements EmailService {
  private sentEmails: Array<{ to: string; subject: string; body: string }> = []

  async send(to: string, subject: string, body: string): Promise<boolean> {
    this.sentEmails.push({ to, subject, body })
    return true
  }

  async verify(_email: string): Promise<boolean> {
    return true
  }

  getSentEmails() {
    return this.sentEmails
  }
}

// 使用例
it('登録時にウェルカムメールを送信する', async () => {
  const emailStub = new StubEmailService()
  const userService = new UserService(emailStub)

  await userService.register({ name: 'Alice', email: 'alice@example.com' })

  const sentEmails = emailStub.getSentEmails()
  expect(sentEmails).toHaveLength(1)
  expect(sentEmails[0].to).toBe('alice@example.com')
  expect(sentEmails[0].subject).toContain('ようこそ')
})

5.2 日付・時刻のStub

// Jest
describe('日付関連のテスト', () => {
  beforeEach(() => {
    jest.useFakeTimers()
    jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
  })

  afterEach(() => {
    jest.useRealTimers()
  })

  it('現在の日付を正しく返す', () => {
    const result = getCurrentDate()
    expect(result).toBe('2026-01-01')
  })

  it('タイマーを進める', async () => {
    const callback = jest.fn()
    setTimeout(callback, 1000)
    jest.advanceTimersByTime(1000)
    expect(callback).toHaveBeenCalled()
  })
})

// Vitest
import { vi } from 'vitest'

describe('日付関連のテスト', () => {
  beforeEach(() => {
    vi.useFakeTimers()
    vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
  })

  afterEach(() => {
    vi.useRealTimers()
  })
})

5.3 環境変数のStub

describe('環境変数のテスト', () => {
  const originalEnv = process.env

  beforeEach(() => {
    process.env = { ...originalEnv }
  })

  afterEach(() => {
    process.env = originalEnv
  })

  it('本番環境では異なる動作をする', () => {
    process.env.NODE_ENV = 'production'
    process.env.API_URL = 'https://api.example.com'

    const config = loadConfig()
    expect(config.apiUrl).toBe('https://api.example.com')
    expect(config.debug).toBe(false)
  })

  it('開発環境ではデバッグモードが有効になる', () => {
    process.env.NODE_ENV = 'development'

    const config = loadConfig()
    expect(config.debug).toBe(true)
  })
})

5.4 localStorageのStub

class LocalStorageStub {
  private store: Map<string, string> = new Map()

  getItem(key: string): string | null {
    return this.store.get(key) ?? null
  }

  setItem(key: string, value: string): void {
    this.store.set(key, value)
  }

  removeItem(key: string): void {
    this.store.delete(key)
  }

  clear(): void {
    this.store.clear()
  }

  get length(): number {
    return this.store.size
  }

  key(index: number): string | null {
    return Array.from(this.store.keys())[index] ?? null
  }
}

// テストでの使用
beforeEach(() => {
  const localStorageStub = new LocalStorageStub()
  Object.defineProperty(window, 'localStorage', {
    value: localStorageStub,
    writable: true,
  })
})

it('トークンをlocalStorageに保存する', () => {
  authService.login('user@example.com', 'password')
  expect(localStorage.getItem('auth_token')).toBeDefined()
})

6. モックサーバーの作成

6.1 json-serverの設定

npm install -D json-server
// db.json
{
  "users": [
    { "id": 1, "name": "Alice", "email": "alice@example.com" },
    { "id": 2, "name": "Bob", "email": "bob@example.com" }
  ],
  "posts": [{ "id": 1, "title": "Hello World", "userId": 1, "body": "First post" }]
}
npx json-server db.json --port 3001
# GET    /users
# GET    /users/1
# POST   /users
# PUT    /users/1
# DELETE /users/1

6.2 Express.js TypeScriptモックサーバー

// mock-server/server.ts
import express, { Request, Response, NextFunction } from 'express'
import cors from 'cors'

const app = express()
app.use(express.json())
app.use(cors())

let users = [
  { id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' },
  { id: 2, name: 'Bob', email: 'bob@example.com', role: 'user' },
]

// 認証ミドルウェアのStub
const authMiddleware = (req: Request, res: Response, next: NextFunction) => {
  const token = req.headers.authorization?.replace('Bearer ', '')
  if (!token || token !== 'valid-test-token') {
    return res.status(401).json({ error: 'Unauthorized' })
  }
  next()
}

app.get('/api/health', (_req: Request, res: Response) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() })
})

app.get('/api/users', authMiddleware, (_req: Request, res: Response) => {
  res.json({ data: users, total: users.length })
})

app.get('/api/users/:id', authMiddleware, (req: Request, res: Response) => {
  const user = users.find((u) => u.id === parseInt(req.params.id))
  if (!user) return res.status(404).json({ error: 'ユーザーが見つかりません' })
  res.json(user)
})

app.post('/api/users', authMiddleware, (req: Request, res: Response) => {
  const { name, email, role = 'user' } = req.body
  if (!name || !email) {
    return res.status(400).json({ error: 'nameとemailは必須です' })
  }
  const newUser = { id: Date.now(), name, email, role }
  users.push(newUser)
  res.status(201).json(newUser)
})

app.delete('/api/users/:id', authMiddleware, (req: Request, res: Response) => {
  const id = parseInt(req.params.id)
  const index = users.findIndex((u) => u.id === id)
  if (index === -1) return res.status(404).json({ error: 'ユーザーが見つかりません' })
  users.splice(index, 1)
  res.status(204).send()
})

// テスト用データリセットエンドポイント
app.post('/api/__reset', (_req, res) => {
  users = [
    { id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' },
    { id: 2, name: 'Bob', email: 'bob@example.com', role: 'user' },
  ]
  res.json({ message: 'データをリセットしました' })
})

export { app }

if (require.main === module) {
  app.listen(3001, () => {
    console.log('モックサーバーが http://localhost:3001 で起動しました')
  })
}

7. 統合テストとE2E

7.1 SupertestでExpressアプリをテスト

import request from 'supertest'
import { app } from '../mock-server/server'

describe('User API 統合テスト', () => {
  const authHeader = { Authorization: 'Bearer valid-test-token' }

  beforeEach(async () => {
    await request(app).post('/api/__reset')
  })

  it('GET /api/users - ユーザー一覧を取得する', async () => {
    const response = await request(app).get('/api/users').set(authHeader).expect(200)

    expect(response.body.data).toHaveLength(2)
    expect(response.body.total).toBe(2)
  })

  it('POST /api/users - ユーザーを作成する', async () => {
    const response = await request(app)
      .post('/api/users')
      .set(authHeader)
      .send({ name: 'Charlie', email: 'charlie@example.com' })
      .expect(201)

    expect(response.body.name).toBe('Charlie')
    expect(response.body.id).toBeDefined()
  })

  it('認証なしでアクセスすると401を返す', async () => {
    await request(app).get('/api/users').expect(401)
  })
})

7.2 Testing Library (@testing-library/react)

import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserList } from '../components/UserList'

const server = setupServer(
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: 'Alice', email: 'alice@example.com' },
    ])
  })
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

describe('UserListコンポーネント', () => {
  it('ユーザー一覧をレンダリングする', async () => {
    render(<UserList />)

    // ローディング状態を確認
    expect(screen.getByText('読み込み中...')).toBeInTheDocument()

    // データ読み込み後を確認
    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument()
    })
  })

  it('ユーザーをクリックすると詳細ページに遷移する', async () => {
    const user = userEvent.setup()
    render(<UserList />)

    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument()
    })

    await user.click(screen.getByText('Alice'))
    expect(screen.getByRole('heading', { name: 'ユーザー詳細' })).toBeInTheDocument()
  })
})

8. TDD実践例

8.1 Red-Green-Refactorサイクル

TDDの核心はテストを先に書くことです。

  1. Red: 失敗するテストを書く
  2. Green: テストを通過する最小限のコードを書く
  3. Refactor: コード品質を改善する(テストは引き続きパスすること)

8.2 ショッピングカートのTDD完全例

// Step 1 (Red): テストを先に書く
// src/domain/__tests__/shoppingCart.test.ts
import { describe, it, expect } from 'vitest'
import { ShoppingCart, CartItem } from '../shoppingCart'

describe('ShoppingCart', () => {
  let cart: ShoppingCart

  beforeEach(() => {
    cart = new ShoppingCart()
  })

  describe('アイテムの追加', () => {
    it('アイテムを追加できる', () => {
      cart.addItem({ id: 1, name: 'リンゴ', price: 100, quantity: 1 })
      expect(cart.items).toHaveLength(1)
    })

    it('同じアイテムを追加すると数量が増える', () => {
      cart.addItem({ id: 1, name: 'リンゴ', price: 100, quantity: 1 })
      cart.addItem({ id: 1, name: 'リンゴ', price: 100, quantity: 2 })
      expect(cart.items[0].quantity).toBe(3)
    })
  })

  describe('アイテムの削除', () => {
    it('アイテムを削除できる', () => {
      cart.addItem({ id: 1, name: 'リンゴ', price: 100, quantity: 1 })
      cart.removeItem(1)
      expect(cart.items).toHaveLength(0)
    })

    it('存在しないアイテムを削除するとエラーを投げる', () => {
      expect(() => cart.removeItem(999)).toThrow('アイテムが見つかりません')
    })
  })

  describe('合計金額の計算', () => {
    it('空のカートは0円', () => {
      expect(cart.total).toBe(0)
    })

    it('複数アイテムの合計を計算する', () => {
      cart.addItem({ id: 1, name: 'リンゴ', price: 100, quantity: 2 })
      cart.addItem({ id: 2, name: 'バナナ', price: 50, quantity: 3 })
      expect(cart.total).toBe(350)
    })
  })

  describe('割引の適用', () => {
    it('10%の割引を適用する', () => {
      cart.addItem({ id: 1, name: 'リンゴ', price: 1000, quantity: 1 })
      cart.applyDiscount(10)
      expect(cart.discountedTotal).toBe(900)
    })

    it('100%超の割引は許可しない', () => {
      expect(() => cart.applyDiscount(101)).toThrow('割引率は0〜100の範囲でなければなりません')
    })
  })

  describe('クーポンの適用', () => {
    it('固定金額クーポンを適用する', () => {
      cart.addItem({ id: 1, name: 'リンゴ', price: 1000, quantity: 1 })
      cart.applyCoupon({ type: 'fixed', amount: 200 })
      expect(cart.discountedTotal).toBe(800)
    })

    it('パーセントクーポンを適用する', () => {
      cart.addItem({ id: 1, name: 'リンゴ', price: 1000, quantity: 1 })
      cart.applyCoupon({ type: 'percent', amount: 20 })
      expect(cart.discountedTotal).toBe(800)
    })
  })
})

// Step 2 (Green): テストを通過する実装
// src/domain/shoppingCart.ts
export interface CartItem {
  id: number
  name: string
  price: number
  quantity: number
}

type Coupon = { type: 'fixed'; amount: number } | { type: 'percent'; amount: number }

export class ShoppingCart {
  private _items: CartItem[] = []
  private _discountRate = 0
  private _coupon?: Coupon

  get items(): CartItem[] {
    return [...this._items]
  }

  addItem(item: CartItem): void {
    const existing = this._items.find((i) => i.id === item.id)
    if (existing) {
      existing.quantity += item.quantity
    } else {
      this._items.push({ ...item })
    }
  }

  removeItem(id: number): void {
    const index = this._items.findIndex((i) => i.id === id)
    if (index === -1) throw new Error('アイテムが見つかりません')
    this._items.splice(index, 1)
  }

  get total(): number {
    return this._items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  }

  applyDiscount(rate: number): void {
    if (rate < 0 || rate > 100) {
      throw new Error('割引率は0〜100の範囲でなければなりません')
    }
    this._discountRate = rate
  }

  applyCoupon(coupon: Coupon): void {
    this._coupon = coupon
  }

  get discountedTotal(): number {
    let total = this.total * (1 - this._discountRate / 100)

    if (this._coupon) {
      if (this._coupon.type === 'fixed') {
        total = Math.max(0, total - this._coupon.amount)
      } else {
        total = total * (1 - this._coupon.amount / 100)
      }
    }

    return Math.round(total)
  }

  clear(): void {
    this._items = []
    this._discountRate = 0
    this._coupon = undefined
  }
}

9. クイズ

クイズ1: jest.fn()の呼び出し検証

以下のコードを実行した後にパスするアサーションはどれですか?

const fn = jest
  .fn()
  .mockReturnValueOnce('first')
  .mockReturnValueOnce('second')
  .mockReturnValue('default')

fn('a')
fn('b', 'c')
fn()

選択肢: (A) expect(fn).toHaveBeenCalledTimes(2) (B) expect(fn.mock.calls[1]).toEqual(['b', 'c']) (C) expect(fn.mock.results[2].value).toBe('first') (D) expect(fn).toHaveBeenLastCalledWith('a')

正解: (B)

解説: fnは3回呼び出されているので(A)は失敗。fn.mock.calls[1]は2回目の呼び出しの引数配列['b', 'c']なので(B)はパス。fn.mock.results[2].valueは3回目の呼び出し結果'default'なので(C)は失敗。最後の呼び出しは引数なしfn()なので(D)は失敗。

クイズ2: clearAllMocksとresetAllMocksの違い

以下のコードでjest.clearAllMocks()jest.resetAllMocks()の呼び出し後の違いを説明してください。

const mock = jest.fn().mockReturnValue(42)
mock('hello')

// clearAllMocksまたはresetAllMocksを呼び出した後...
console.log(mock())                      // ?
expect(mock).toHaveBeenCalledTimes(???)  // ?

正解: clearAllMocks後: mock()は42を返す、CalledTimesは1。resetAllMocks後: mock()はundefinedを返す、CalledTimesは1。

解説: clearAllMocksは呼び出し履歴(mock.calls、mock.results)のみリセットし、mockReturnValueの実装は維持します。resetAllMocksは実装もリセットするためundefinedを返します。どちらの場合もリセット後に新しくmock()を1回呼び出しているのでCalledTimesは1です。

クイズ3: MSWリクエストのインターセプト

MSWで特定のテストのみ一時的に異なるレスポンスを返したい場合はどのメソッドを使うべきか?

正解: server.use(...handlers)を使用

解説: server.use()は既存のハンドラーを上書きします。テストスイートのafterEachserver.resetHandlers()を呼び出すと、そのテスト後に元のハンドラーに戻ります。このパターンがMSWの核心的な使用法です。

クイズ4: StubとMockの違い

StubとMockの正しい違いはどれか?

(A) Stubは実際のロジックを含み、Mockは含まない (B) Stubは事前に定義されたレスポンスを返し、Mockは呼び出し検証機能も含む (C) Stubはクラスにのみ使用し、Mockは関数にのみ使用する (D) StubとMockは完全な同義語である

正解: (B)

解説: Stubはテスト対象のコードに必要なデータを提供する役割(事前定義されたレスポンス)を担います。Mockはその機能に加えて「どのように呼び出されたか」を検証する機能(toHaveBeenCalledWithなど)も含みます。現代のテストフレームワークはほとんどの場合、同じAPIで両方の機能を提供しています。

クイズ5: TDDサイクル

TDDのRed-Green-RefactorサイクルにおけるGreenフェーズの正しい原則はどれか?

(A) できるだけ完璧で拡張可能なコードを書く (B) テストを通過する最小限のコードだけを書く (C) 全てのエッジケースをあらかじめ処理する (D) コードを書く前に設計ドキュメントを完成させる

正解: (B)

解説: Greenフェーズの核心は「動くコード」です。テストを通過する最小限のコードを書きます。コード品質の改善はRefactorフェーズで、新しい機能やエッジケースの処理は新しいRedフェーズ(テストの追加)で行います。過剰設計はTDDの反対です。


まとめ

テストは開発速度を落とすものではなく、長期的に開発速度を上げるための投資です。TypeScriptと組み合わせると、型安全性とテストカバレッジが相乗効果を発揮します。

重要なポイント:

  • 単体テストは高速で分離されていること - 外部依存はMock/Stubで置き換える
  • MSWはネットワークリクエストテストの新標準 - 実際のfetch/axiosコードをそのままテスト
  • TDDは設計ツール - インターフェース(テスト)を先に定義し、実装は後
  • clearAllMocksresetAllMocksの違いを理解して適切に使用
  • 統合テストは複数のモジュールが正しく連携して動作するかの検証に集中