E2E 테스트 실전 가이드 - Playwright와 Cypress로 사용자 시나리오 테스트하기
Playwright와 Cypress를 활용한 E2E(End-to-End) 테스트 완벽 가이드. 실제 사용자 시나리오를 자동화하는 방법과 두 도구의 장단점 비교, 실전 예제로 로그인부터 결제까지 테스트하는 방법을 배웁니다. CI/CD 통합과 Best Practices로 안정적인 테스트 자동화를 구축합니다.
개요
E2E(End-to-End) 테스트는 실제 사용자가 애플리케이션을 사용하는 것처럼 전체 워크플로우를 테스트하는 방법입니다. 이 가이드에서는 현재 가장 인기 있는 두 도구인 Playwright와 Cypress를 활용한 실전 E2E 테스트 작성법을 다룹니다.
이 글에서 배울 수 있는 것:
- E2E 테스트의 개념과 필요성
- Playwright vs Cypress 비교 및 선택 가이드
- 실전 E2E 테스트 시나리오 작성
- CI/CD 환경에서의 테스트 자동화
- 안정적인 E2E 테스트 작성 Best Practices
사전 요구사항:
- JavaScript/TypeScript 기본 지식
- 웹 애플리케이션 개발 경험
- HTML, CSS, DOM 기본 이해
- 단위 테스트 개념 이해 (프론트엔드 테스팅 완벽 가이드 참고)
예상 소요 시간: 약 30분
목차
- E2E 테스트란?
- Playwright vs Cypress
- Playwright 시작하기
- Cypress 시작하기
- 실전 E2E 테스트 작성
- Best Practices
- CI/CD 통합
- 자주 묻는 질문 (FAQ)
- 참고 자료
E2E 테스트란?
정의
E2E(End-to-End) 테스트는 애플리케이션의 시작부터 끝까지 전체 사용자 워크플로우를 테스트하는 방법입니다.
테스트 피라미드에서의 위치
E2E 테스트가 검증하는 것
- 사용자 워크플로우: 로그인 → 상품 검색 → 장바구니 → 결제
- 브라우저 호환성: Chrome, Firefox, Safari, Edge
- 실제 환경: 네트워크 지연, 데이터베이스 연동, API 통신
- UI/UX: 버튼 클릭, 폼 입력, 페이지 이동
- 통합: 프론트엔드 + 백엔드 + 데이터베이스
E2E vs 단위 테스트 vs 통합 테스트
| 구분 | 단위 테스트 | 통합 테스트 | E2E 테스트 |
|---|---|---|---|
| 범위 | 개별 함수/컴포넌트 | 여러 모듈 조합 | 전체 애플리케이션 |
| 속도 | ⚡️ 매우 빠름 | ⚡️ 빠름 | 🐢 느림 |
| 비용 | 💰 낮음 | 💰💰 보통 | 💰💰💰 높음 |
| 신뢰도 | 낮음 | 보통 | 높음 |
| 유지보수 | 쉬움 | 보통 | 어려움 |
| 개수 | 수백~수천 개 | 수십~수백 개 | 수개~수십 개 |
Playwright vs Cypress
현재 가장 인기 있는 두 E2E 테스팅 도구를 비교해봅시다.
기본 비교
| 구분 | Playwright | Cypress |
|---|---|---|
| 제작사 | Microsoft | Cypress.io |
| 출시 | 2020년 | 2017년 |
| 언어 | JavaScript, TypeScript, Python, Java, .NET | JavaScript, TypeScript |
| 브라우저 | Chromium, Firefox, WebKit (Safari) | Chrome, Edge, Firefox, Electron |
| 병렬 실행 | ✅ 기본 지원 | ⚠️ 유료 플랜 |
| 속도 | ⚡️⚡️⚡️ 매우 빠름 | ⚡️⚡️ 빠름 |
| 학습 곡선 | 보통 | 쉬움 |
| 디버깅 | 좋음 | 매우 좋음 (Time Travel) |
Playwright의 장점
✅ 멀티 브라우저 지원
1
2
// Chromium, Firefox, WebKit 모두 지원
const { chromium, firefox, webkit } = require('@playwright/test');
✅ 병렬 실행 기본 지원
1
2
# 모든 테스트를 병렬로 실행
npx playwright test --workers=4
✅ 자동 대기 (Auto-waiting)
1
2
// 요소가 나타날 때까지 자동 대기
await page.click('button');
✅ 네트워크 인터셉션
1
2
3
4
// API 응답 모킹
await page.route('**/api/users', route => {
route.fulfill({ body: JSON.stringify([...]) });
});
✅ 모바일 에뮬레이션
1
2
const iPhone13 = devices['iPhone 13'];
const context = await browser.newContext({ ...iPhone13 });
Cypress의 장점
✅ 개발자 경험 (DX)
- 실시간 리로드
- 타임 트래블 디버깅
- 직관적인 UI
✅ 쉬운 학습 곡선
1
2
3
4
5
6
// 매우 직관적인 API
cy.visit('/login')
cy.get('[data-testid="email"]').type('user@example.com')
cy.get('[data-testid="password"]').type('password')
cy.get('button[type="submit"]').click()
cy.url().should('include', '/dashboard')
✅ 강력한 디버깅 도구
- 각 단계별 스냅샷
- 명령 실행 전후 상태 확인
- 에러 발생 시 자동 스크린샷
✅ 풍부한 플러그인 생태계
1
2
3
4
// Testing Library 통합
import '@testing-library/cypress/add-commands'
cy.findByRole('button', { name: /submit/i }).click()
선택 가이드
Playwright를 선택하세요:
- ✅ 여러 브라우저 테스트가 필요할 때
- ✅ 빠른 실행 속도가 중요할 때
- ✅ Python, Java, .NET 사용 시
- ✅ 복잡한 네트워크 시나리오 테스트
- ✅ 모바일 웹 테스트
Cypress를 선택하세요:
- ✅ 개발자 경험(DX)이 최우선일 때
- ✅ 디버깅을 자주 해야 할 때
- ✅ 팀원들이 테스트에 익숙하지 않을 때
- ✅ Chrome/Firefox만 지원하면 될 때
- ✅ 풍부한 커뮤니티 플러그인 활용
추천: 신규 프로젝트라면 Playwright, 이미 Cypress에 익숙하다면 Cypress 유지
Playwright 시작하기
설치 및 설정
1
2
3
4
5
6
# Playwright 설치
npm init playwright@latest
# 또는 기존 프로젝트에 추가
npm install -D @playwright/test
npx playwright install
설치 시 자동으로 생성되는 파일:
playwright.config.ts:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
});
첫 번째 Playwright 테스트
tests/example.spec.ts:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { test, expect } from '@playwright/test';
test('홈페이지 제목 확인', async ({ page }) => {
await page.goto('https://playwright.dev/');
await expect(page).toHaveTitle(/Playwright/);
});
test('Get Started 링크 클릭', async ({ page }) => {
await page.goto('https://playwright.dev/');
await page.getByRole('link', { name: 'Get started' }).click();
await expect(page.getByRole('heading', { name: 'Installation' }))
.toBeVisible();
});
테스트 실행
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 모든 테스트 실행
npx playwright test
# 특정 파일만 실행
npx playwright test tests/example.spec.ts
# 헤드풀 모드 (브라우저 보면서 실행)
npx playwright test --headed
# 디버그 모드
npx playwright test --debug
# UI 모드 (인터랙티브)
npx playwright test --ui
Cypress 시작하기
설치 및 설정
1
2
3
4
5
# Cypress 설치
npm install -D cypress
# Cypress 열기 (초기 설정)
npx cypress open
cypress.config.js:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const { defineConfig } = require('cypress')
module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
viewportWidth: 1280,
viewportHeight: 720,
video: false,
screenshotOnRunFailure: true,
setupNodeEvents(on, config) {
// 플러그인 이벤트 등록
},
},
})
첫 번째 Cypress 테스트
cypress/e2e/example.cy.js:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
describe('홈페이지 테스트', () => {
beforeEach(() => {
cy.visit('/')
})
it('페이지 제목을 확인한다', () => {
cy.title().should('include', 'My App')
})
it('로고가 표시된다', () => {
cy.get('[data-testid="logo"]').should('be.visible')
})
it('네비게이션 메뉴가 동작한다', () => {
cy.get('nav').within(() => {
cy.contains('About').click()
})
cy.url().should('include', '/about')
cy.get('h1').should('contain', 'About Us')
})
})
테스트 실행
1
2
3
4
5
6
7
8
9
10
11
# UI 모드로 실행
npx cypress open
# 헤드리스 모드로 실행
npx cypress run
# 특정 브라우저로 실행
npx cypress run --browser chrome
# 특정 파일만 실행
npx cypress run --spec "cypress/e2e/example.cy.js"
실전 E2E 테스트 작성
실제 이커머스 사이트의 주요 워크플로우를 테스트해봅시다.
시나리오 1: 로그인 플로우
Playwright 버전
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import { test, expect } from '@playwright/test';
test.describe('로그인 기능', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('이메일과 비밀번호로 로그인', async ({ page }) => {
// Arrange: 테스트 데이터 준비
const email = 'test@example.com';
const password = 'password123';
// Act: 로그인 수행
await page.getByLabel('이메일').fill(email);
await page.getByLabel('비밀번호').fill(password);
await page.getByRole('button', { name: '로그인' }).click();
// Assert: 결과 확인
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('환영합니다')).toBeVisible();
});
test('잘못된 비밀번호로 로그인 실패', async ({ page }) => {
await page.getByLabel('이메일').fill('test@example.com');
await page.getByLabel('비밀번호').fill('wrongpassword');
await page.getByRole('button', { name: '로그인' }).click();
await expect(page.getByText('이메일 또는 비밀번호가 올바르지 않습니다'))
.toBeVisible();
});
test('이메일 유효성 검사', async ({ page }) => {
await page.getByLabel('이메일').fill('invalid-email');
await page.getByLabel('비밀번호').fill('password123');
await page.getByRole('button', { name: '로그인' }).click();
await expect(page.getByText('올바른 이메일 형식을 입력하세요'))
.toBeVisible();
});
});
Cypress 버전
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
describe('로그인 기능', () => {
beforeEach(() => {
cy.visit('/login')
})
it('이메일과 비밀번호로 로그인', () => {
// Arrange
const email = 'test@example.com'
const password = 'password123'
// Act
cy.get('[data-testid="email-input"]').type(email)
cy.get('[data-testid="password-input"]').type(password)
cy.get('[data-testid="login-button"]').click()
// Assert
cy.url().should('include', '/dashboard')
cy.contains('환영합니다').should('be.visible')
})
it('잘못된 비밀번호로 로그인 실패', () => {
cy.get('[data-testid="email-input"]').type('test@example.com')
cy.get('[data-testid="password-input"]').type('wrongpassword')
cy.get('[data-testid="login-button"]').click()
cy.contains('이메일 또는 비밀번호가 올바르지 않습니다')
.should('be.visible')
})
it('이메일 유효성 검사', () => {
cy.get('[data-testid="email-input"]').type('invalid-email')
cy.get('[data-testid="password-input"]').type('password123')
cy.get('[data-testid="login-button"]').click()
cy.contains('올바른 이메일 형식을 입력하세요')
.should('be.visible')
})
})
시나리오 2: 쇼핑 플로우 (상품 검색 → 장바구니 → 결제)
Playwright 버전
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import { test, expect } from '@playwright/test';
test.describe('쇼핑 플로우', () => {
test('상품 검색부터 결제까지 전체 프로세스', async ({ page }) => {
// 1. 로그인
await page.goto('/login');
await page.getByLabel('이메일').fill('test@example.com');
await page.getByLabel('비밀번호').fill('password123');
await page.getByRole('button', { name: '로그인' }).click();
await expect(page).toHaveURL('/dashboard');
// 2. 상품 검색
await page.getByPlaceholder('상품 검색').fill('노트북');
await page.getByRole('button', { name: '검색' }).click();
await expect(page.getByText('검색 결과')).toBeVisible();
await expect(page.getByTestId('product-item')).toHaveCount(5);
// 3. 상품 선택 및 상세 페이지
await page.getByText('맥북 프로 14인치').click();
await expect(page.getByRole('heading', { name: '맥북 프로 14인치' }))
.toBeVisible();
// 4. 장바구니 추가
await page.getByRole('button', { name: '장바구니에 추가' }).click();
await expect(page.getByText('장바구니에 추가되었습니다'))
.toBeVisible();
// 5. 장바구니 확인
await page.getByRole('link', { name: '장바구니' }).click();
await expect(page).toHaveURL('/cart');
await expect(page.getByText('맥북 프로 14인치')).toBeVisible();
// 수량 변경
await page.getByTestId('quantity-increase').click();
await expect(page.getByTestId('quantity')).toHaveText('2');
// 총 가격 확인
const totalPrice = await page.getByTestId('total-price').textContent();
expect(totalPrice).toContain('5,000,000원');
// 6. 결제 진행
await page.getByRole('button', { name: '결제하기' }).click();
await expect(page).toHaveURL('/checkout');
// 배송 정보 입력
await page.getByLabel('받는 사람').fill('홍길동');
await page.getByLabel('연락처').fill('010-1234-5678');
await page.getByLabel('주소').fill('서울시 강남구 테헤란로 123');
// 결제 방법 선택
await page.getByRole('radio', { name: '신용카드' }).check();
// 최종 결제
await page.getByRole('button', { name: '결제 완료' }).click();
// 7. 주문 완료 확인
await expect(page).toHaveURL(/\/order\/\d+/);
await expect(page.getByText('주문이 완료되었습니다')).toBeVisible();
// 주문 번호 확인
const orderNumber = await page.getByTestId('order-number').textContent();
expect(orderNumber).toMatch(/ORD-\d{8}/);
});
});
Cypress 버전
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
describe('쇼핑 플로우', () => {
it('상품 검색부터 결제까지 전체 프로세스', () => {
// 1. 로그인
cy.visit('/login')
cy.get('[data-testid="email-input"]').type('test@example.com')
cy.get('[data-testid="password-input"]').type('password123')
cy.get('[data-testid="login-button"]').click()
cy.url().should('include', '/dashboard')
// 2. 상품 검색
cy.get('[data-testid="search-input"]').type('노트북')
cy.get('[data-testid="search-button"]').click()
cy.contains('검색 결과').should('be.visible')
cy.get('[data-testid="product-item"]').should('have.length', 5)
// 3. 상품 선택
cy.contains('맥북 프로 14인치').click()
cy.get('h1').should('contain', '맥북 프로 14인치')
// 4. 장바구니 추가
cy.get('[data-testid="add-to-cart"]').click()
cy.contains('장바구니에 추가되었습니다').should('be.visible')
// 5. 장바구니 확인
cy.get('[data-testid="cart-link"]').click()
cy.url().should('include', '/cart')
cy.contains('맥북 프로 14인치').should('be.visible')
// 수량 변경
cy.get('[data-testid="quantity-increase"]').click()
cy.get('[data-testid="quantity"]').should('have.text', '2')
// 총 가격 확인
cy.get('[data-testid="total-price"]')
.should('contain', '5,000,000원')
// 6. 결제 진행
cy.get('[data-testid="checkout-button"]').click()
cy.url().should('include', '/checkout')
// 배송 정보 입력
cy.get('[data-testid="recipient-name"]').type('홍길동')
cy.get('[data-testid="phone"]').type('010-1234-5678')
cy.get('[data-testid="address"]').type('서울시 강남구 테헤란로 123')
// 결제 방법 선택
cy.get('[data-testid="payment-card"]').check()
// 최종 결제
cy.get('[data-testid="complete-payment"]').click()
// 7. 주문 완료 확인
cy.url().should('match', /\/order\/\d+/)
cy.contains('주문이 완료되었습니다').should('be.visible')
// 주문 번호 확인
cy.get('[data-testid="order-number"]')
.invoke('text')
.should('match', /ORD-\d{8}/)
})
})
시나리오 3: 폼 검증 (회원가입)
Playwright 버전
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { test, expect } from '@playwright/test';
test.describe('회원가입 폼 검증', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/signup');
});
test('모든 필드를 올바르게 입력하면 회원가입 성공', async ({ page }) => {
await page.getByLabel('이메일').fill('newuser@example.com');
await page.getByLabel('비밀번호').fill('StrongPass123!');
await page.getByLabel('비밀번호 확인').fill('StrongPass123!');
await page.getByLabel('이름').fill('김철수');
await page.getByLabel('전화번호').fill('010-9876-5432');
await page.getByRole('checkbox', { name: '이용약관 동의' }).check();
await page.getByRole('button', { name: '가입하기' }).click();
await expect(page).toHaveURL('/welcome');
await expect(page.getByText('회원가입이 완료되었습니다')).toBeVisible();
});
test('비밀번호 불일치 시 에러 표시', async ({ page }) => {
await page.getByLabel('비밀번호').fill('password123');
await page.getByLabel('비밀번호 확인').fill('password456');
await page.getByRole('button', { name: '가입하기' }).click();
await expect(page.getByText('비밀번호가 일치하지 않습니다'))
.toBeVisible();
});
test('약한 비밀번호 경고', async ({ page }) => {
await page.getByLabel('비밀번호').fill('123');
await expect(page.getByText('8자 이상, 영문/숫자/특수문자 포함'))
.toBeVisible();
});
test('이미 사용 중인 이메일', async ({ page }) => {
// API 모킹
await page.route('**/api/signup', (route) => {
route.fulfill({
status: 400,
body: JSON.stringify({ error: '이미 사용 중인 이메일입니다' })
});
});
await page.getByLabel('이메일').fill('existing@example.com');
await page.getByLabel('비밀번호').fill('StrongPass123!');
await page.getByLabel('비밀번호 확인').fill('StrongPass123!');
await page.getByRole('button', { name: '가입하기' }).click();
await expect(page.getByText('이미 사용 중인 이메일입니다'))
.toBeVisible();
});
});
Cypress 버전
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
describe('회원가입 폼 검증', () => {
beforeEach(() => {
cy.visit('/signup')
})
it('모든 필드를 올바르게 입력하면 회원가입 성공', () => {
cy.get('[data-testid="email"]').type('newuser@example.com')
cy.get('[data-testid="password"]').type('StrongPass123!')
cy.get('[data-testid="password-confirm"]').type('StrongPass123!')
cy.get('[data-testid="name"]').type('김철수')
cy.get('[data-testid="phone"]').type('010-9876-5432')
cy.get('[data-testid="terms-checkbox"]').check()
cy.get('[data-testid="signup-button"]').click()
cy.url().should('include', '/welcome')
cy.contains('회원가입이 완료되었습니다').should('be.visible')
})
it('비밀번호 불일치 시 에러 표시', () => {
cy.get('[data-testid="password"]').type('password123')
cy.get('[data-testid="password-confirm"]').type('password456')
cy.get('[data-testid="signup-button"]').click()
cy.contains('비밀번호가 일치하지 않습니다').should('be.visible')
})
it('약한 비밀번호 경고', () => {
cy.get('[data-testid="password"]').type('123')
cy.contains('8자 이상, 영문/숫자/특수문자 포함')
.should('be.visible')
})
it('이미 사용 중인 이메일', () => {
// API 인터셉트
cy.intercept('POST', '**/api/signup', {
statusCode: 400,
body: { error: '이미 사용 중인 이메일입니다' }
}).as('signupRequest')
cy.get('[data-testid="email"]').type('existing@example.com')
cy.get('[data-testid="password"]').type('StrongPass123!')
cy.get('[data-testid="password-confirm"]').type('StrongPass123!')
cy.get('[data-testid="signup-button"]').click()
cy.wait('@signupRequest')
cy.contains('이미 사용 중인 이메일입니다').should('be.visible')
})
})
Best Practices
1. 선택자 우선순위
권장 순서:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1. 역할 기반 (가장 권장)
await page.getByRole('button', { name: '로그인' })
// 2. 레이블 (폼 요소)
await page.getByLabel('이메일')
// 3. Placeholder
await page.getByPlaceholder('검색어를 입력하세요')
// 4. 텍스트
await page.getByText('환영합니다')
// 5. Test ID (안정적이지만 HTML 수정 필요)
await page.getByTestId('submit-button')
// ❌ 피해야 할 선택자
await page.locator('.btn-primary') // CSS 변경 시 깨짐
await page.locator('div > button:nth-child(3)') // 구조 변경 시 깨짐
2. Test ID 활용
HTML에 data-testid 속성 추가:
1
2
3
<button data-testid="login-button">로그인</button>
<input data-testid="email-input" type="email" />
<div data-testid="error-message">에러 메시지</div>
테스트 코드:
1
2
3
4
5
// Playwright
await page.getByTestId('login-button').click();
// Cypress
cy.get('[data-testid="login-button"]').click();
장점: CSS 클래스나 구조 변경에 영향 받지 않음
3. 자동 대기 활용
Playwright는 자동 대기 기본 제공:
1
2
3
4
5
6
// ✅ 좋은 예 - 자동으로 요소가 나타날 때까지 대기
await page.click('button');
// ❌ 나쁜 예 - 명시적 대기 불필요
await page.waitForTimeout(1000);
await page.click('button');
Cypress는 자동 재시도:
1
2
3
4
5
6
// ✅ 좋은 예 - 자동으로 재시도하며 대기
cy.get('button').click()
// ❌ 나쁜 예 - 고정 대기
cy.wait(1000)
cy.get('button').click()
4. API 모킹
Playwright:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// API 응답 모킹
await page.route('**/api/users', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: '홍길동' },
{ id: 2, name: '김철수' }
])
});
});
// 요청 확인
const [request] = await Promise.all([
page.waitForRequest('**/api/login'),
page.click('[data-testid="login-button"]')
]);
expect(request.postDataJSON()).toEqual({ email, password });
Cypress:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// API 인터셉트
cy.intercept('GET', '**/api/users', {
statusCode: 200,
body: [
{ id: 1, name: '홍길동' },
{ id: 2, name: '김철수' }
]
}).as('getUsers')
cy.visit('/users')
cy.wait('@getUsers')
// 요청 검증
cy.wait('@getUsers').its('request.body').should('deep.equal', {
page: 1,
limit: 10
})
5. 페이지 객체 모델 (POM)
LoginPage.ts:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { Page } from '@playwright/test';
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.page.getByLabel('이메일').fill(email);
await this.page.getByLabel('비밀번호').fill(password);
await this.page.getByRole('button', { name: '로그인' }).click();
}
async getErrorMessage() {
return await this.page.getByTestId('error-message').textContent();
}
}
테스트에서 사용:
1
2
3
4
5
6
7
8
9
10
11
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
test('로그인 테스트', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('test@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
6. 테스트 격리
각 테스트는 독립적이어야 합니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ❌ 나쁜 예 - 테스트 간 의존성
test('상품 추가', async ({ page }) => {
// 상품 추가
await addProduct(page, 'Product A');
});
test('상품 삭제', async ({ page }) => {
// 이전 테스트의 상품에 의존
await deleteProduct(page, 'Product A');
});
// ✅ 좋은 예 - 독립적인 테스트
test('상품 추가 및 삭제', async ({ page }) => {
await addProduct(page, 'Product A');
await deleteProduct(page, 'Product A');
});
test('상품 추가만 테스트', async ({ page }) => {
await addProduct(page, 'Product B');
await expect(page.getByText('Product B')).toBeVisible();
});
7. 스크린샷과 비디오
Playwright 설정:
1
2
3
4
5
6
7
8
// playwright.config.ts
export default defineConfig({
use: {
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'on-first-retry',
},
});
수동 스크린샷:
1
2
await page.screenshot({ path: 'screenshot.png' });
await page.screenshot({ path: 'fullpage.png', fullPage: true });
Cypress 설정:
1
2
3
4
5
6
7
// cypress.config.js
module.exports = defineConfig({
e2e: {
video: true,
screenshotOnRunFailure: true,
},
})
8. 테스트 데이터 관리
fixtures 활용:
1
2
3
4
5
6
7
8
9
10
11
// tests/fixtures/users.json
{
"validUser": {
"email": "test@example.com",
"password": "password123"
},
"adminUser": {
"email": "admin@example.com",
"password": "admin123"
}
}
테스트에서 사용:
1
2
3
4
5
6
import users from './fixtures/users.json';
test('관리자 로그인', async ({ page }) => {
const { email, password } = users.adminUser;
await login(page, email, password);
});
CI/CD 통합
GitHub Actions with Playwright
.github/workflows/playwright.yml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
name: Playwright Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
GitHub Actions with Cypress
.github/workflows/cypress.yml:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
name: Cypress Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
cypress-run:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Cypress run
uses: cypress-io/github-action@v5
with:
build: npm run build
start: npm start
wait-on: 'http://localhost:3000'
- name: Upload screenshots
uses: actions/upload-artifact@v3
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshots
- name: Upload videos
uses: actions/upload-artifact@v3
if: always()
with:
name: cypress-videos
path: cypress/videos
Docker 환경에서 실행
Dockerfile:
1
2
3
4
5
6
7
8
9
10
FROM mcr.microsoft.com/playwright:v1.40.0-jammy
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npx", "playwright", "test"]
docker-compose.yml:
1
2
3
4
5
6
7
8
9
version: '3.8'
services:
playwright:
build: .
volumes:
- ./tests:/app/tests
- ./playwright-report:/app/playwright-report
environment:
- CI=true
실행:
1
docker-compose up --abort-on-container-exit
자주 묻는 질문 (FAQ)
Q1. Playwright와 Cypress 중 어느 것을 선택해야 하나요?
A: 프로젝트 요구사항에 따라 다릅니다.
Playwright 선택:
- 멀티 브라우저 테스트 필요 (Safari 포함)
- 빠른 실행 속도 중요
- Python/Java/.NET 사용
- 복잡한 네트워크 시나리오
Cypress 선택:
- 개발자 경험(DX) 최우선
- 디버깅 자주 필요
- Chrome/Firefox만 지원하면 됨
- 팀원들이 테스트 초보
Q2. E2E 테스트를 몇 개나 작성해야 하나요?
A: 핵심 사용자 워크플로우만 커버하세요.
권장 개수:
- 소규모 앱: 5-10개
- 중규모 앱: 10-30개
- 대규모 앱: 30-100개
우선순위:
- ✅ 회원가입/로그인
- ✅ 핵심 비즈니스 로직 (주문, 결제 등)
- ✅ 데이터 손실 위험 작업
- ⚠️ 엣지 케이스 (선택적)
70/20/10 법칙: 단위 테스트 70%, 통합 테스트 20%, E2E 테스트 10%
Q3. E2E 테스트가 너무 느려요. 어떻게 하나요?
A: 여러 최적화 방법이 있습니다.
- 병렬 실행
1
2
3
4
5
# Playwright
npx playwright test --workers=4
# Cypress (유료)
npx cypress run --parallel
- 헤드리스 모드
1
2
3
4
// playwright.config.ts
use: {
headless: true, // 기본값
}
- 불필요한 대기 제거
1
2
3
4
5
// ❌ 나쁜 예
await page.waitForTimeout(3000);
// ✅ 좋은 예
await page.waitForSelector('[data-testid="content"]');
- API 모킹 활용
1
2
3
4
// 실제 API 대신 모킹
await page.route('**/api/**', (route) => {
route.fulfill({ body: mockData });
});
Q4. 테스트가 자주 실패해요 (Flaky Tests)
A: 다음을 확인하세요.
- 명시적 대기 대신 자동 대기
1
2
3
4
5
// ❌ 불안정
await page.waitForTimeout(1000);
// ✅ 안정적
await page.waitForSelector('button');
- 네트워크 안정성
1
2
// API 응답 대기
await page.waitForResponse('**/api/users');
- 애니메이션 비활성화
1
2
3
4
5
6
// playwright.config.ts
use: {
actionTimeout: 10000,
// 애니메이션 비활성화
reducedMotion: 'reduce',
}
- 재시도 설정
1
2
// playwright.config.ts
retries: process.env.CI ? 2 : 0,
Q5. 로그인 상태를 테스트마다 유지하려면?
A: 세션 저장 및 재사용하세요.
Playwright:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// global-setup.ts
import { chromium } from '@playwright/test';
async function globalSetup() {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
// 로그인
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
// 세션 저장
await context.storageState({ path: 'auth.json' });
await browser.close();
}
export default globalSetup;
1
2
3
4
5
6
7
// playwright.config.ts
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
use: {
storageState: 'auth.json',
},
});
Cypress:
1
2
3
4
5
6
7
8
9
10
// cypress/support/commands.js
Cypress.Commands.add('login', () => {
cy.session('user-session', () => {
cy.visit('/login')
cy.get('[data-testid="email"]').type('test@example.com')
cy.get('[data-testid="password"]').type('password')
cy.get('[data-testid="login-button"]').click()
cy.url().should('include', '/dashboard')
})
})
1
2
3
4
5
// 테스트에서 사용
beforeEach(() => {
cy.login()
cy.visit('/products')
})
Q6. 모바일 브라우저 테스트는 어떻게 하나요?
A: 에뮬레이션 기능을 사용하세요.
Playwright:
1
2
3
4
5
6
7
8
import { test, devices } from '@playwright/test';
test.use({ ...devices['iPhone 13'] });
test('모바일 테스트', async ({ page }) => {
await page.goto('/');
// iPhone 13 뷰포트로 실행
});
여러 기기 동시 테스트:
1
2
3
4
5
6
7
// playwright.config.ts
projects: [
{ name: 'Desktop Chrome', use: { ...devices['Desktop Chrome'] } },
{ name: 'iPhone 13', use: { ...devices['iPhone 13'] } },
{ name: 'iPad Pro', use: { ...devices['iPad Pro'] } },
{ name: 'Pixel 5', use: { ...devices['Pixel 5'] } },
],
Cypress:
1
2
3
4
5
6
7
// cypress.config.js
module.exports = defineConfig({
e2e: {
viewportWidth: 375,
viewportHeight: 667,
},
})
1
2
3
// 테스트에서 뷰포트 변경
cy.viewport('iphone-x')
cy.viewport(375, 812)
Q7. CI 환경에서 테스트가 로컬과 다르게 동작해요
A: 환경 차이를 최소화하세요.
- 타임존 통일
1
2
3
4
5
// playwright.config.ts
use: {
timezoneId: 'Asia/Seoul',
locale: 'ko-KR',
}
- Docker 사용
1
2
3
4
5
6
# 로컬에서도 CI와 동일한 환경
docker run -it --rm \
-v $(pwd):/work \
-w /work \
mcr.microsoft.com/playwright:v1.40.0-jammy \
npx playwright test
- 환경변수 관리
1
2
3
// .env.test
BASE_URL=http://localhost:3000
API_URL=http://localhost:4000
- 브라우저 버전 고정
1
2
3
4
5
{
"dependencies": {
"@playwright/test": "1.40.0"
}
}
결론
E2E 테스트는 실제 사용자 경험을 검증하는 가장 확실한 방법입니다. Playwright와 Cypress는 각각 장단점이 있으므로 프로젝트 요구사항에 맞게 선택하세요.
핵심 요약:
- ✅ 선택 기준: Playwright (멀티 브라우저, 속도) vs Cypress (DX, 디버깅)
- ✅ Best Practices: Test ID 사용, 자동 대기, API 모킹, POM 패턴
- ✅ 안정성: 재시도 설정, Flaky 테스트 방지, 환경 통일
- ✅ CI/CD: GitHub Actions, Docker, 병렬 실행
- ✅ 적정 개수: 핵심 워크플로우만 (70/20/10 법칙)
E2E 테스트는 초기 설정 비용이 있지만, 배포 전 버그를 잡아내어 사용자에게 안정적인 서비스를 제공할 수 있습니다. 핵심 사용자 시나리오부터 시작하여 점진적으로 테스트를 확장해나가세요!
다음 단계:
E2E 테스트를 마스터했다면 다음을 학습해보세요:
- 시각적 회귀 테스트: Percy, Chromatic
- 성능 테스트: Lighthouse CI, WebPageTest
- 접근성 테스트: axe-core, Pa11y
- 부하 테스트: k6, Artillery
피드백 환영:
이 글이 도움이 되셨나요? 댓글로 피드백을 남겨주시면 더 좋은 콘텐츠로 보답하겠습니다! 🚀