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' for browser-like tests
    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 (전역으로 타입 주입 - jest.config.ts types 설정 필요)
// tsconfig.json: "types": ["jest"]
describe('example', () => {
  it('works', () => {
    expect(true).toBe(true)
  })
})

// 방법 2: @jest/globals (명시적 import - 더 안전)
import { describe, it, expect, jest } from '@jest/globals'

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

// Vitest에서는 globals: true 설정 시 import 불필요
// 또는 명시적으로:
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('0으로 나눌 수 없습니다')
  return a / b
}

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

describe('Calculator', () => {
  describe('add', () => {
    it('두 양수를 더한다', () => {
      expect(add(2, 3)).toBe(5)
    })

    it('음수를 더한다', () => {
      expect(add(-1, -2)).toBe(-3)
    })

    it('0을 더한다', () => {
      expect(add(5, 0)).toBe(5)
    })
  })

  describe('divide', () => {
    it('나누기를 수행한다', () => {
      expect(divide(10, 2)).toBe(5)
    })

    it('0으로 나누면 에러를 던진다', () => {
      expect(() => divide(10, 0)).toThrow('0으로 나눌 수 없습니다')
    })

    it('소수점 결과를 반환한다', () => {
      expect(divide(1, 3)).toBeCloseTo(0.333, 2)
    })
  })
})

2.2 라이프사이클 훅

import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'

describe('Database Tests', () => {
  let db: MockDatabase

  beforeAll(async () => {
    // 전체 테스트 스위트 시작 전 한 번 실행
    db = await MockDatabase.connect('test-db')
    await db.migrate()
  })

  afterAll(async () => {
    // 전체 테스트 스위트 종료 후 한 번 실행
    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('Matchers 예시', () => {
  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 Mock 완전 정복

3.1 jest.fn() - 함수 모킹

import { jest } from '@jest/globals'

// 기본 mock 함수
const mockFn = jest.fn()
mockFn('hello')
expect(mockFn).toHaveBeenCalledWith('hello')
expect(mockFn).toHaveBeenCalledTimes(1)

// 반환값 설정
const mockAdd = jest
  .fn()
  .mockReturnValue(10) // 항상 10 반환
  .mockReturnValueOnce(5) // 첫 번째 호출 시 5 반환
  .mockReturnValueOnce(7) // 두 번째 호출 시 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' }])
expect(spy.mock.results[0].value).toBeUndefined()

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'

// fetchUser는 자동으로 jest.fn()이 됨
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')),
}))

// ES module default export 모킹
jest.mock('../services/emailService', () => ({
  default: {
    send: jest.fn().mockResolvedValue(true),
  },
}))

3.4 Mock 리셋 메서드 차이

describe('Mock 리셋 차이점', () => {
  const mockFn = jest.fn().mockReturnValue(42)

  afterEach(() => {
    // jest.clearAllMocks(): mock.calls, mock.instances, mock.results 초기화
    // 하지만 mockReturnValue 같은 구현은 유지
    jest.clearAllMocks()

    // jest.resetAllMocks(): clearAllMocks + mockReturnValue 등 구현도 초기화
    // jest.resetAllMocks()

    // jest.restoreAllMocks(): spyOn으로 만든 spy를 원래 구현으로 복원
    // 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') 한 줄만 추가
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)
  }),

  // 경로 파라미터
  http.get('/api/users/:id', ({ params }) => {
    const { id } = params
    const user = users.find((u) => u.id === Number(id))
    if (!user) {
      return HttpResponse.json({ error: 'User not found' }, { 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/PATCH 요청
  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: 'User not found' }, { 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: 'User not found' }, { 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'
import { beforeAll, afterAll, afterEach } from 'vitest'

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('Failed to fetch users')
  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('Failed to fetch users')
  })
})

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

// 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" }]
}
// json-server.config.js
module.exports = {
  port: 3001,
  routes: {
    '/api/*': '/$1', // /api/users -> /users
  },
  middlewares: ['logger'],
}
# 실행
npx json-server db.json --config json-server.config.js
# GET    /api/users
# GET    /api/users/1
# POST   /api/users
# PUT    /api/users/1
# DELETE /api/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()
}

// Routes
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: 'User not found' })
  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 and email are required' })
  }
  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: 'User not found' })
  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: 'Data reset' })
})

export { app }

// 직접 실행 시
if (require.main === module) {
  app.listen(3001, () => {
    console.log('Mock server running on http://localhost:3001')
  })
}

7. 통합 테스트와 E2E

7.1 Supertest로 Express 앱 테스트

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

describe('User API Integration Tests', () => {
  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)

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

// MSW 서버 설정 (위에서 설명한 것과 동일)
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 Component', () => {
  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: 1000, quantity: 1 })
      expect(cart.items).toHaveLength(1)
    })

    it('같은 아이템을 추가하면 수량이 증가한다', () => {
      cart.addItem({ id: 1, name: '사과', price: 1000, quantity: 1 })
      cart.addItem({ id: 1, name: '사과', price: 1000, quantity: 2 })
      expect(cart.items[0].quantity).toBe(3)
    })
  })

  describe('아이템 제거', () => {
    it('아이템을 제거할 수 있다', () => {
      cart.addItem({ id: 1, name: '사과', price: 1000, 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: 1000, quantity: 2 })
      cart.addItem({ id: 2, name: '바나나', price: 500, quantity: 3 })
      expect(cart.total).toBe(3500)
    })
  })

  describe('할인 적용', () => {
    it('10% 할인을 적용한다', () => {
      cart.addItem({ id: 1, name: '사과', price: 10000, quantity: 1 })
      cart.applyDiscount(10)
      expect(cart.discountedTotal).toBe(9000)
    })

    it('100% 초과 할인은 허용하지 않는다', () => {
      expect(() => cart.applyDiscount(101)).toThrow('할인율은 0-100 사이여야 합니다')
    })
  })

  describe('쿠폰 적용', () => {
    it('고정 금액 쿠폰을 적용한다', () => {
      cart.addItem({ id: 1, name: '사과', price: 10000, quantity: 1 })
      cart.applyCoupon({ type: 'fixed', amount: 2000 })
      expect(cart.discountedTotal).toBe(8000)
    })

    it('퍼센트 쿠폰을 적용한다', () => {
      cart.addItem({ id: 1, name: '사과', price: 10000, quantity: 1 })
      cart.applyCoupon({ type: 'percent', amount: 20 })
      expect(cart.discountedTotal).toBe(8000)
    })
  })
})

// 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() 호출 검증

다음 코드의 실행 결과로 통과하는 assertion은?

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]은 두 번째 호출의 인수 배열 ['b', 'c']이므로 (B)는 통과. fn.mock.results[2].value는 세 번째 호출 결과 'default'이므로 (C)는 실패. 마지막 호출은 인수 없이 fn()이므로 (D)는 실패.

퀴즈 2: clearAllMocks vs 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는 구현까지 초기화하여 mock 함수가 undefined를 반환합니다. 두 경우 모두 reset/clear 이후 새로 mock()을 1번 호출했으므로 CalledTimes는 1입니다.

퀴즈 3: MSW 요청 가로채기

MSW에서 특정 테스트에서만 일시적으로 다른 응답을 반환하려면 어떤 메서드를 사용해야 하는가?

정답: server.use(...handlers) 사용

설명: server.use()는 기존 핸들러를 재정의합니다. 테스트 스위트의 afterEachserver.resetHandlers()를 호출하면 해당 테스트 이후 원래 핸들러로 복원됩니다. 이 패턴이 MSW의 핵심 사용법입니다.

퀴즈 4: Stub vs Mock 차이

다음 중 Stub와 Mock의 올바른 차이점은?

(A) Stub은 실제 로직을 포함하고 Mock은 포함하지 않는다 (B) Stub은 사전에 정해진 응답을 반환하고, Mock은 호출 검증 기능도 포함한다 (C) Stub은 클래스에만 사용하고 Mock은 함수에만 사용한다 (D) Stub과 Mock은 완전히 동의어이다

정답: (B)

설명: Stub은 테스트 대상 코드에 필요한 데이터를 제공하는 역할(사전 정의된 응답)을 합니다. Mock은 Stub 기능에 더해 '어떻게 호출되었는가'를 검증하는 기능(toHaveBeenCalledWith 등)까지 포함합니다. 현대의 테스트 프레임워크는 대부분 두 기능을 모두 제공합니다.

퀴즈 5: TDD 사이클

TDD의 Red-Green-Refactor 사이클에서 Green 단계의 올바른 원칙은?

(A) 가능한 한 완벽하고 확장 가능한 코드를 작성한다 (B) 테스트를 통과하는 최소한의 코드만 작성한다 (C) 모든 엣지 케이스를 미리 처리한다 (D) 코드를 작성하기 전에 설계 문서를 완성한다

정답: (B)

설명: Green 단계의 핵심은 "작동하는 코드"입니다. 지금 당장 테스트를 통과하는 최소한의 코드를 작성합니다. 코드 품질 개선은 Refactor 단계에서, 새로운 기능과 엣지 케이스 처리는 새로운 Red 단계(테스트 추가)에서 다룹니다. 과도한 설계는 TDD의 반대입니다.


마치며

테스팅은 개발 속도를 늦추는 것이 아니라 장기적으로 개발 속도를 높이는 투자입니다. 특히 TypeScript와 함께 사용하면 타입 안전성과 테스트 커버리지가 시너지를 발휘합니다.

핵심 정리:

  • 단위 테스트는 빠르고 격리되어야 함 - 외부 의존성은 Mock/Stub으로 교체
  • MSW는 네트워크 요청 테스트의 새로운 표준 - 실제 fetch/axios 코드를 그대로 테스트
  • TDD는 설계 도구 - 먼저 인터페이스(테스트)를 정의하고 구현은 나중에
  • clearAllMocksresetAllMocks의 차이를 이해하고 적절히 사용
  • 통합 테스트는 여러 모듈이 함께 동작하는지 검증하는 데 집중