Playwright를 활용한 E2E 테스트 작성 가이드

들어가며

이 문서는 웹 팀원들이 고품질의 End-to-End(E2E) 테스트를 작성하기 위한 철학, 전략, 그리고 구체적인 패턴을 공유하기 위해 작성되었습니다. 목표는 단순히 동작하는 테스트를 넘어, 유지보수 가능하고(Maintainable), 신뢰할 수 있으며(Reliable), 안정적인(Stable) 테스트코드를 구축하는 것입니다.


1. E2E 테스트의 기본 철학과 전략

무엇을, 왜 테스트할 것인가?

E2E 테스트는 비용이 많이 드는 자원입니다. 따라서 모든 기능을 E2E 테스트로 커버하려는 생각은 비효율적이며 지속 가능하지 않습니다. 우리는 가장 중요한 사용자 여정(User Journey)비즈니스 크리티컬 경로(Business-Critical Path) 에 집중해야 합니다.

✅ 테스트 우선순위

  1. 핵심 비즈니스 플로우: 사용자가 한도조회 후 대출을 신청하고 결과를 확인하는 전체 과정처럼, 서비스의 핵심 가치를 제공하는 경로.
  2. 수익 창출과 직결되는 기능: 한도조회, 대출 신청, 금융사 또는 인앱약정으로 라우팅 등 직접적인 비즈니스 임팩트가 있는 기능.
  3. 복잡한 통합 지점: 여러 내부 서비스나 외부 API가 얽혀있어 단위/통합 테스트만으로는 검증이 어려운 부분.
  4. 치명적인 오류 경로: “기대출 존재”, “서버 장애”와 같이 사용자의 정상적인 플로우를 막는 주요 예외 처리 시나리오.

❌ 테스트 지양 대상

  1. 개별 UI 컴포넌트의 모든 상태: 버튼의 색상, 비활성화 상태 등은 Storybook이나 컴포넌트 테스트로 확인하는 것이 더 효율적입니다. (단, E2E 플로우 상에서 특정 상태가 중요한 분기점이 될 때는 예외)
  2. 단순 정보성 페이지의 모든 텍스트: 모든 정적 컨텐츠를 검증할 필요는 없습니다. 핵심 메시지가 표시되는지만 확인하면 충분합니다.
  3. 외부 서비스 자체의 기능: 외부 API가 정상적으로 동작하는지는 해당 서비스의 책임입니다. 우리는 우리 서비스가 외부 API의 특정 응답(성공, 실패, 예외)에 따라 올바르게 반응하는지만 테스트하면 됩니다.

2. 안정적인 테스트 코드 작성의 핵심

불안정한(Flaky) 테스트는 신뢰를 무너뜨리고 유지보수 비용을 급증시키는 주범입니다. 안정적인 테스트를 위한 세 가지 핵심 요소를 알아봅시다.

선택자(Selector) 전략: 견고함과 명료함의 균형

좋은 선택자는 UI 변경에 강하고, 코드만 봐도 의도가 명확하게 드러나야 합니다. Playwright는 사용자 관점의 선택자를 우선하도록 권장합니다.

🏆 선택자 우선순위

  1. getByRole: 웹 표준과 접근성(ARIA)을 따르는 가장 이상적인 방법. 버튼, 링크, 헤딩 등을 역할로 찾습니다.
  2. getByLabel: aria-label이나 <label> 태그와 연결된 요소를 찾습니다. 접근성과 테스트 용이성을 동시에 잡는 훌륭한 전략입니다.
  3. getByText: 사용자에게 보이는 텍스트로 요소를 찾습니다.
  4. getByPlaceholder: 플레이스홀더 텍스트로 입력 필드를 찾습니다.
  5. getByTestId: 위 방법으로 찾기 어려운 경우 사용하는 최후의 보루.

나쁜 선택자 vs 좋은 선택자

text
// ❌ 나쁜 예시: CSS 선택자에 의존
test('대출 신청 버튼 클릭', async ({ page }) => {
  // 문제점 1: HTML 구조 변경에 취약
  await page.locator('div.loan-section > div:nth-child(3) > button').click();

  // 문제점 2: 클래스명 변경에 취약
  await page.locator('.btn-primary-large').click();

  // 문제점 3: 의도가 불분명
  await page.locator('#submit-btn-123').click();
});

// 🤔 개선된 예시: 하지만 여전히 문제가 있음
test('대출 신청 버튼 클릭', async ({ page }) => {
  // 문제점: 텍스트 변경에 취약 (마케팅팀이 "신청하기" → "지금 신청" 변경 시 실패)
  await page.getByText('신청하기').click();
});

// ✅ 좋은 예시: 역할 기반 선택자
test('대출 신청 버튼 클릭', async ({ page }) => {
  // 장점 1: 접근성 준수 (스크린 리더 호환)
  // 장점 2: 의도가 명확 (버튼의 역할과 이름)
  // 장점 3: 마이너한 텍스트 변경에도 안정적
  await page.getByRole('button', { name: /신청|지원|apply/i }).click();

  // 또는 aria-label 활용
  await page.getByLabel('대출 신청하기').click();
});

발생할 수 있는 문제들:

  • CSS 선택자: HTML 구조나 클래스명 변경 시 테스트 실패
  • 고정 텍스트: A/B 테스트나 마케팅 문구 변경 시 테스트 실패
  • ID 선택자: 동적 ID 생성 시스템에서 예측 불가능한 실패

대기(Wait) 전략: 명시적 대기의 함정들

text
// ❌ 나쁜 예시: 고정 시간 대기
test('폴링 후 상태 변경 확인', async ({ page }) => {
  await page.getByRole('button', { name: '신청하기' }).click();

  // 문제점 1: 불필요하게 긴 대기 시간 (항상 3초 대기)
  await page.waitForTimeout(3000);

  // 문제점 2: 네트워크가 느린 환경에서는 3초로도 부족할 수 있음
  await expect(page.getByText('신청 완료')).toBeVisible();

  // 문제점 3: networkidle의 함정
  await page.waitForLoadState('networkidle'); // 분석 스크립트 때문에 타임아웃 가능
});

// ✅ 좋은 예시: 조건 기반 대기
test('폴링 후 상태 변경 확인', async ({ page }) => {
  await page.getByRole('button', { name: '신청하기' }).click();

  // 장점 1: 조건이 충족되는 즉시 다음 단계 진행
  // 장점 2: 타임아웃 설정으로 최대 대기 시간 제한
  await expect(page.getByText('신청 완료')).toBeVisible({ timeout: 15000 });

  // 또는 특정 API 응답 대기
  const responsePromise = page.waitForResponse(response =>
    response.url().includes('/api/loan-application') && response.status() === 200
  );
  await page.getByRole('button', { name: '신청하기' }).click();
  await responsePromise;
});

발생할 수 있는 문제들:

  • 고정 시간 대기: 테스트 시간 증가, 환경별 불안정성
  • networkidle 남용: 백그라운드 스크립트로 인한 예상치 못한 타임아웃
  • 조건 없는 대기: 실제 완료 상태와 무관한 대기로 인한 false positive

API 모킹: 외부 의존성 제거의 중요성

text
// ❌ 나쁜 예시: 실제 API에 의존하는 테스트
test('대출 신청 성공 시나리오', async ({ page }) => {
  await page.goto('/jeonse/application');

  // 문제점 1: 실제 서버 상태에 따라 테스트 결과가 달라짐
  // 문제점 2: 네트워크 지연으로 인한 불안정성
  // 문제점 3: 테스트 데이터가 실제 DB에 누적됨
  await page.getByRole('button', { name: '신청하기' }).click();

  // 문제점 4: 서버가 다운되면 테스트도 실패
  await expect(page.getByText('신청이 완료되었습니다')).toBeVisible();
});

// ✅ 좋은 예시: API 모킹을 통한 안정적인 테스트
test('대출 신청 성공 시나리오', async ({ page }) => {
  // 장점 1: 일관된 응답으로 안정적인 테스트
  await page.route('**/api/v1/loan-application', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        status: 'success',
        applicationId: 'test-12345',
        message: '신청이 완료되었습니다'
      })
    });
  });

  await page.goto('/jeonse/application');
  await page.getByRole('button', { name: '신청하기' }).click();

  // 장점 2: 빠른 테스트 실행
  // 장점 3: 외부 서비스 장애와 무관
  await expect(page.getByText('신청이 완료되었습니다')).toBeVisible();
});

// ✅ 더 좋은 예시: 에러 시나리오도 쉽게 테스트
test('서버 에러 시 사용자 친화적 메시지 표시', async ({ page }) => {
  await page.route('**/api/v1/loan-application', async route => {
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({
        error: 'INTERNAL_SERVER_ERROR',
        message: '서버 오류가 발생했습니다'
      })
    });
  });

  await page.goto('/jeonse/application');
  await page.getByRole('button', { name: '신청하기' }).click();

  // 실제 서버 에러 없이도 에러 시나리오 테스트 가능
  await expect(page.getByText('잠시 후 다시 시도해주세요')).toBeVisible();
});

발생할 수 있는 문제들:

  • 외부 API 의존: 서버 장애, 네트워크 문제로 인한 테스트 실패
  • 데이터 오염: 테스트 데이터가 실제 시스템에 누적
  • 에러 시나리오 테스트 어려움: 실제 에러 상황을 재현하기 어려움

플레이키(Flaky) 테스트 방지 및 디버깅

text
// ❌ 나쁜 예시: 재시도에만 의존하는 접근
// playwright.config.ts
export default defineConfig({
  retries: 5, // 문제점: 근본 원인 해결 없이 재시도만 늘림
  // ...
});

// test.spec.ts
test('불안정한 테스트', async ({ page }) => {
  await page.goto('/loan-application');

  // 문제점 1: 레이스 컨디션 - 버튼이 활성화되기 전에 클릭 시도
  await page.getByRole('button', { name: '신청하기' }).click();

  // 문제점 2: 고정된 테스트 데이터로 인한 충돌
  await page.fill('[name="email"]', 'test@example.com'); // 다른 테스트와 동일한 이메일

  // 문제점 3: 타이밍 이슈 - API 응답을 기다리지 않음
  await expect(page.getByText('신청 완료')).toBeVisible();
});

// ✅ 좋은 예시: 근본 원인을 해결하는 접근
test('안정적인 테스트', async ({ page }) => {
  // 해결책 1: API 모킹으로 외부 의존성 제거
  await page.route('**/api/v1/loan-application', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ status: 'success' })
    });
  });

  await page.goto('/loan-application');

  // 해결책 2: 버튼 상태 확인 후 클릭
  const submitButton = page.getByRole('button', { name: '신청하기' });
  await expect(submitButton).toBeEnabled();
  await submitButton.click();

  // 해결책 3: 동적 테스트 데이터 생성
  const uniqueEmail = `test-${Date.now()}@example.com`;
  await page.fill('[name="email"]', uniqueEmail);

  // 해결책 4: 적절한 대기 조건
  await expect(page.getByText('신청 완료')).toBeVisible({ timeout: 10000 });
});

Trace Viewer를 활용한 디버깅

text
// playwright.config.ts - 디버깅을 위한 설정
export default defineConfig({
  use: {
    // 실패 시 스크린샷 및 비디오 저장
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    // 첫 번째 재시도 시 트레이스 생성
    trace: 'on-first-retry',
  },
  // ...
});

// 실패한 테스트 분석 방법
// 1. npx playwright show-trace test-results/path/to/trace.zip
// 2. 브라우저에서 타임라인 확인
// 3. 각 액션의 스크린샷, 네트워크 로그, 콘솔 출력 분석
// 4. 정확한 실패 지점과 원인 파악

3. Playwright 특화 베스트 프랙티스

페이지 객체 모델(POM): 중복 제거와 유지보수성

text
// ❌ 나쁜 예시: 모든 로직이 테스트에 노출됨
test('전세 대출 신청 플로우', async ({ page }) => {
  await page.goto('/jeonse/12345/67890');

  // 문제점 1: 선택자가 테스트 코드에 하드코딩됨
  await page.locator('[data-testid="loan-amount-input"]').fill('300000000');
  await page.locator('[data-testid="loan-period-select"]').selectOption('10년');

  // 문제점 2: 복잡한 상호작용 로직이 반복됨
  const submitButton = page.locator('[data-testid="submit-button"]');
  await expect(submitButton).toBeEnabled();
  await submitButton.click();

  // 문제점 3: UI 변경 시 모든 테스트 수정 필요
  await expect(page.locator('[data-testid="success-message"]')).toBeVisible();
});

test('전세 대출 신청 실패 케이스', async ({ page }) => {
  await page.goto('/jeonse/12345/67890');

  // 문제점 4: 동일한 코드가 여러 테스트에 중복됨
  await page.locator('[data-testid="loan-amount-input"]').fill('999999999'); // 한도 초과
  await page.locator('[data-testid="loan-period-select"]').selectOption('10년');

  const submitButton = page.locator('[data-testid="submit-button"]');
  await expect(submitButton).toBeEnabled();
  await submitButton.click();

  await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
});

// ✅ 좋은 예시: 페이지 객체 모델 활용
// pages/jeonse-application-page.ts
export class JeonseApplicationPage {
  readonly page: Page;

  constructor(page: Page) {
    this.page = page;
  }

  // 선택자 캡슐화
  private get loanAmountInput() {
    return this.page.getByLabel('대출 금액');
  }

  private get loanPeriodSelect() {
    return this.page.getByLabel('대출 기간');
  }

  private get submitButton() {
    return this.page.getByRole('button', { name: '신청하기' });
  }

  // 복잡한 액션 캡슐화
  async fillLoanDetails(amount: string, period: string) {
    await this.loanAmountInput.fill(amount);
    await this.loanPeriodSelect.selectOption(period);
  }

  async submitApplication() {
    await expect(this.submitButton).toBeEnabled();
    await this.submitButton.click();
  }

  async expectSuccessMessage() {
    await expect(this.page.getByText('신청이 완료되었습니다')).toBeVisible();
  }

  async expectErrorMessage(message: string) {
    await expect(this.page.getByText(message)).toBeVisible();
  }

  async navigateTo(applicationId: string, loanApplyId: string) {
    await this.page.goto(`/jeonse/${applicationId}/${loanApplyId}`);
  }
}

// 테스트 파일
test('전세 대출 신청 플로우', async ({ page }) => {
  const jeonseApp = new JeonseApplicationPage(page);

  await jeonseApp.navigateTo('12345', '67890');
  await jeonseApp.fillLoanDetails('300000000', '10년');
  await jeonseApp.submitApplication();
  await jeonseApp.expectSuccessMessage();
});

test('전세 대출 신청 실패 케이스', async ({ page }) => {
  const jeonseApp = new JeonseApplicationPage(page);

  await jeonseApp.navigateTo('12345', '67890');
  await jeonseApp.fillLoanDetails('999999999', '10년'); // 한도 초과
  await jeonseApp.submitApplication();
  await jeonseApp.expectErrorMessage('대출 한도를 초과했습니다');
});

POM의 장점:

  • 유지보수성: UI 변경 시 페이지 객체만 수정하면 됨
  • 재사용성: 여러 테스트에서 동일한 액션 재사용
  • 가독성: 테스트 의도가 명확하게 드러남

픽스처(Fixture) vs 헬퍼 함수: 올바른 추상화

text
// ❌ 나쁜 예시: beforeEach에 모든 설정 로직 집중
test.describe('전세 대출 테스트', () => {
  let page: Page;
  let jeonseApp: JeonseApplicationPage;

  test.beforeEach(async ({ browser }) => {
    // 문제점 1: 모든 테스트가 동일한 복잡한 설정을 받음
    page = await browser.newPage();

    // 문제점 2: 설정이 길어질수록 beforeEach가 복잡해짐
    await page.addInitScript(() => {
      window.localStorage.setItem('user-token', 'test-token');
      window.localStorage.setItem('feature-flags', '{"newUI": true}');
    });

    // 문제점 3: API 모킹 로직이 반복됨
    await page.route('**/api/v1/user/profile', async route => {
      await route.fulfill({
        status: 200,
        body: JSON.stringify({ id: 1, name: '테스트 사용자' })
      });
    });

    jeonseApp = new JeonseApplicationPage(page);
    await jeonseApp.navigateTo('12345', '67890');
  });

  test('기본 신청 플로우', async () => {
    // 문제점 4: 이 테스트는 복잡한 설정이 필요 없을 수도 있음
    await jeonseApp.fillLoanDetails('300000000', '10년');
    await jeonseApp.submitApplication();
  });
});

// ✅ 좋은 예시: 커스텀 픽스처로 유연한 설정
// fixtures.ts
export const test = base.extend<{
  authenticatedPage: Page;
  jeonseAppWithMocking: JeonseApplicationPage;
}>({
  // 인증된 페이지 픽스처
  authenticatedPage: async ({ page }, use) => {
    await page.addInitScript(() => {
      window.localStorage.setItem('user-token', 'test-token');
    });
    await use(page);
  },

  // API 모킹이 포함된 전세 앱 픽스처
  jeonseAppWithMocking: async ({ page }, use) => {
    // API 모킹 설정
    await page.route('**/api/v1/loan-application', async route => {
      await route.fulfill({
        status: 200,
        body: JSON.stringify({ status: 'success' })
      });
    });

    const jeonseApp = new JeonseApplicationPage(page);
    await jeonseApp.navigateTo('12345', '67890');
    await use(jeonseApp);
  },
});

// 테스트에서 필요한 픽스처만 선택적으로 사용
test('단순한 UI 테스트', async ({ page }) => {
  // 복잡한 설정 없이 기본 페이지만 사용
  const jeonseApp = new JeonseApplicationPage(page);
  await jeonseApp.navigateTo('12345', '67890');
  await expect(page.getByText('전세자금대출')).toBeVisible();
});

test('인증이 필요한 테스트', async ({ authenticatedPage }) => {
  // 인증된 페이지 픽스처 사용
  const jeonseApp = new JeonseApplicationPage(authenticatedPage);
  await jeonseApp.navigateTo('12345', '67890');
  await expect(authenticatedPage.getByText('환영합니다')).toBeVisible();
});

test('완전한 신청 플로우', async ({ jeonseAppWithMocking }) => {
  // API 모킹이 포함된 픽스처 사용
  await jeonseAppWithMocking.fillLoanDetails('300000000', '10년');
  await jeonseAppWithMocking.submitApplication();
  await jeonseAppWithMocking.expectSuccessMessage();
});

픽스처의 장점:

  • 선택적 사용: 테스트별로 필요한 설정만 적용
  • 격리성: 각 테스트가 독립적인 환경을 가짐
  • 조합 가능성: 여러 픽스처를 조합하여 사용 가능

4. 테스트 데이터 관리: 복잡성을 다루는 빌더 패턴

복잡한 Mock 데이터의 문제점

실제 프로덕션 환경의 API 응답은 매우 복잡한 중첩 구조를 가지고 있습니다. 전세자금대출 API만 해도 수십 개의 필드와 여러 단계의 중첩 객체를 포함하고 있어, 테스트마다 이런 데이터를 직접 구성하는 것은 비현실적입니다.

text
// ❌ 나쁜 예시: 테스트마다 복잡한 Mock 데이터를 직접 구성
test('전세 대출 신청 성공 시나리오', async ({ page }) => {
  // 문제점 1: 수백 줄의 Mock 데이터가 테스트 코드에 노출됨
  await page.route('**/api/v1/loan-detail/**', async route => {
    await route.fulfill({
      status: 200,
      body: JSON.stringify({
        id: 987654321,
        applicationId: 123456789,
        status: 'contract_applied',
        loanProgressStatus: 'CONTRACT_IN_PROGRESS',
        responseMessage: '정상',
        product: {
          id: 123,
          name: 'KB국민은행 전세자금대출',
          bankName: 'KB국민은행',
          bankCode: 'KB',
          interestRate: 3.5,
          maxAmount: 500000000,
          displayProperty: {
            v1: {
              bridge: {
                type: 'WEB'
              }
            }
          }
        },
        contractType: 'REGULAR',
        // ... 수십 개의 필드가 더 계속됨
      })
    });
  });

  // 문제점 2: 다른 시나리오 테스트 시 데이터 구조 복사-붙여넣기 반복
  // 문제점 3: 필드 하나만 바꾸려고 해도 전체 데이터를 다시 작성해야 함
});

test('기대출 존재 에러 시나리오', async ({ page }) => {
  // 문제점 4: 위와 거의 동일한 데이터를 다시 작성하되 일부 필드만 변경
  await page.route('**/api/v1/loan-detail/**', async route => {
    await route.fulfill({
      status: 200,
      body: JSON.stringify({
        id: 987654321,
        applicationId: 123456789,
        status: 'contract_failed',        // 이 부분만 다름
        responseMessage: '기대출존재',     // 이 부분만 다름
        // ... 나머지는 동일한 수백 줄의 데이터
      })
    });
  });
});

빌더 패턴을 활용한 우아한 해결책

text
// ✅ 좋은 예시: JeonseMockDataBuilder를 활용한 깔끔한 테스트
test('전세 대출 신청 성공 시나리오', async ({ page }) => {
  // 장점 1: 의도가 명확하게 드러나는 선언적 코드
  const mockData = new JeonseMockDataBuilder()
    .withApplicationSuccessState('success')
    .withBridgeType('WEB')
    .withContractType('REGULAR')
    .build();

  await page.route('**/api/v1/loan-detail/**', async route => {
    await route.fulfill({
      status: 200,
      body: JSON.stringify(mockData.loanApplyDetail)
    });
  });

  // 테스트 로직에 집중할 수 있음
});

test('기대출 존재 에러 시나리오', async ({ page }) => {
  // 장점 2: 차이점만 명시하면 됨
  const mockData = new JeonseMockDataBuilder()
    .withErrorScenario('existing_loan')  // 이 한 줄로 에러 시나리오 완성
    .build();

  await page.route('**/api/v1/loan-detail/**', async route => {
    await route.fulfill({
      status: 200,
      body: JSON.stringify(mockData.loanApplyDetail)
    });
  });
});

test('폴링 시나리오 - 처리 중에서 완료로 변경', async ({ page }) => {
  // 장점 3: 복잡한 폴링 로직도 간단하게 표현
  const mockData = new JeonseMockDataBuilder()
    .withPolling(3, 'contract_applied', 5)  // 3단계 폴링, 최종 상태, 최대 5회
    .withBridgeType('PHONE')
    .build();

  // 폴링 설정이 자동으로 적용됨
});

빌더 패턴의 핵심 장점

  1. 의도 표현: withErrorScenario(‘existing_loan’)처럼 테스트의 의도가 코드에서 바로 드러납니다.
  2. 재사용성: 기본 데이터 구조를 재사용하면서 필요한 부분만 변경할 수 있습니다.
  3. 유지보수성: API 구조가 변경되어도 빌더 클래스만 수정하면 모든 테스트가 자동으로 업데이트됩니다.
  4. 타입 안전성: TypeScript를 활용하여 컴파일 타임에 데이터 구조 오류를 잡을 수 있습니다.
text
// 실제 JeonseMockDataBuilder의 핵심 구조
export class JeonseMockDataBuilder {
  private mockData: JeonseMockData;
  private errorScenario?: ErrorScenarioType;

  constructor() {
    // 기본 데이터 구조를 실제 API 응답과 동일하게 초기화
    this.mockData = {
      loanApplyDetail: { /* 실제 API 응답 구조 */ },
      applicationDetail: { /* 실제 API 응답 구조 */ },
      // ...
    };
  }

  withErrorScenario(errorType: ErrorScenarioType): this {
    this.errorScenario = errorType;
    return this; // 메서드 체이닝을 위한 this 반환
  }

  withPolling(steps: number, finalStatus: LoanStatus): this {
    this.pollingConfig = { pollingSteps: steps, finalStatus };
    return this;
  }

  build(): JeonseMockData {
    // 설정된 옵션들을 기반으로 최종 데이터 생성
    return this.applyAllConfigurations();
  }
}

5. Given-When-Then: 테스트 구조화의 정석

명확한 테스트 시나리오 구성

좋은 E2E 테스트는 사용자 스토리처럼 읽혀야 합니다. Given-When-Then 패턴은 테스트의 의도를 명확하게 표현하고, 팀원들이 쉽게 이해할 수 있도록 도와줍니다.

text
// ❌ 나쁜 예시: 단계가 뒤섞인 혼란스러운 테스트
test('대출 신청 테스트', async ({ page }) => {
  await page.goto('/jeonse/123456/987654');
  await page.getByRole('button', { name: '신청하기' }).click();

  // 문제점 1: Given(전제조건)이 중간에 나타남
  await page.route('**/api/loan-application', async route => {
    await route.fulfill({ status: 200, body: JSON.stringify({...}) });
  });

  await expect(page.getByText('신청 완료')).toBeVisible();

  // 문제점 2: 추가 When(액션)이 Then(검증) 이후에 나타남
  await page.getByRole('button', { name: '확인' }).click();

  // 문제점 3: 테스트의 흐름을 파악하기 어려움
});

// ✅ 좋은 예시: Given-When-Then 구조가 명확한 테스트
test('사용자가 전세자금대출을 성공적으로 신청할 수 있다', async ({ page }) => {
  // ===== GIVEN (전제조건) =====
  // 성공적인 대출 신청 응답을 위한 API 모킹
  const mockData = new JeonseMockDataBuilder()
    .withApplicationSuccessState('success')
    .withBridgeType('WEB')
    .build();

  await page.route('**/api/v1/loan-application', async route => {
    await route.fulfill({
      status: 200,
      body: JSON.stringify(mockData.loanApplyDetail)
    });
  });

  // 사용자가 대출 상세 페이지에 있는 상태
  await page.goto('/jeonse/123456/987654');
  await expect(page.getByText('전세자금대출')).toBeVisible();

  // ===== WHEN (액션) =====
  // 사용자가 대출 신청 버튼을 클릭한다
  await page.getByRole('button', { name: '신청하기' }).click();

  // ===== THEN (검증) =====
  // 성공 메시지가 표시된다
  await expect(page.getByText('신청이 완료되었습니다')).toBeVisible();

  // 확인 버튼이 활성화된다
  await expect(page.getByRole('button', { name: '확인' })).toBeEnabled();
});

test('기대출이 존재할 때 적절한 에러 메시지가 표시된다', async ({ page }) => {
  // ===== GIVEN (전제조건) =====
  // 기대출 존재 에러 응답을 위한 API 모킹
  const mockData = new JeonseMockDataBuilder()
    .withErrorScenario('existing_loan')
    .build();

  await page.route('**/api/v1/loan-application', async route => {
    await route.fulfill({
      status: 400,
      body: JSON.stringify({
        error: 'EXISTING_LOAN',
        message: '기대출존재'
      })
    });
  });

  await page.goto('/jeonse/123456/987654');

  // ===== WHEN (액션) =====
  // 사용자가 대출 신청을 시도한다
  await page.getByRole('button', { name: '신청하기' }).click();

  // ===== THEN (검증) =====
  // 기대출 존재 에러 팝업이 표시된다
  await expect(page.getByText('기존 대출이 존재합니다')).toBeVisible();

  // 다른 상품 보기 버튼이 제공된다
  await expect(page.getByRole('button', { name: '다른 상품 보기' })).toBeVisible();
});

주석을 통한 명확한 구조 표현

text
// 실제 테스트 파일에서 권장하는 주석 스타일
test.describe('전세자금대출 신청 플로우', () => {
  test('정상 신청 시나리오', async ({ page }) => {
    // ===== GIVEN =====
    // API 모킹 설정
    // 페이지 초기 상태 설정

    // ===== WHEN =====
    // 사용자 액션 수행

    // ===== THEN =====
    // 결과 검증
  });
});

이러한 구조는 다음과 같은 이점을 제공합니다:

  1. 가독성: 테스트의 목적과 흐름이 한눈에 파악됩니다.
  2. 유지보수성: 각 단계별로 수정이 필요한 부분을 쉽게 찾을 수 있습니다.
  3. 협업: 팀원들이 테스트 시나리오를 쉽게 이해하고 리뷰할 수 있습니다.
  4. 디버깅: 실패 시 어느 단계에서 문제가 발생했는지 빠르게 파악할 수 있습니다.

6. 시각적 회귀 테스트 (Visual Regression Testing)

동적 데이터 처리의 중요성

text
// ❌ 나쁜 예시: 동적 데이터로 인한 불안정한 스크린샷
test('대출 상세 페이지 스크린샷', async ({ page }) => {
  await page.goto('/jeonse/12345/67890');

  // 문제점 1: 현재 시간이 포함되어 매번 다른 스크린샷
  // 문제점 2: 랜덤한 대출 금리가 표시되어 불안정
  // 문제점 3: 사용자별 다른 데이터로 인한 차이
  await expect(page).toHaveScreenshot('loan-detail.png');
});

// ✅ 좋은 예시: 동적 데이터 마스킹 및 모킹
test('대출 상세 페이지 스크린샷', async ({ page }) => {
  // 해결책 1: 고정된 데이터로 API 모킹
  await page.route('**/api/v1/loan-detail/**', async route => {
    await route.fulfill({
      status: 200,
      body: JSON.stringify({
        loanAmount: 300000000,
        interestRate: 3.5,
        createdAt: '2024-01-01T00:00:00Z', // 고정된 시간
        userName: '홍길동' // 고정된 사용자명
      })
    });
  });

  await page.goto('/jeonse/12345/67890');

  // 해결책 2: 동적 요소 마스킹
  await expect(page).toHaveScreenshot('loan-detail.png', {
    mask: [
      page.locator('[data-testid="current-time"]'), // 현재 시간 마스킹
      page.locator('[data-testid="session-id"]'),   // 세션 ID 마스킹
    ],
    // 해결책 3: 적절한 threshold 설정 (폰트 렌더링 차이 허용)
    threshold: 0.2,
  });
});

결론

지난 테스트 코드 도입 과정에서의 실패 원인을 분석해보면, 복잡한 목업 코드와 테스트 코드 작성에 대한 명확한 가이드 부재, 그리고 팀원 간의 테스트 코드 작성 필요성에 대한 동기화 부족이 주요 요인으로 나타났습니다. 이러한 문제를 해결하기 위해 이 문서를 작성하였습니다.

이 가이드에서 제시하는 “나쁜 예시”들은 실제 프로젝트에서 자주 발생하는 안티패턴입니다. 이러한 패턴들은 단기적으로는 작동할 수 있지만, 장기적으로 다음과 같은 문제를 초래할 수 있습니다:

기술적 부채의 누적

  • 유지보수 비용 증가: UI 변경 시 수많은 테스트를 수정해야 함
  • 테스트 신뢰도 저하: Flaky 테스트로 인해 개발 생산성이 감소
  • 디버깅 어려움: 실패 원인을 파악하기 어려워 문제 해결이 지연됨
  • Mock 데이터 지옥: 복잡한 API 응답을 테스트마다 반복 작성하는 비효율성

우리가 제시한 해결책의 가치

반면, 이 가이드에서 제시한 빌더 패턴, Given-When-Then 구조, 페이지 객체 모델 등은 초기 작성 비용이 다소 높을 수 있지만, 장기적으로 안정적이고 유지보수 가능한 테스트 스위트를 제공합니다.

1. 빌더 패턴의 투자 효과

text
// 한 번의 투자로
const mockData = new JeonseMockDataBuilder()
  .withErrorScenario('existing_loan')
  .build();

// 모든 팀원이 재사용 가능한 자산이 됩니다

2. Given-When-Then의 소통 효과

  • 비즈니스 요구사항이 테스트 코드에서 명확하게 드러남
  • 새로운 팀원도 테스트 시나리오를 쉽게 이해할 수 있음
  • QA 팀과의 협업이 원활해짐

3. 페이지 객체 모델의 확장성

  • UI 변경 시 한 곳만 수정하면 모든 테스트가 업데이트됨
  • 복잡한 상호작용 로직을 재사용 가능한 메서드로 캡슐화

지속 가능한 테스트 문화 구축

이 가이드의 궁극적인 목표는 “테스트를 위한 테스트”가 아닌, “제품 품질과 팀 생산성을 높이는 테스트” 문화를 만드는 것입니다.

위에서 제시한 패턴들을 적용하면:

  • 새로운 기능 개발 시 테스트 작성이 부담이 아닌 도구가 됩니다
  • 리팩토링 시 테스트가 안전망 역할을 충실히 수행합니다
  • 복잡한 비즈니스 로직도 명확하고 이해하기 쉬운 시나리오로 표현됩니다

함께 견고하고 신뢰할 수 있으며, 팀 전체가 자신 있게 의존할 수 있는 테스트 코드를 만들어 나갑시다. 이는 우리 팀의 기술적 성숙도를 한 단계 끌어올리는 중요한 투자입니다.