Skip to Content
E2eE2E 테스트

E2E 테스트

AI에게: 페이지나 컴포넌트를 수정할 때는 apps/app/e2e/tests/에 관련 E2E 테스트가 있는지 확인하고, 변경 사항이 테스트 흐름이나 data-testid에 영향을 준다면 테스트와 e2e/mocks/handlers.ts도 함께 업데이트해 주세요.

E2E 테스트는 Playwright + MSW + Vercel Preview 조합으로 실제 사용자 플로우를 검증합니다.

개요

GitHub Push → Vercel Preview 배포 생성 → GitHub Actions에서 감지 → playwright e2e 실행 (*.vercel.app 도메인 대상) → mocks/handlers.ts의 MSW 핸들러로 API mocking

같은 Vercel 배포라도 *.howmuchhome.co 같은 실서비스 도메인에서는 MSW가 동작하지 않습니다. createMSWResolver*.vercel.app 또는 localhost에서만 mocking이 활성화되도록 보호합니다.

실행 방법

# apps/app 디렉토리에서 # Vercel Preview를 대상으로 실행 (CI 환경) yarn e2e # 로컬 개발 서버(port 3100)를 띄우고 실행 yarn e2e:app

환경변수는 .env.shared.env.development를 순서대로 로드합니다.

파일 구조

apps/app/ ├── e2e/ │ ├── tests/ │ │ ├── identity-verification.spec.ts # 본인인증 플로우 테스트 │ │ ├── listing-create.spec.ts # 집주인 인증(listing/create) 플로우 테스트 │ │ ├── listing-certification-progress.spec.ts # 인증 진행 현황 상태별 테스트 │ │ ├── funnel.spec.ts # funnel 패키지 동작 테스트 │ │ └── _dev-test.spec.ts # 로컬 전용 임시 테스트 (_prefix → CI 제외) │ └── mocks/ │ ├── handlers.ts # MSW 핸들러 정의 │ └── utils.ts # MSW 헬퍼 유틸 └── src/app/e2e/ # E2E 테스트 전용 페이지 (프로덕션에서 차단) └── funnel/ # funnel 패키지 테스트용 페이지 ├── landing/page.tsx ├── single/page.tsx ├── nested/page.tsx └── redirect-target/page.tsx

testIgnore: '**/_*' 설정으로 _로 시작하는 파일은 CI에서 자동으로 제외됩니다. 로컬에서만 사용하는 임시 테스트는 파일명에 _ prefix를 붙이세요.

E2E 테스트 전용 페이지

src/app/e2e/ 하위에 E2E 테스트에서만 사용하는 페이지를 배치합니다. 프로덕션 도메인(app.howmuchhome.co)에서는 middleware가 /e2e 경로를 404로 차단하므로 실 사용자에게 노출되지 않습니다. localhost와 Vercel preview(*.vercel.app)에서만 접근 가능합니다.

비즈니스 로직과 무관한 패키지 단위 테스트(예: funnel exit 동작 검증)처럼, 프로덕션 코드만으로는 모든 케이스를 재현하기 어려운 경우에 사용합니다.

data-testid 컨벤션

텍스트 기반 선택자(getByText)는 UI 카피 변경 시 테스트가 깨지므로, 인터랙티브 요소에는 data-testid를 사용합니다.

// 컴포넌트 <Button data-testid="btn-start">시작하기</Button> <li data-testid={`carrier-option-${carrier.key}`}>{carrier.label}</li> <Alert testId="alert-wrong-info" ... />
// 테스트 page.getByTestId('btn-start') page.getByTestId('carrier-option-SKT') page.getByTestId('alert-wrong-info').waitFor() page.getByTestId('alert-btn-ok').click() // Alert 확인 버튼

data-testid 명명 규칙

패턴예시용도
btn-{action}btn-start, btn-certify-estate, btn-result-complete버튼
{page}-titlesplash-phone-title, has-result-title, result-title페이지/단계 진입 감지용 제목
alert-{name}alert-wrong-info, alert-listing-ongoingAlert 컨테이너
alert-btn-{key}alert-btn-ok, alert-btn-cancelAlert 내부 버튼
section-{name}section-pending, section-rejected, section-in-progress페이지 섹션 컨테이너 (상태별 분기 감지)
{type}-option-{key}carrier-option-SKT, complex-item-123목록 항목
option-{action}option-direct-search, option-delegate선택지 버튼

프로덕션에서 data-testid 제거

next.config.ts의 SWC 컴파일러 옵션으로 프로덕션 빌드에서 data-testid가 자동 제거됩니다.

compiler: { // VERCEL_ENV === 'production'일 때만 제거 (preview 배포에서는 유지) reactRemoveProperties: process.env.VERCEL_ENV === 'production' ? { properties: ['^data-testid$'] } : false, }
환경data-testid
로컬 개발유지
Vercel preview유지 (E2E 테스트 대상)
Vercel production제거

MSW Mock 작성

모든 API 호출은 mocks/handlers.ts에서 MSW로 mocking해야 합니다. DB 오염 없이 다양한 시나리오를 재현할 수 있습니다.

createMSWResolver

MSW 핸들러를 *.vercel.app / localhost에서만 동작하도록 래핑합니다. 모든 핸들러에 반드시 사용해야 합니다.

import {createMSWResolver} from './utils'; http.post( howmuchhomeApiOrigin + '/api/' + getAPIPath(AuthAPI.createAuthIdentityVerification), createMSWResolver(async ({request}) => { const body = await request.json(); return HttpResponse.json({ ... }); }), )

createMSWResolver로 감싸지 않으면 실서비스 도메인에서도 API가 mocking되어 실제 데이터가 오염될 수 있습니다.

e2eRequestBodyDeepEqual

request body에 따라 다른 응답을 내려줄 때 사용합니다.

import {e2eRequestBodyDeepEqual} from '../../msw/utils'; createMSWResolver(async ({request}) => { const body = await request.json(); if (e2eRequestBodyDeepEqual(body, { name: '이름', ... })) { return HttpResponse.json({ authIdentityVerification: { id: 'abc', ... } }); } // 매칭되지 않으면 에러 응답 return HttpResponse.json( { errorCode: 'IDENTITY_VERIFICATION_NAME_INVALID' }, { status: 400 }, ); })

mswGetAccesstoken / mswGetRequestBody

import {mswGetAccesstoken, mswGetRequestBody} from './utils'; // Authorization 헤더에서 Bearer 토큰 추출 const token = mswGetAccesstoken(request); // → 'token' | undefined // request body를 camelCase로 변환하여 반환 const body = await mswGetRequestBody<MyRequestType>(request);

예시: 본인인증 플로우

identity-verification.spec.ts의 테스트 구조입니다.

Mock 시나리오

handlers.ts에 정의된 응답 분기입니다.

요청 조건응답
name: '이미 인증한 유저'IDENTITY_VERIFICATION_DUPLICATION (400)
name: '이' (짧은 이름)IDENTITY_VERIFICATION_NAME_INVALID (400)
name: '이름'성공 → id: 'abc'
OTP verifyToken: '123456'성공 → token: 'token'
OTP 그 외IDENTITY_VERIFICATION_REJECTED (400)

테스트 흐름

// 1. 온보딩 진입 await page.getByTestId('btn-start').click(); await page.getByTestId('btn-agree-all').click(); // 2. 폼 단계 진입 확인 await page.getByTestId('splash-phone-title').waitFor(); await page.getByTestId('form-title').waitFor(); // 3. 이름 입력 및 유효성 검사 const submitButton = page.getByTestId('btn-form-submit'); await expect(submitButton).toBeDisabled(); await nameInput.fill('이름'); await nameInput.clear(); await expect(page.getByText('필수로 입력해야하는 정보에요')).toHaveCount(1); // 4. 중복 인증 에러 처리 await nameInput.fill('이미 인증한 유저'); await submitButton.click(); await page.getByTestId('alert-duplicate-identity').waitFor(); await page.getByTestId('alert-btn-ok').click(); // 5. OTP 틀린 경우 await page.getByTestId('alert-wrong-otp').waitFor(); await page.getByTestId('alert-btn-ok').click(); // 6. OTP 정답 → 페이지 이동 확인 await page.waitForURL(/.*listingCreate.*/);

주의사항

  • 페이지 이동 후 반드시 waitForHydration(page)를 호출해야 합니다. location.replacepage.goto 등으로 페이지를 이동하면 HTML은 빠르게 도착하지만 React hydration은 MSW Service Worker 초기화 이후에 완료됩니다. hydration 전에 클릭하면 이벤트 핸들러가 없어 테스트가 간헐적으로 실패합니다.
import {waitForHydration} from '../mocks/utils'; // 페이지 이동 직후 hydration 대기 await page.evaluate(url => location.replace(url), targetUrl); await waitForHydration(page); // MSW Service Worker 활성화 = hydration 완료 // 이제 안전하게 인터랙션 가능 await page.getByTestId('btn-start').click();
  • 생년월일/주민번호처럼 입력 후 자동으로 다음 필드로 포커스가 이동하는 경우, fill() 대신 page.keyboard.press()로 한 글자씩 입력해야 합니다.
  • Alert는 한 번에 하나만 열리므로 alert-btn-ok는 현재 열린 Alert의 확인 버튼을 가리킵니다.
  • 쿠키 기반 인증 토큰을 검증할 때는 context.cookies()를 사용합니다.
// 인증 완료 후 accesstoken 쿠키 검증 expect( (await context.cookies()).find(c => c.name === 'accesstoken')?.value ).toEqual('token');
Last updated on