๐Ÿš€ Next.js ์‹ฌํ™” 9์žฅ: Testing Strategy โ€” Jest + MSW + Playwright ์‹ค์ „ ํ…Œ์ŠคํŠธ

๐Ÿ“‹ ๊ฐœ์š”

Jest + MSW + Playwright๋กœ Next.js ์•ฑ์„ ๋‹จ์œ„ยทํ†ตํ•ฉยทE2E ํ…Œ์ŠคํŠธํ•˜๋Š” ์ „๋žต์„ ์ •๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ“‹ ๋ชฉ์ฐจ


๐Ÿ“Œ ์ด ๋ฌธ์„œ๋ฅผ ์ฝ๊ธฐ ์ „์—

โฑ๏ธ ์˜ˆ์ƒ ์ฝ๊ธฐ ์‹œ๊ฐ„: 20๋ถ„ (์ „์ฒด) / ํ•ต์‹ฌ ํŒŒํŠธ๋งŒ: 10๋ถ„

๐Ÿ—บ๏ธ ์ด ๋ฌธ์„œ์˜ ๋ฐฐ๊ฒฝ ์„ธ๊ณ„๊ด€: '์˜์ˆ˜๋„ค ์ปค๋ฎค๋‹ˆํ‹ฐ'

  • ์˜์ฒ (์‹ ์ž…): "์ฝ”๋“œ ์งœ๊ณ  ๋‚˜์„œ ์ƒˆ ๊ธฐ๋Šฅ ์ถ”๊ฐ€ํ•  ๋•Œ๋งˆ๋‹ค ์ด์ „ ๊ธฐ๋Šฅ์ด ๊นจ์ง€๋Š”์ง€ ์†์œผ๋กœ ๋‹ค ํด๋ฆญํ•ด์„œ ํ™•์ธํ•˜๊ณ  ์žˆ์–ด์š”. ๊ธฐ๋Šฅ์ด ๋Š˜์–ด๋‚ ์ˆ˜๋ก ํ™•์ธํ•ด์•ผ ํ•  ๊ฒŒ ๋„ˆ๋ฌด ๋งŽ์•„์„œ ๋น ๋œจ๋ฆฌ๋Š” ๊ฒƒ๋„ ์ƒ๊ธฐ๊ณ , ๋ฐฐํฌํ•  ๋•Œ๋งˆ๋‹ค ๋‘๋ ค์›Œ์š”."
  • ์˜ํ˜ธ(๋ฆฌ๋“œ): "์˜์ฒ  ๋‹˜, ๊ทธ๊ฒŒ ๋ฐ”๋กœ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๊ฐ€ ํ•„์š”ํ•œ ์ด์œ ์˜ˆ์š”. '๊ธฐ์กด ๊ธฐ๋Šฅ์ด ์ƒˆ ์ฝ”๋“œ์—๋„ ์—ฌ์ „ํžˆ ์ž˜ ๋™์ž‘ํ•˜๋Š”๊ฐ€'๋ฅผ ์ž๋™์œผ๋กœ ๊ฒ€์ฆํ•ด์ฃผ๋Š” ๊ฒŒ ํ…Œ์ŠคํŠธ์˜ˆ์š”. ์ฒ˜์Œ ์งœ๋Š” ์‹œ๊ฐ„๋ณด๋‹ค ๋‚˜์ค‘์— ๋ฒ„๊ทธ ์žก๋Š” ์‹œ๊ฐ„์ด ํ›จ์”ฌ ๋” ๊ธธ์–ด์š”. ํ…Œ์ŠคํŠธ๊ฐ€ ์žˆ์œผ๋ฉด ๋ฆฌํŒฉํ† ๋ง๋„, ์ƒˆ ๊ธฐ๋Šฅ ์ถ”๊ฐ€๋„ ๋‘๋ ต์ง€ ์•Š์•„์š”."

๐Ÿ—บ๏ธ ์ด ๋ฌธ์„œ์˜ ํ๋ฆ„
ํ…Œ์ŠคํŠธ ํ”ผ๋ผ๋ฏธ๋“œ ์ „๋žต โ†’ ํ™˜๊ฒฝ ์„ค์ • โ†’ ์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ โ†’ Server Action ํ…Œ์ŠคํŠธ โ†’ MSW API ๋ชจํ‚น โ†’ E2E ํ…Œ์ŠคํŠธ

๐ŸŽฏ ์ด ๋ฌธ์„œ๋ฅผ ๋‹ค ์ฝ์œผ๋ฉด ํ•  ์ˆ˜ ์žˆ๋Š” ๊ฒƒ

  • Jest + Testing Library๋กœ ์„œ๋ฒ„/ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ๋‹ค
  • MSW๋กœ API๋ฅผ ๋ชจํ‚นํ•ด ์™ธ๋ถ€ ์˜์กด์„ฑ ์—†์ด ํ…Œ์ŠคํŠธ๋ฅผ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๋‹ค
  • Playwright๋กœ ์‹ค์ œ ๋ธŒ๋ผ์šฐ์ € E2E ํ…Œ์ŠคํŠธ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค

๐Ÿค” ์™œ ์•Œ์•„์•ผ ํ•˜๋Š”๊ฐ€

ํ…Œ์ŠคํŠธ ์—†์ด ๊ฐœ๋ฐœํ•˜๋ฉด ์ด๋Ÿฐ ์ผ์ด ์ƒ๊ฒจ:

  1. "๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ ๊ณ ์ณค๋Š”๋ฐ ๋Œ“๊ธ€ ๊ธฐ๋Šฅ์ด ์™œ ๊นจ์ง€์ง€?" โ€” ํšŒ๊ท€ ๋ฒ„๊ทธ
  2. "๋ฆฌํŒฉํ† ๋งํ–ˆ๋‹ค๊ฐ€ API ์‘๋‹ต ํ˜•์‹์ด ๋ฐ”๋€ ์ค„ ๋ชจ๋ฅด๊ณ  ๋ฐฐํฌ" โ€” ํ”„๋กœ๋•์…˜ ์žฅ์• 
  3. "์ด ํ•จ์ˆ˜ ๊ฑด๋“œ๋ฆฌ๋ฉด ์–ด๋””๊ฐ€ ๊นจ์ง€๋Š”์ง€ ๋ชฐ๋ผ์„œ ์ฝ”๋“œ๋ฅผ ๋ชป ๋ฐ”๊พธ๊ฒ ์–ด" โ€” ์ฝ”๋“œ ๊ฒฝ์ง

ํ…Œ์ŠคํŠธ๋Š” "๋‚ด๊ฐ€ ๋งŒ๋“  ๊ฒŒ ์ œ๋Œ€๋กœ ๋™์ž‘ํ•œ๋‹ค๋Š” ์ฆ๊ฑฐ"์ด์ž "์ด ์ฝ”๋“œ๋ฅผ ๋ฐ”๊ฟ”๋„ ๊ดœ์ฐฎ๋‹ค๋Š” ์ž์‹ ๊ฐ"์ด์•ผ.


๐Ÿ—๏ธ ๋น„์œ ๋กœ ๋จผ์ € ์ดํ•ดํ•˜๊ธฐ

๐Ÿง’ 5์‚ด์—๊ฒŒ ์„ค๋ช…ํ•œ๋‹ค๋ฉด?
์ž๋™์ฐจ ๊ณต์žฅ ๊ฒ€์‚ฌ ๋ผ์ธ์„ ์ƒ๊ฐํ•ด๋ด. ์ฐจ๋ฅผ ์™„์„ฑํ•˜๊ณ  ๋‚˜์„œ:

  • ๊ฐ ๋ถ€ํ’ˆ ๊ฒ€์‚ฌ (๋‹จ์œ„ ํ…Œ์ŠคํŠธ) โ€” ๋ธŒ๋ ˆ์ดํฌ ํŒจ๋“œ๊ฐ€ ๊ทœ๊ฒฉ์— ๋งž๋Š”๊ฐ€
  • ์กฐ๋ฆฝ ๊ฒ€์‚ฌ (ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ) โ€” ๋ธŒ๋ ˆ์ดํฌ ์—ฐ๊ฒฐ์ด ์ œ๋Œ€๋กœ ๋๋Š”๊ฐ€
  • ์‹œ์šด์ „ ๊ฒ€์‚ฌ (E2E ํ…Œ์ŠคํŠธ) โ€” ์‹ค์ œ๋กœ ๋‹ฌ๋ ค๋ณด๊ณ , ๋ธŒ๋ ˆ์ดํฌ ๋ฐŸ์œผ๋ฉด ์„œ๋Š”๊ฐ€

ํ…Œ์ŠคํŠธ๋„ ๋˜‘๊ฐ™์•„. ๋ถ€ํ’ˆ๋ถ€ํ„ฐ ์ „์ฒด ๊ธฐ๋Šฅ๊นŒ์ง€ ๋‹จ๊ณ„๋ณ„๋กœ ๊ฒ€์ฆํ•˜๋Š” ๊ฑฐ์•ผ.


๐Ÿงฉ ํ…Œ์ŠคํŠธ ํ”ผ๋ผ๋ฏธ๋“œ โ€” ์–ด๋–ค ํ…Œ์ŠคํŠธ๋ฅผ ์–ผ๋งˆ๋‚˜ ๐ŸŸข

๐ŸŽฏ ์ด ์„น์…˜์„ ์ฝ๊ณ  ๋‚˜๋ฉด:

  • ๋‹จ์œ„/ํ†ตํ•ฉ/E2E ํ…Œ์ŠคํŠธ์˜ ์ฐจ์ด์™€ ์ ์ ˆํ•œ ๋น„์œจ์„ ์•Œ ์ˆ˜ ์žˆ๋‹ค
      /\
     /E2E\        ์ ์Œ: ๋А๋ฆฌ๊ณ  ๋น„์‹ธ์ง€๋งŒ ์‹ค์ œ ์‚ฌ์šฉ์ž ์‹œ๋‚˜๋ฆฌ์˜ค
    /------\
   /ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ\   ๋ณดํ†ต: ์ปดํฌ๋„ŒํŠธ ๊ฐ„ ์ƒํ˜ธ์ž‘์šฉ
  /------------\
 /  ๋‹จ์œ„ ํ…Œ์ŠคํŠธ  \  ๋งŽ์Œ: ๋น ๋ฅด๊ณ  ๋…๋ฆฝ์ ์ธ ํ•จ์ˆ˜/์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ
/________________\
ํ…Œ์ŠคํŠธ ์ข…๋ฅ˜๋น ๋ฅด๊ธฐ์‹ ๋ขฐ์„ฑ๋ฌด์—‡์„ ํ…Œ์ŠคํŠธ๋„๊ตฌ
๋‹จ์œ„(Unit)๋งค์šฐ ๋น ๋ฆ„๋ถ€๋ถ„์ ๊ฐœ๋ณ„ ํ•จ์ˆ˜, ์ปดํฌ๋„ŒํŠธJest + Testing Library
ํ†ตํ•ฉ(Integration)๋ณดํ†ต๋†’์Œ์ปดํฌ๋„ŒํŠธ + API ์ƒํ˜ธ์ž‘์šฉJest + MSW
E2E๋А๋ฆผ๋งค์šฐ ๋†’์Œ์‹ค์ œ ์‚ฌ์šฉ์ž ์‹œ๋‚˜๋ฆฌ์˜คPlaywright

โš™๏ธ ํ™˜๊ฒฝ ์„ค์ • โ€” Jest + Testing Library ๐ŸŸข

# ์„ค์น˜
npm install -D jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
npm install -D ts-jest @types/jest
// jest.config.ts
import type { Config } from 'jest'
import nextJest from 'next/jest.js'
 
const createJestConfig = nextJest({
  dir: './',  // Next.js ๋ฃจํŠธ ๋””๋ ‰ํ† ๋ฆฌ (next.config.ts, .env ์ฝ๊ธฐ์šฉ)
})
 
const config: Config = {
  coverageProvider: 'v8',
  testEnvironment: 'jsdom',    // ๋ธŒ๋ผ์šฐ์ € ํ™˜๊ฒฝ ์‹œ๋ฎฌ๋ ˆ์ด์…˜
  setupFilesAfterFramework: ['<rootDir>/jest.setup.ts'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/$1',  // @ ๊ฒฝ๋กœ ๋ณ„์นญ ๋งคํ•‘
  },
}
 
export default createJestConfig(config)
// jest.setup.ts
import '@testing-library/jest-dom'  // toBeInTheDocument() ๋“ฑ DOM ๋งค์ฒ˜ ์ถ”๊ฐ€

๐Ÿ”ฌ ๋‹จ์œ„ยทํ†ตํ•ฉ ํ…Œ์ŠคํŠธ โ€” ์ปดํฌ๋„ŒํŠธ์™€ Server Action ๐ŸŸก

๐ŸŽฏ ์ด ์„น์…˜์„ ์ฝ๊ณ  ๋‚˜๋ฉด:

  • ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ์˜ ๋ Œ๋”๋ง๊ณผ ์‚ฌ์šฉ์ž ์ด๋ฒคํŠธ๋ฅผ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ๋‹ค
  • Server Action์„ ์ง์ ‘ importํ•ด์„œ ๋กœ์ง์„ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ๋‹ค

๐Ÿ–ฅ๏ธ ํด๋ผ์ด์–ธํŠธ ์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ

// __tests__/components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LoginForm } from '@/components/features/auth/LoginForm'
 
describe('LoginForm', () => {
  it('์ด๋ฉ”์ผ๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ ์ž…๋ ฅ ํ•„๋“œ๊ฐ€ ๋ Œ๋”๋ผ์•ผ ํ•œ๋‹ค', () => {
    render(<LoginForm />)
 
    expect(screen.getByLabelText('์ด๋ฉ”์ผ')).toBeInTheDocument()
    expect(screen.getByLabelText('๋น„๋ฐ€๋ฒˆํ˜ธ')).toBeInTheDocument()
    expect(screen.getByRole('button', { name: '๋กœ๊ทธ์ธ' })).toBeInTheDocument()
  })
 
  it('๋นˆ ํผ์„ ์ œ์ถœํ•˜๋ฉด ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ์—๋Ÿฌ๊ฐ€ ํ‘œ์‹œ๋ผ์•ผ ํ•œ๋‹ค', async () => {
    const user = userEvent.setup()
    render(<LoginForm />)
 
    // ์•„๋ฌด๊ฒƒ๋„ ์ž…๋ ฅ ์•ˆ ํ•˜๊ณ  ์ œ์ถœ
    await user.click(screen.getByRole('button', { name: '๋กœ๊ทธ์ธ' }))
 
    expect(screen.getByText('์ด๋ฉ”์ผ์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”')).toBeInTheDocument()
  })
 
  it('์œ ํšจํ•œ ์ด๋ฉ”์ผ๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•˜๋ฉด ์ œ์ถœ ๋ฒ„ํŠผ์ด ํ™œ์„ฑํ™”๋ผ์•ผ ํ•œ๋‹ค', async () => {
    const user = userEvent.setup()
    render(<LoginForm />)
 
    await user.type(screen.getByLabelText('์ด๋ฉ”์ผ'), 'test@example.com')
    await user.type(screen.getByLabelText('๋น„๋ฐ€๋ฒˆํ˜ธ'), 'password123')
 
    expect(screen.getByRole('button', { name: '๋กœ๊ทธ์ธ' })).not.toBeDisabled()
  })
})

โš™๏ธ Server Action ํ…Œ์ŠคํŠธ (๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง)

Server Action์˜ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์€ ์ง์ ‘ ํ•จ์ˆ˜๋ฅผ importํ•ด์„œ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ์–ด.

// __tests__/lib/actions/post.actions.test.ts
import { createPost } from '@/lib/actions/post.actions'
import { db } from '@/lib/db'
import { getSession } from '@/lib/session'
 
// DB์™€ ์„ธ์…˜์„ ๋ชจํ‚น (์‹ค์ œ DB ์—†์ด ํ…Œ์ŠคํŠธ)
jest.mock('@/lib/db', () => ({
  db: {
    post: {
      create: jest.fn(),
    },
  },
}))
 
jest.mock('@/lib/session', () => ({
  getSession: jest.fn(),
}))
 
const mockDb = db as jest.Mocked<typeof db>
const mockGetSession = getSession as jest.Mock
 
describe('createPost Server Action', () => {
  beforeEach(() => {
    jest.clearAllMocks()
    // ๊ธฐ๋ณธ: ๋กœ๊ทธ์ธ ์ƒํƒœ๋กœ ์„ค์ •
    mockGetSession.mockResolvedValue({ userId: 'user-1', role: 'user' })
  })
 
  it('์œ ํšจํ•œ ๋ฐ์ดํ„ฐ๋กœ ๊ฒŒ์‹œ๊ธ€์„ ์ƒ์„ฑํ•ด์•ผ ํ•œ๋‹ค', async () => {
    const expectedPost = { id: 'post-1', title: 'ํ…Œ์ŠคํŠธ ๊ฒŒ์‹œ๊ธ€', content: '๋‚ด์šฉ' }
    mockDb.post.create.mockResolvedValue(expectedPost as any)
 
    const formData = new FormData()
    formData.set('title', 'ํ…Œ์ŠคํŠธ ๊ฒŒ์‹œ๊ธ€')
    formData.set('content', '๋‚ด์šฉ')
 
    const result = await createPost(null, formData)
 
    expect(mockDb.post.create).toHaveBeenCalledWith({
      data: { title: 'ํ…Œ์ŠคํŠธ ๊ฒŒ์‹œ๊ธ€', content: '๋‚ด์šฉ', authorId: 'user-1' },
    })
    expect(result).toEqual({ success: true })
  })
 
  it('๋กœ๊ทธ์ธ ์•ˆ ํ•œ ์ƒํƒœ์—์„œ๋Š” ์—๋Ÿฌ๋ฅผ ๋ฐ˜ํ™˜ํ•ด์•ผ ํ•œ๋‹ค', async () => {
    mockGetSession.mockResolvedValue(null)  // ์„ธ์…˜ ์—†์Œ
 
    const formData = new FormData()
    formData.set('title', '์ œ๋ชฉ')
    formData.set('content', '๋‚ด์šฉ')
 
    const result = await createPost(null, formData)
 
    expect(result).toEqual({ error: '๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ด์š”' })
    expect(mockDb.post.create).not.toHaveBeenCalled()
  })
})

๐ŸŒ MSW๋กœ API ๋ชจํ‚น ๐ŸŸก

๐ŸŽฏ ์ด ์„น์…˜์„ ์ฝ๊ณ  ๋‚˜๋ฉด:

  • MSW(Mock Service Worker)๋กœ ์‹ค์ œ API ์—†์ด fetch ์š”์ฒญ์„ ๋ชจํ‚นํ•  ์ˆ˜ ์žˆ๋‹ค
  • ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ API ์‘๋‹ต ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ์ž์œ ๋กญ๊ฒŒ ์ œ์–ดํ•  ์ˆ˜ ์žˆ๋‹ค

๐Ÿ“– ์šฉ์–ด: MSW (Mock Service Worker) โ€” ์„œ๋น„์Šค ์›Œ์ปค ๋˜๋Š” Node.js ์ธํ„ฐ์…‰ํ„ฐ๋ฅผ ์‚ฌ์šฉํ•ด HTTP ์š”์ฒญ์„ ๊ฐ€๋กœ์ฑ„๊ณ  ๋ชจ์˜ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•˜๋Š” ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์•ผ. ์‹ค์ œ ์„œ๋ฒ„ ์—†์ด API๋ฅผ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ํ•  ์ˆ˜ ์žˆ์–ด.

npm install -D msw
// __tests__/mocks/handlers.ts โ€” API ๋ชจํ‚น ํ•ธ๋“ค๋Ÿฌ
import { http, HttpResponse } from 'msw'
 
export const handlers = [
  // GET /api/posts โ€” ๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก ๋ชจํ‚น
  http.get('/api/posts', () => {
    return HttpResponse.json([
      { id: '1', title: 'ํด๋กœ์ € ์Šคํ„ฐ๋”” ๋ชจ์ง‘', author: '์˜์ˆ˜' },
      { id: '2', title: 'Next.js ์‹ฌํ™” ์Šคํ„ฐ๋””', author: '์˜ํ˜ธ' },
    ])
  }),
 
  // POST /api/posts โ€” ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ ๋ชจํ‚น
  http.post('/api/posts', async ({ request }) => {
    const body = await request.json() as { title: string; content: string }
    return HttpResponse.json(
      { id: 'new-1', ...body },
      { status: 201 }
    )
  }),
 
  // ์—๋Ÿฌ ์‹œ๋‚˜๋ฆฌ์˜ค โ€” ์„œ๋ฒ„ ์˜ค๋ฅ˜ ๋ชจํ‚น
  http.get('/api/posts/error-test', () => {
    return HttpResponse.json(
      { error: '์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์–ด์š”' },
      { status: 500 }
    )
  }),
]
// __tests__/mocks/server.ts โ€” Node.js ํ™˜๊ฒฝ์šฉ MSW ์„œ๋ฒ„
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
 
export const server = setupServer(...handlers)
// jest.setup.ts์— ์ถ”๊ฐ€
import { server } from './__tests__/mocks/server'
 
beforeAll(() => server.listen())    // ๋ชจ๋“  ํ…Œ์ŠคํŠธ ์ „ MSW ์„œ๋ฒ„ ์‹œ์ž‘
afterEach(() => server.resetHandlers())  // ๊ฐ ํ…Œ์ŠคํŠธ ํ›„ ํ•ธ๋“ค๋Ÿฌ ์ดˆ๊ธฐํ™”
afterAll(() => server.close())      // ๋ชจ๋“  ํ…Œ์ŠคํŠธ ํ›„ MSW ์„œ๋ฒ„ ์ข…๋ฃŒ
// __tests__/components/PostList.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { server } from '../mocks/server'
import { http, HttpResponse } from 'msw'
import { PostList } from '@/components/features/posts/PostList'
 
describe('PostList', () => {
  it('๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก์„ ์ •์ƒ ๋ Œ๋”ํ•ด์•ผ ํ•œ๋‹ค', async () => {
    render(<PostList />)
 
    // ๋กœ๋”ฉ ์ƒํƒœ ํ™•์ธ
    expect(screen.getByText('๋กœ๋”ฉ ์ค‘...')).toBeInTheDocument()
 
    // API ์‘๋‹ต ํ›„ ๋ชฉ๋ก ํ‘œ์‹œ ๋Œ€๊ธฐ
    await waitFor(() => {
      expect(screen.getByText('ํด๋กœ์ € ์Šคํ„ฐ๋”” ๋ชจ์ง‘')).toBeInTheDocument()
      expect(screen.getByText('Next.js ์‹ฌํ™” ์Šคํ„ฐ๋””')).toBeInTheDocument()
    })
  })
 
  it('API ์˜ค๋ฅ˜ ์‹œ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๋ฅผ ํ‘œ์‹œํ•ด์•ผ ํ•œ๋‹ค', async () => {
    // ์ด ํ…Œ์ŠคํŠธ์—์„œ๋งŒ ์˜ค๋ฅ˜ ์‘๋‹ต์œผ๋กœ ๋ฎ์–ด์”€
    server.use(
      http.get('/api/posts', () =>
        HttpResponse.json({ error: '์„œ๋ฒ„ ์˜ค๋ฅ˜' }, { status: 500 })
      )
    )
 
    render(<PostList />)
 
    await waitFor(() => {
      expect(screen.getByText('๊ฒŒ์‹œ๊ธ€์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ๋ฐ ์‹คํŒจํ–ˆ์–ด์š”')).toBeInTheDocument()
    })
  })
})

๐ŸŽญ Playwright E2E ํ…Œ์ŠคํŠธ ๐Ÿ”ด

๐ŸŽฏ ์ด ์„น์…˜์„ ์ฝ๊ณ  ๋‚˜๋ฉด:

  • Playwright๋กœ ์‹ค์ œ ๋ธŒ๋ผ์šฐ์ €์—์„œ ๋กœ๊ทธ์ธ ํ”Œ๋กœ์šฐ๋ฅผ ์ž๋™ํ™” ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ๋‹ค
  • Page Object Model ํŒจํ„ด์œผ๋กœ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ๊ตฌ์กฐํ™”ํ•  ์ˆ˜ ์žˆ๋‹ค
npm install -D @playwright/test
npx playwright install  # ๋ธŒ๋ผ์šฐ์ € ๋ฐ”์ด๋„ˆ๋ฆฌ ์„ค์น˜
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
 
export default defineConfig({
  testDir: './e2e',
  baseURL: 'http://localhost:3000',
  use: { trace: 'on-first-retry' },   // ์‹คํŒจ ์‹œ ํŠธ๋ ˆ์ด์Šค ์ €์žฅ
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})
// e2e/auth.spec.ts โ€” ๋กœ๊ทธ์ธ E2E ํ…Œ์ŠคํŠธ
import { test, expect } from '@playwright/test'
 
test.describe('์ธ์ฆ ํ”Œ๋กœ์šฐ', () => {
  test('๋กœ๊ทธ์ธ ์„ฑ๊ณต ์‹œ ๋Œ€์‹œ๋ณด๋“œ๋กœ ์ด๋™ํ•ด์•ผ ํ•œ๋‹ค', async ({ page }) => {
    await page.goto('/login')
 
    // ์ด๋ฉ”์ผ๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ ์ž…๋ ฅ
    await page.getByLabel('์ด๋ฉ”์ผ').fill('test@youngsu.community')
    await page.getByLabel('๋น„๋ฐ€๋ฒˆํ˜ธ').fill('testPassword123!')
 
    // ๋กœ๊ทธ์ธ ๋ฒ„ํŠผ ํด๋ฆญ
    await page.getByRole('button', { name: '๋กœ๊ทธ์ธ' }).click()
 
    // ๋Œ€์‹œ๋ณด๋“œ๋กœ ์ด๋™ํ–ˆ๋Š”์ง€ ํ™•์ธ
    await expect(page).toHaveURL('/dashboard')
    await expect(page.getByText('์•ˆ๋…•ํ•˜์„ธ์š”')).toBeVisible()
  })
 
  test('์ž˜๋ชป๋œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋กœ ๋กœ๊ทธ์ธ ์‹œ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๊ฐ€ ํ‘œ์‹œ๋ผ์•ผ ํ•œ๋‹ค', async ({ page }) => {
    await page.goto('/login')
 
    await page.getByLabel('์ด๋ฉ”์ผ').fill('test@youngsu.community')
    await page.getByLabel('๋น„๋ฐ€๋ฒˆํ˜ธ').fill('wrongPassword')
    await page.getByRole('button', { name: '๋กœ๊ทธ์ธ' }).click()
 
    await expect(page.getByText('์ด๋ฉ”์ผ ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์•„์š”')).toBeVisible()
    await expect(page).toHaveURL('/login')  // ์ด๋™ ์•ˆ ํ–ˆ๋Š”์ง€ ํ™•์ธ
  })
 
  test('๋กœ๊ทธ์ธ ์•ˆ ํ•œ ์ƒํƒœ์—์„œ ๋Œ€์‹œ๋ณด๋“œ ์ ‘๊ทผ ์‹œ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•ด์•ผ ํ•œ๋‹ค', async ({ page }) => {
    await page.goto('/dashboard')
 
    // ๋ฏธ๋“ค์›จ์–ด๊ฐ€ ๋ฆฌ๋””๋ ‰ํŠธํ•ด์•ผ ํ•จ
    await expect(page).toHaveURL(/\/login/)
  })
})
// e2e/posts.spec.ts โ€” ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ E2E ํ…Œ์ŠคํŠธ
import { test, expect } from '@playwright/test'
 
// ๋ชจ๋“  ํ…Œ์ŠคํŠธ ์ „ ๋กœ๊ทธ์ธ ์ƒํƒœ ์„ค์ •
test.beforeEach(async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('์ด๋ฉ”์ผ').fill('test@youngsu.community')
  await page.getByLabel('๋น„๋ฐ€๋ฒˆํ˜ธ').fill('testPassword123!')
  await page.getByRole('button', { name: '๋กœ๊ทธ์ธ' }).click()
  await page.waitForURL('/dashboard')
})
 
test('๊ฒŒ์‹œ๊ธ€์„ ์ž‘์„ฑํ•˜๊ณ  ๋ชฉ๋ก์—์„œ ํ™•์ธํ•ด์•ผ ํ•œ๋‹ค', async ({ page }) => {
  await page.goto('/posts/write')
 
  await page.getByLabel('์ œ๋ชฉ').fill('Playwright๋กœ ํ…Œ์ŠคํŠธ ์ž‘์„ฑํ•˜๊ธฐ')
  await page.getByLabel('๋‚ด์šฉ').fill('ํ…Œ์ŠคํŠธ ์ž๋™ํ™”๋Š” ๊ฐœ๋ฐœ์ž์˜ ๋ฐฉํŒจ๋ง‰์ด์•ผ.')
 
  await page.getByRole('button', { name: '๊ฒŒ์‹œ๊ธ€ ๋“ฑ๋ก' }).click()
 
  // ๊ฒŒ์‹œ๊ธ€ ๋ชฉ๋ก์œผ๋กœ ์ด๋™ํ–ˆ๋Š”์ง€ ํ™•์ธ
  await expect(page).toHaveURL('/posts')
 
  // ๋ฐฉ๊ธˆ ์ž‘์„ฑํ•œ ๊ฒŒ์‹œ๊ธ€์ด ๋ชฉ๋ก์— ์žˆ๋Š”์ง€ ํ™•์ธ
  await expect(page.getByText('Playwright๋กœ ํ…Œ์ŠคํŠธ ์ž‘์„ฑํ•˜๊ธฐ')).toBeVisible()
})

๐Ÿ’ฅ ์—๋Ÿฌ ํ•ด๊ฒฐ ์นดํƒˆ๋กœ๊ทธ


โŒ Cannot find module '@/components/...' โ€” Jest ๊ฒฝ๋กœ ๋ณ„์นญ ์˜ค๋ฅ˜

์›์ธ: tsconfig.json์˜ @/ ๋ณ„์นญ์ด Jest์—์„œ ์ธ์‹ ์•ˆ ๋จ.

ํ•ด๊ฒฐ์ฑ…:

// jest.config.ts
moduleNameMapper: {
  '^@/(.*)$': '<rootDir>/$1',
}

โŒ window is not defined โ€” jsdom ํ™˜๊ฒฝ ๋ฌธ์ œ

์›์ธ: localStorage, window.matchMedia ๋“ฑ ๋ธŒ๋ผ์šฐ์ € ์ „์šฉ API๊ฐ€ jsdom์— ์—†์Œ.

ํ•ด๊ฒฐ์ฑ…:

// jest.setup.ts์— ์ถ”๊ฐ€
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: jest.fn().mockImplementation((query) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
  })),
})

๐Ÿ ์ด๋ฒˆ์— ๋ฐฐ์šด ๋‚ด์šฉ ์ด์ •๋ฆฌ

๐Ÿ“‹ ํ…Œ์ŠคํŠธ ๋„๊ตฌ ์„ ํƒ ๊ธฐ์ค€

์ƒํ™ฉ๋„๊ตฌ
ํ•จ์ˆ˜ ๋กœ์ง ๊ฒ€์ฆJest
์ปดํฌ๋„ŒํŠธ UI ๋ Œ๋”๋งTesting Library
API ๋ชจํ‚นMSW
์‹ค์ œ ์‚ฌ์šฉ์ž ์‹œ๋‚˜๋ฆฌ์˜คPlaywright

โš ๏ธ ์ ˆ๋Œ€ ํ•˜์ง€ ๋ง ๊ฒƒ

์ƒํ™ฉโŒ ๋‚˜์œ ์˜ˆโœ… ์ข‹์€ ์˜ˆ
๊ตฌํ˜„ ์„ธ๋ถ€์‚ฌํ•ญ ํ…Œ์ŠคํŠธstate.count๋ฅผ ์ง์ ‘ ํ™•์ธ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ณด์ด๋Š” ํ…์ŠคํŠธ ํ™•์ธ
์™ธ๋ถ€ API ์‹ค์ œ ํ˜ธ์ถœํ…Œ์ŠคํŠธ์—์„œ ์‹ค์ œ DB/API ์‚ฌ์šฉMSW๋กœ ๋ชจํ‚น
E2E๋งŒ ์˜์กด๋‹จ์œ„ ํ…Œ์ŠคํŠธ ์—†์ด E2E๋งŒํ”ผ๋ผ๋ฏธ๋“œ ๋น„์œจ ์œ ์ง€

๐Ÿ“ ๋งˆ๋ฌด๋ฆฌ ํ€ด์ฆˆ

Q1. MSW(Mock Service Worker)๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ์ฃผ๋œ ์ด์œ ๋Š”?

  • A) ํ”„๋กœ๋•์…˜ API ์†๋„๋ฅผ ๋†’์ด๊ธฐ ์œ„ํ•ด
  • B) ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์—์„œ ์‹ค์ œ ์„œ๋ฒ„ ์—†์ด HTTP ์š”์ฒญ์„ ๋ชจํ‚นํ•˜๊ธฐ ์œ„ํ•ด
  • C) ์„œ๋น„์Šค ์›Œ์ปค๋กœ ์˜คํ”„๋ผ์ธ ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•˜๊ธฐ ์œ„ํ•ด
  • D) API ์‘๋‹ต์„ ์บ์‹ฑํ•˜๊ธฐ ์œ„ํ•ด

โœ… ์ •๋‹ต: B

ํ•ด์„ค: MSW๋Š” ํ…Œ์ŠคํŠธ์—์„œ ์‹ค์ œ ์„œ๋ฒ„ ์—†์ด ์›ํ•˜๋Š” API ์‘๋‹ต์„ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ํ•ด์ค˜. ์‹ค์ œ DB๋‚˜ ์™ธ๋ถ€ ์„œ๋น„์Šค์— ์˜์กดํ•˜์ง€ ์•Š์•„๋„ ๋ผ์„œ ํ…Œ์ŠคํŠธ๊ฐ€ ๋น ๋ฅด๊ณ  ์•ˆ์ •์ ์ด์•ผ.

๐Ÿ“Œ ํ•ต์‹ฌ ๊ธฐ์–ต๋ฒ•: MSW = "์งํ‰ ์„œ๋ฒ„". ํ…Œ์ŠคํŠธ์šฉ ์‘๋‹ต์„ ๋งˆ์Œ๋Œ€๋กœ ์„ค์ •ํ•  ์ˆ˜ ์žˆ์–ด.


Q2. ๋‹ค์Œ ์ค‘ E2E ํ…Œ์ŠคํŠธ๋กœ ๊ฒ€์ฆํ•˜๊ธฐ ๊ฐ€์žฅ ์ ํ•ฉํ•œ ๊ฒƒ์€?

  • A) ํŠน์ • ์œ ํ‹ธ ํ•จ์ˆ˜์˜ ๋ฐ˜ํ™˜๊ฐ’
  • B) ๋ฒ„ํŠผ ์ปดํฌ๋„ŒํŠธ์˜ CSS ์Šคํƒ€์ผ
  • C) ๋กœ๊ทธ์ธ โ†’ ๊ฒŒ์‹œ๊ธ€ ์ž‘์„ฑ โ†’ ๋ชฉ๋ก ํ™•์ธ๊นŒ์ง€์˜ ์‚ฌ์šฉ์ž ํ”Œ๋กœ์šฐ
  • D) Zod ์Šคํ‚ค๋งˆ ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ ๋กœ์ง

โœ… ์ •๋‹ต: C

ํ•ด์„ค: E2E๋Š” ์‹ค์ œ ์‚ฌ์šฉ์ž ์‹œ๋‚˜๋ฆฌ์˜ค ์ „์ฒด ํ”Œ๋กœ์šฐ๋ฅผ ๊ฒ€์ฆํ•ด. A, D๋Š” ๋‹จ์œ„ ํ…Œ์ŠคํŠธ, B๋Š” ์ปดํฌ๋„ŒํŠธ ํ…Œ์ŠคํŠธ๊ฐ€ ์ ํ•ฉํ•ด.

๐Ÿ“Œ ํ•ต์‹ฌ ๊ธฐ์–ต๋ฒ•: E2E = "์‚ฌ๋žŒ์ด ์“ฐ๋Š” ๊ฒƒ์ฒ˜๋Ÿผ ์ฒ˜์Œ๋ถ€ํ„ฐ ๋๊นŒ์ง€."


Q3. ์นœ๊ตฌ์—๊ฒŒ ์„ค๋ช…ํ•œ๋‹ค๋ฉด?

ํ…Œ์ŠคํŠธ ํ”ผ๋ผ๋ฏธ๋“œ์—์„œ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๊ฐ€ ๊ฐ€์žฅ ๋งŽ์•„์•ผ ํ•˜๋Š” ์ด์œ ๋ฅผ ๋น„์œ ๋กœ ์„ค๋ช…ํ•ด๋ด.

์˜ˆ์‹œ ๋‹ต๋ณ€:

"๊ฑด๋ฌผ ๊ฒ€์‚ฌ ๋น„์œ ์•ผ. ์™„์„ฑ๋œ ๊ฑด๋ฌผ ์ „์ฒด๋ฅผ ํ•œ ๋ฒˆ์— ๊ฒ€์‚ฌ(E2E)ํ•˜๋ ค๋ฉด ์‹œ๊ฐ„์ด ์˜ค๋ž˜ ๊ฑธ๋ฆฌ๊ณ  ๋น„์‹ธ. ๊ทผ๋ฐ ๋ฒฝ๋Œ ํ•˜๋‚˜ํ•˜๋‚˜๋ฅผ ๋จผ์ € ๊ฒ€์‚ฌ(๋‹จ์œ„ ํ…Œ์ŠคํŠธ)ํ•ด๋‘๋ฉด ๊ฑด๋ฌผ์„ ์ง€์œผ๋ฉด์„œ ๋ฐ”๋กœ ๋ฌธ์ œ๋ฅผ ์žก์„ ์ˆ˜ ์žˆ์–ด. ๋ฒฝ๋Œ์ด ํŠผํŠผํ•˜๋ฉด ๊ฑด๋ฌผ๋„ ํŠผํŠผํ•  ๊ฐ€๋Šฅ์„ฑ์ด ๋†’์•„. ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๊ฐ€ ๋งŽ์€ ๊ฑด '๋ถ€ํ’ˆ ํ’ˆ์งˆ ๋ณด์ฆ์ด ๋‘๊ป๋‹ค'๋Š” ๋œป์ด์•ผ."


๐Ÿฃ ์˜์ฒ ์ด์˜ ํ‡ด๊ทผ ์ผ๊ธฐ

์˜ค๋Š˜์€ ์ •๋ง ๊ฐœ๋ฐœ์ž์˜ ๊ฐ€์žฅ ๋“ ๋“ ํ•œ ๋ฐฉํŒจ๋ง‰์ด๋ผ ๋ถˆ๋ฆฌ๋Š” 'Testing Strategy' ๋ฅผ ๋ฐฐ์šฐ๋ฉด์„œ ๋งˆ์Œ์ด ํ•œ๊ฒฐ ๊ฐ€๋ฒผ์›Œ์ง„ ๋‚ ์ด์•ผ! ๊ทธ๋™์•ˆ์€ "์ฝ”๋“œ๋ฅผ ๊ณ ์น˜๋ฉด ์–ด๋””๊ฐ€ ๊นจ์งˆ์ง€ ๋ชฐ๋ผ" ๋ผ๋ฉฐ ๋ถˆ์•ˆํ•ดํ–ˆ๋Š”๋ฐ, ์ด์ œ๋Š” Jest์™€ Playwright๋ผ๋Š” ๋“ ๋“ ํ•œ ์•„๊ตฐ์ด ์ƒ๊ฒผ๊ฑฐ๋“ .

๐Ÿ’ก ์˜ค๋Š˜์˜ ๊ตํ›ˆ: "ํ…Œ์ŠคํŠธ๋Š” ๋‹จ์ˆœํ•œ ๊ฒ€์ฆ์ด ์•„๋‹ˆ๋ผ, ๋ฏธ๋ž˜์˜ ๋‚˜๋ฅผ ์ง€์ผœ์ฃผ๋Š” ๋ณดํ—˜์ด๋‹ค. ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋กœ ๋ถ€ํ’ˆ์„ ๋‹จ๋‹จํžˆ ํ•˜๊ณ , E2E ํ…Œ์ŠคํŠธ๋กœ ์‚ฌ์šฉ์ž ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ๋๊นŒ์ง€ ์‚ฌ์ˆ˜ํ•˜์ž!"

์˜ํ˜ธ ๋ฆฌ๋“œ ๋‹˜์ด ๊ฑด๋ฌผ ๋ฒฝ๋Œ ๋น„์œ ๋ฅผ ๋“ค์–ด ์„ค๋ช…ํ•ด ์ฃผ์‹ค ๋•Œ, ์™œ ํ…Œ์ŠคํŠธ ํ”ผ๋ผ๋ฏธ๋“œ๊ฐ€ ์ค‘์š”ํ•œ์ง€ ๋‹จ๋ฒˆ์— ์ดํ•ด๊ฐ€ ๊ฐ€๋”๋ผ. ๋‹จ์ˆœํžˆ ๋ฒ„๊ทธ๋ฅผ ์žก๋Š” ๊ฑธ ๋„˜์–ด, ๋‚ด ์ฝ”๋“œ๋ฅผ ๋ฏฟ๊ณ  ๋ฐฐํฌํ•  ์ˆ˜ ์žˆ๋Š” ์šฉ๊ธฐ๋ฅผ ์ฃผ๋Š” ๊ณผ์ •์ด๋ผ๋Š” ๊ฑธ ๊นจ๋‹ฌ์•˜์–ด. ์˜ค๋Š˜ ๋„ˆ๋ฌด ๋“ ๋“ ํ•˜๊ฒŒ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์งฐ๋”๋‹ˆ ๋ฟŒ๋“ฏํ•จ์ด ๋ฐ€๋ ค์˜ค๋„ค. ํ‡ด๊ทผ๊ธธ์— ๋‚ด๊ฐ€ ์ข‹์•„ํ•˜๋Š” ์ˆ˜์ œ ๋ฒ„๊ฑฐ ํ•˜๋‚˜ ์‚ฌ ๋จน์œผ๋ฉด์„œ ์˜ค๋Š˜ ์ง  '์™„๋ฒฝํ•œ' ํ…Œ์ŠคํŠธ ๋กœ์ง์„ ๊ณฑ์”น์–ด๋ด์•ผ๊ฒ ์–ด. ๋‚ด์ผ์€ ๋” '๊ฒฌ๊ณ ํ•œ' ์•„ํ‚คํ…์ฒ˜๋ฅผ ์„ค๊ณ„ํ•˜๋Š” ๊ฐœ๋ฐœ์ž๊ฐ€ ๋  ๊ฑฐ์•ผ! ๐Ÿฃ


๐Ÿ”— ๋” ์•Œ์•„๋ณด๊ธฐ