E2E 테스트 flaky 줄이기 — 실패 원인을 구조적으로 제거하는 방법

게시일: 2025년 4월 22일 · 14분 읽기

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% 안정화할 수 있다.

iL
ian.lab

실무 개발자입니다. 현장에서 겪은 문제와 해결 과정을 기록합니다. 오류 제보는 연락처로 보내주세요.