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.tsxtestIgnore: '**/_*' 설정으로 _로 시작하는 파일은 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}-title | splash-phone-title, has-result-title, result-title | 페이지/단계 진입 감지용 제목 |
alert-{name} | alert-wrong-info, alert-listing-ongoing | Alert 컨테이너 |
alert-btn-{key} | alert-btn-ok, alert-btn-cancel | Alert 내부 버튼 |
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.replace나page.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');