E2E 테스트 flaky 줄이기 — 실패 원인을 구조적으로 제거하는 방법
CI에서 50% 확률로 실패하는 테스트가 있었다. 무시하다가 결국 폭발했다.
문제: Flaky Test 란?
같은 코드를 실행하는데도 때로는 통과, 때로는 실패하는 테스트.
우리의 경우:
test('사용자가 음성 통화를 시작할 수 있다', async ({ page }) => {
await page.goto('http://localhost:3000');
await page.click('button:has-text("통화 시작")');
await page.waitForSelector('.call-active'); // ← 이게 때때로 timeout
});
원인: 네트워크가 느리거나, 서버 응답이 느리면 timeout 초과
Flaky의 주요 원인
1. Timing issues (40%)
2. Data dependency (30%)
3. Selector instability (15%)
4. Network flakiness (10%)
5. 기타 (5%)
해결 1: Timing Issues
나쁜 방법:
// ❌ 고정 대기
await page.waitForTimeout(1000); // 1초 대기
좋은 방법:
// ✅ 조건 기반 대기
await page.waitForSelector('.call-active', {
timeout: 5000, // 최대 5초
});
// 또는 더 명시적으로
await page.waitForFunction(
() => document.querySelectorAll('.call-active').length > 0,
{ timeout: 5000 }
);
또 다른 패턴:
// ✅ 네트워크 idle 대기
await page.waitForLoadState('networkidle', { timeout: 5000 });
해결 2: Data Dependency
문제: 테스트 데이터가 일관되지 않음
// ❌ 나쁨: 기존 데이터 사용
test('사용자 목록을 본다', async ({ page }) => {
await page.goto('http://localhost:3000/users');
await page.waitForSelector('[data-testid="user-row"]');
const count = await page.locator('[data-testid="user-row"]').count();
expect(count).toBeGreaterThan(0); // 때때로 실패 (데이터가 없을 수 있음)
});
좋은 방법:
// ✅ 테스트마다 격리된 데이터 생성
test('사용자 목록을 본다', async ({ page }) => {
// 1. 테스트 데이터 생성
const testUser = await db.users.create({
name: 'Test User ' + Date.now(),
email: `test-${Date.now()}@example.com`,
});
// 2. 테스트 실행
await page.goto('http://localhost:3000/users');
await page.getByText(testUser.name).waitFor();
// 3. 정리
await db.users.delete(testUser.id);
});
해결 3: Selector Instability
문제: CSS selector가 변경되면 테스트 깨짐
// ❌ 나쁨: 구현 세부사항에 의존
await page.click('.MuiButton-root:nth-child(3)');
좋은 방법:
// ✅ 명확한 testid 사용
await page.click('[data-testid="start-call-button"]');
// React에서
<button data-testid="start-call-button">통화 시작</button>
또는 의미 있는 선택자:
// ✅ 사람이 읽을 수 있는 텍스트
await page.click('button:has-text("통화 시작")');
해결 4: Network Mocking
문제: 실제 네트워크 의존 → 느린 서버, 끊김 등
// ✅ API Mock
test('메시지 전송', async ({ page }) => {
// 네트워크 응답 모킹
await page.route('**/api/messages', route => {
route.abort('failed'); // 네트워크 오류 시뮬레이션
});
await page.goto('http://localhost:3000');
await page.fill('[data-testid="message-input"]', 'Hello');
await page.click('[data-testid="send-button"]');
// 에러 메시지 확인
await page.waitForSelector('[data-testid="error-message"]');
});
또는 느린 네트워크:
// ✅ 느린 네트워크 시뮬레이션
await page.route('**/api/**', async route => {
await new Promise(resolve => setTimeout(resolve, 1000)); // 1초 지연
await route.continue();
});
해결 5: Retry 전략
완벽한 테스트를 만들 수 없다면, 재시도 로직을 추가:
// Playwright config
module.exports = {
use: {
// ...
},
webServer: {
command: 'npm run dev',
port: 3000,
},
retries: process.env.CI ? 3 : 0, // CI에서만 3회 재시도
};
또는 테스트 레벨에서:
test.describe('주요 기능', () => {
test.describe.configure({ retries: 2 });
test('음성 통화 시작', async ({ page }) => {
// ...
});
});
해결 6: CI 병렬화
느린 테스트를 병렬로 실행:
// playwright.config.ts
export const config = {
workers: process.env.CI ? 4 : 1, // CI에서는 4개 워커
timeout: 30 * 1000,
expect: {
timeout: 5000,
},
};
실전 팁
1. waitForSelector 대신 waitForFunction 사용
2. 고정 대기(sleep)는 절대 금지
3. 모든 테스트 요소에 data-testid 추가
4. API는 mock, UI는 실제로 테스트
5. 테스트 간 데이터 격리
6. 느린 CI에서는 재시도 활성화
결론
Flaky test는 제거할 수 없다. 하지만 원인을 구조적으로 제거하면 99% 안정화할 수 있다.