본문으로 건너뛰기
고급약 50분

자동화 프레임워크 직접 설계 — 빈 리포지터리에서 완성까지

레슨 1의 TAF 세 계층을 TypeScript + Playwright 저장소의 디렉터리와 코드로 옮긴다. 환경별 설정 분리, 픽스처와 테스트 데이터, 페이지·컴포넌트 추상화, 공통 단언, 격리와 재시도, 병렬 실행을 여섯 단계로 쌓으며 "이 구조가 없으면 나중에 무엇이 터지는가"로 설계 결정을 정당화한다.

  • #Playwright
  • #TypeScript
  • #프레임워크설계
  • #픽스처
  • #테스트격리
  • #병렬실행

개념

이 레슨이 만드는 것과 만들지 않는 것

레슨 1은 TAF 가 테스트 스크립트 · 비즈니스 로직 · 코어 라이브러리 세 계층으로 갈라지고 호출은 아래로만 흐른다고 정했다. 이 레슨은 그 계층을 디렉터리와 파일로 착지시킨다. 빈 저장소에서 여섯 단계로 쌓고, 단계마다 "이 경계를 지금 긋지 않으면 3개월 뒤 무엇이 터지는가" 를 적는다.

좋은 로케이터 고르기, 페이지 객체 문법, 대기 전략은 모듈 13 레슨 3·4·5 가 이미 다뤘다. 여기서는 그 기법을 쓴다고 가정하고 그 코드가 어느 디렉터리에 살며 무엇을 import 할 수 있는지 만 정한다. 파이프라인 트리거와 결과 수집은 모듈 14 가 맡는다. 주제는 기법도 운영도 아니라 구조와 경계 다.

0단계 — 뼈대와 의존 방향

qa-automation/
├── playwright.config.ts      실행 역량의 단일 진입점
├── tsconfig.json             경로 별칭으로 계층을 분리
├── eslint.config.js          의존 방향을 고정 (레슨 1)
├── src/core/                 ← SUT 를 모른다
│   └── http/client.ts · clock.ts · ids.ts · config/env.ts
├── src/domain/               ← 이 제품을 안다
│   ├── pages/ · api/ · data/ · assertions/
│   └── fixtures/             두 계층을 테스트에 배선하는 곳
├── tests/setup/auth.setup.ts 먼저 도는 셋업 · tests/smoke/ · regression/
└── artifacts/                실행 산출물. git 에 올리지 않는다

tests/ 는 읽히기 위한 디렉터리이고 src/ 는 재사용되기 위한 디렉터리다. tsconfig.json 의 paths 로 @core/*·@domain/* 별칭을 잡고 strict 를 켠다. 상대 경로만 쓰면 파일 이동이 리팩터링으로 번지고, 린트가 의존 방향을 감시할 이름 이 없어진다.

1단계 — 설정 분리: 환경은 코드가 아니다

환경 분기를 if 로 쓰면 환경 하나 추가가 전수 수정이 된다(레슨 1의 비교표). 설정은 한 번 파싱하고 타입으로 고정해 읽기 전용으로 흘린다.

// src/core/config/env.ts — 코어. 환경 이름만 알고 제품은 모른다
export function loadEnv() {
  const name = process.env.TEST_ENV ?? 'local';
  const missing = ['BASE_URL', 'API_URL'].filter((k) => !process.env[k]);
  if (missing.length > 0) {
    throw new Error(`환경 변수 누락: ${missing.join(', ')} — .env.example 을 복사하세요 (TEST_ENV=${name})`);
  }
  return Object.freeze({ name, baseURL: process.env.BASE_URL!, apiURL: process.env.API_URL! });
}

누락을 실행 시작 시점에 터뜨리고, 오류 메시지가 해결책을 말한다. 변수가 빈 채로 돌면 30분 뒤 "요소를 찾을 수 없음" 으로 실패하고 원인 추적에 또 30분이 든다.

// playwright.config.ts — 실행 역량이 모이는 유일한 파일
import { defineConfig, devices } from '@playwright/test';
import { loadEnv } from './src/core/config/env';
 
const env = loadEnv();
const isCI = !!process.env.CI;
 
export default defineConfig({
  testDir: './tests',
  outputDir: './artifacts/test-results',
  fullyParallel: true,
  retries: isCI ? 2 : 0,
  workers: isCI ? 4 : undefined,
  reporter: isCI ? [['blob'], ['junit', { outputFile: 'artifacts/junit.xml' }]]
                 : [['html', { outputFolder: 'artifacts/html' }], ['list']],
  use: {
    baseURL: env.baseURL,
    testIdAttribute: 'data-testid',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: 'artifacts/.auth/shopper.json' },
      dependencies: ['setup'],
    },
  ],
});

읽을 것은 값이 아니라 어떤 결정이 여기 모였는가 다. 재시도 횟수, 워커 수, 리포터, 추적 수집 조건 — 전부 gTAA 의 테스트 실행 역량이고 테스트 파일 어디에도 흩어지지 않았다. testIdAttribute 선언 하나로 page.getByTestId() 가 팀의 실제 속성명을 따라간다(그 속성을 개발팀과 합의하는 방법은 레슨 4).

2단계 — 픽스처: 배선은 테스트의 일이 아니다

픽스처는 비즈니스 로직 계층을 테스트에 꽂아 주는 어댑터 다. 여기가 서면 테스트 파일에서 조립 코드가 사라진다.

// src/domain/fixtures/shop-fixtures.ts
import { test as base } from '@playwright/test';
import { loadEnv } from '@core/config/env';
import { newCorrelationId } from '@core/ids';
import { SeedApi } from '@domain/api/seed-api';
 
interface Fx { correlationId: string; seed: SeedApi }
interface WorkerFx { apiBase: string }   // 워커 스코프는 두 번째 제네릭 인수
 
export const test = base.extend<Fx, WorkerFx>({
  // 워커 스코프 — 워커당 한 번만 계산한다
  apiBase: [async ({}, use) => { await use(loadEnv().apiURL); }, { scope: 'worker' }],
 
  // 테스트 스코프 — 매 테스트마다 새로 만든다
  correlationId: async ({}, use, testInfo) => {
    const id = newCorrelationId(testInfo.title);
    testInfo.annotations.push({ type: 'correlationId', description: id });
    await use(id);
  },
 
  page: async ({ page, correlationId }, use) => {
    await page.setExtraHTTPHeaders({ 'x-correlation-id': correlationId });
    await use(page);
  },
 
  seed: async ({ apiBase, correlationId }, use) => {
    const api = await SeedApi.create(apiBase, correlationId);
    await use(api);
    await api.cleanup();          // 테스트가 실패해도 반드시 돈다
  },
});
 
export { expect } from '@playwright/test';

설계 판단이 셋 들어 있다. 스코프를 구분한다 — 워커당 한 번이면 되는 것(설정 로딩, 토큰 발급)과 테스트마다 새로워야 하는 것(상관 ID, 시드 데이터)을 섞으면 병렬 실행에서 바로 깨진다. 정리를 use() 뒤에 둔다 — Playwright 는 그 코드를 테스트 종료 후, 실패했을 때도 실행한다. 본문 마지막 줄에 적으면 실패한 테스트는 정리를 건너뛴다. expect 를 재수출한다 — 커스텀 매처를 붙일 때 테스트 파일을 고치지 않는다.

3단계 — 테스트 데이터: 빌더와 시드 경로

테스트 데이터에서 터지는 건 거의 항상 공유 다. 두 테스트가 같은 계정을 쓰면 병렬 실행에서 서로를 망친다. 데이터는 빌더로 만들고 생성 지점에서 유일성을 보장한다 — 고정 계정을 공유하면 워커를 2개로 올리는 순간 간헐 실패가 시작된다.

// src/domain/data/order-builder.ts — 기본값 + 필요한 것만 덮어쓰기
const DEFAULTS: OrderSpec = { sku: 'SKU-1001', quantity: 1, status: 'paid' };
export const anOrder = (o: Partial<OrderSpec> = {}): OrderSpec => ({ ...DEFAULTS, ...o });
 
// src/domain/api/seed-api.ts (발췌)
export class SeedApi {
  private readonly created: string[] = [];
 
  async createOrder(spec: OrderSpec) {
    const email = `qa+${this.correlationId}@example.test`;   // 실행마다 유일
    const res = await this.ctx.post('/test/orders', { data: { ...spec, email } });
    if (!res.ok()) throw new Error(`주문 시드 실패 (${res.status()}): ${await res.text()}`);
    const { orderNo } = (await res.json()) as { orderNo: string };
    this.created.push(orderNo);
    return { orderNo, email };
  }
 
  async cleanup() {
    await Promise.allSettled(this.created.map((no) => this.ctx.delete(`/test/orders/${no}`)));
    this.created.length = 0;
  }
}

빌더가 하는 일은 테스트가 신경 쓰는 값만 보이게 하는 것 이다. seed.createOrder(anOrder({ status: 'shipping' })) 를 읽으면 배송 중 상태가 관심사라는 게 바로 보인다. Promise.allSettled 는 정리 중 하나가 실패해도 나머지를 지우기 위한 것이다 — 정리에서 예외를 던지면 원래의 실패 원인을 가린다.

시딩 경로는 셋이다 — API 직접 호출(기본값), DB 직접 삽입(가장 빠르지만 스키마 변경에 바로 깨지는 최후 수단), UI 경유(느리고 잘 깨짐. 그 UI 흐름 자체가 검증 대상일 때만). 즉 전제 조건은 API 로, 검증 대상은 UI 로 다. 모듈 13 레슨 10 이 구현을 다루므로 여기서는 계층 배정만 정한다 — 시드 API 클라이언트는 이 제품의 엔드포인트를 아니까 비즈니스 로직 이고, 그 아래 HTTP 래퍼는 코어 다.

4단계 — 페이지와 컴포넌트: 화면 단위로만 쪼개지 않는다

모듈 13 레슨 4 가 페이지 객체의 기본형을 다뤘다. 구조 관점에서 더 정할 것은 컴포넌트 추상화 다. 페이지마다 같은 헤더·테이블·모달을 반복 선언하면 페이지 객체 수에 비례해 중복이 커진다.

// src/domain/pages/components/DataTable.ts — 루트 로케이터만 받는 조각
export class DataTable {
  readonly root: Locator;
  constructor(page: Page, testId: string) { this.root = page.getByTestId(testId); }
  rowByText(t: string): Locator { return this.root.getByRole('row').filter({ hasText: t }); }
}
 
// src/domain/pages/OrderListPage.ts — 페이지는 컴포넌트를 조립한다
export class OrderListPage {
  readonly toast = this.page.getByTestId('toast');
  private readonly table = new DataTable(this.page, 'order-table');
  constructor(private readonly page: Page) {}
 
  async cancel(orderNo: string): Promise<void> {
    await this.table.rowByText(orderNo).getByRole('button', { name: '취소' }).click();
    await this.page.getByRole('dialog').getByRole('button', { name: '확인' }).click();
  }
}

구조 규칙 셋을 지킨다. 페이지 객체는 단언하지 않는다 — Locator 를 노출하고 판정은 테스트나 도메인 단언이 한다. 안에 expect 가 들어가면 그 메서드는 한 가지 판정에만 쓰이는 반쪽짜리가 된다. Page 를 받고 브라우저를 열지 않는다 — 생명주기는 픽스처의 책임이다. 컴포넌트는 루트 로케이터를 받는다 — 같은 테이블이 두 군데 나오면 테스트 id 만 바꿔 재사용한다.

5단계 — 격리와 병렬 실행: 같은 문제의 두 얼굴

fullyParallel: true 는 공짜가 아니다. 테스트가 서로 독립 일 때만 성립하고, 독립의 조건은 셋이다 — 데이터를 공유하지 않고, 순서를 가정하지 않고, 전역 상태(세션·기능 플래그·시간)를 바꾸지 않는다.

// tests/setup/auth.setup.ts — 로그인은 한 번만. 세션은 파일로 넘긴다
setup('구매자 세션 저장', async ({ page, context }) => {
  await new LoginPage(page).signIn(process.env.QA_USER!, process.env.QA_PW!);
  await expect(page.getByTestId('user-menu')).toBeVisible();
  await context.storageState({ path: 'artifacts/.auth/shopper.json' });
});

이 패턴이 주는 것은 속도만이 아니다. 로그인 실패와 기능 실패가 분리된다. 셋업 프로젝트가 실패하면 뒤따르는 테스트가 의존성 실패로 묶여, 리포트를 열자마자 "로그인이 안 됐다" 는 사실이 보인다. beforeEach 로그인은 200개가 각자의 이유로 실패한 것처럼 보이게 만든다.

순서 의존이 정말로 필요한 흐름만 test.describe.configure({ mode: 'serial' }) 로 예외 선언한다. 비용이 드는 선언 이다 — 앞이 실패하면 뒤가 전부 건너뛰기가 되고 병렬화 이득도 사라진다. 그래서 왜 직렬인지를 주석으로 남긴다.

로컬은 retries: 0 으로 플래키를 즉시 보고, CI 는 retries: 2 로 일시적 실패를 흡수하되 trace: 'on-first-retry' 로 첫 재시도에만 추적을 남긴다(수집 비용은 레슨 3). 핵심은 재시도로 통과한 테스트를 성공으로 보지 않는 것 이다 — Playwright 는 이 경우를 flaky 로 구분해 보고한다. 격리 없이 retries 를 올리면 플래키율만 가려진다.

6단계 — 공통 단언: 메시지를 설계한다

도메인 복합 단언은 여러 줄짜리 확인을 한 문장으로 만들고 실패 메시지를 사람이 읽게 만든다.

// src/domain/assertions/order-assertions.ts
export async function expectOrderStatus(page: Page, orderNo: string, expected: OrderStatus) {
  const badge = page.getByTestId(`order-${orderNo}-status`);
  await expect(badge, `주문 ${orderNo} 의 상태 배지가 "${expected}" 여야 한다`)
    .toHaveText(expected);
}

expect(locator, message) 의 둘째 인수가 실패 시 리포트 맨 위에 찍힌다. 이 한 줄 덕에 "Expected / Received" 만 남던 리포트가 어떤 주문의 어떤 배지인가 까지 말하게 된다. 커스텀 매처는 expect.extend 로 올리되 코어가 아니라 도메인 이다 — OrderStatus 를 아는 순간 코어 자격을 잃는다.

과잉 추상화를 멈추는 선

레슨 1 은 실러버스가 계층 수를 낮게 유지하라 고 권고한 것을 인용했다. 실무 판정 기준은 이렇다.

  • 클릭 하나에 도달하려고 4개 이상의 파일을 연다 → 계층이 과하다
  • 래퍼가 인수를 그대로 넘기고 아무 것도 더하지 않는다 → 지운다
  • 구현이 하나뿐인 추상 클래스·인터페이스가 있다 → 아직 필요하지 않다
  • 프레임워크 코드가 테스트 코드보다 길다 → 방향을 의심한다

규칙 하나로 압축하면 — 두 번째 사용처가 나타날 때 올린다. 첫 번째에 올린 추상화는 거의 항상 틀린 모양이고, 틀린 추상화는 중복보다 비싸다.

실무에서 왜 필요한가

첫째, 인수받은 저장소를 고칠 수 있다. 실무에서 빈 저장소로 시작하는 경우는 드물고 대개 몇 년치 자동화를 물려받는다. 여섯 단계를 쌓아 본 사람은 무엇이 없는지 를 목록으로 센다 — 설정이 흩어졌는지, 픽스처 없이 beforeEach 조립인지, 데이터가 공유 계정인지, 재시도가 플래키를 덮고 있는지.

둘째, 병렬화는 뒤늦게 켤 수 없다. 스위트가 1시간을 넘기면 반드시 병렬화 요구가 온다. 그때 데이터 격리와 세션 분리가 없으면 간헐 실패 폭발로 돌아오고 결국 워커를 1로 되돌린다. 3단계와 5단계를 처음에 깔아 두는 비용은 하루지만, 소급 적용 비용은 스위트 전체 재작업이다.

셋째, 구조가 리뷰 기준을 만든다. "이 PR 의 페이지 객체에 expect 가 있다" 같은 지적은 기준이 있을 때만 나온다.

실습 과제

과제 1 — 0~2단계를 빈 저장소에 쌓기 (60분)

npm init -y && npm i -D @playwright/test typescript 로 시작한다. 결과물은 넷이다 — (1) @core/*·@domain/* 별칭이 동작하는 tsconfig.json, (2) 환경 변수 누락 시 해결책이 적힌 메시지로 즉시 실패하는 loadEnv(), (3) 셋업 프로젝트와 본 프로젝트가 dependencies 로 이어진 playwright.config.ts, (4) 픽스처 하나가 꽂힌 테스트 1개. 검증은 npx playwright test --list 가 테스트를 나열하고, 환경 변수를 하나 지우면 수집 단계에서 멈추는지로 한다.

과제 2 — 병렬화 깨뜨려 보고 고치기 (40분)

일부러 공유 데이터를 쓰는 테스트 3개를 만든다 — 같은 이메일로 계정을 만들고 같은 리소스를 수정한다. workers: 1 로는 통과하고 workers: 3 으로는 실패하는 것을 확인한 뒤 3단계의 유일 키 생성과 픽스처 정리로 고친다. 보고할 것은 실패 양상의 차이 다 — 오류 메시지가 원인을 가리켰는가, 엉뚱한 곳에서 터졌는가. 이것이 레슨 3의 출발점이다.

과제 3 — 물려받은 저장소 구조 감사표 (30분)

담당 중인(또는 공개) Playwright 저장소 하나를 골라 여섯 단계 각각에 있음 / 부분 / 없음 을 매기고, 없음 마다 "이대로 가면 무엇이 먼저 터지는가" 를 한 문장으로 쓴다. 그중 가장 싸게 고칠 수 있는 하나 의 변경 범위(파일 수·예상 시간)를 추정한다 — 전면 재작업 제안은 거의 승인되지 않는다.

자가 체크리스트

  • src/core·src/domain·tests 의 역할과 각자가 import 할 수 있는 대상을 안다
  • 환경 설정을 한 곳에서 파싱·검증하고 누락을 실행 초기에 터뜨리는 코드를 썼다
  • playwright.config.ts 에 모이는 결정이 gTAA 의 어느 역량인지 설명할 수 있다
  • 픽스처의 테스트 스코프와 워커 스코프를 구분해 쓰고, 어느 쪽에 둘지 판단할 수 있다
  • 픽스처의 정리 코드를 use() 뒤에 두는 이유를 설명할 수 있다
  • 테스트 데이터를 테스트 간에 공유하지 않게 만드는 방법을 구현했다
  • 페이지 객체가 단언을 포함하면 안 되는 이유를 말할 수 있다
  • 추상화를 지워야 할 신호를 셋 이상 들 수 있다

흔한 실수

설정 파일을 환경마다 복제한다. playwright.dev.config.ts 를 따로 만들면 공통 변경이 두 배로 든다. 설정 파일은 하나, 환경은 변수로 받는다. 환경별로 다른 테스트 집합 이 필요하면 설정 복제가 아니라 projects 나 태그(--grep @smoke)로 푼다.

격리 없이 재시도를 먼저 올린다. retries: 3 은 가장 쉬운 응급처치이자 가장 비싼 부채다. 플래키율이 리포트에서 사라지므로 문제가 해결된 것처럼 보인다. 재시도는 네트워크 순간 장애처럼 진짜 비결정적인 것만 흡수해야 한다(모듈 13 레슨 6).

코어에 Playwright 를 import 한다. 코어가 실행 도구를 알면 재사용이 깨진다. HTTP 래퍼·클록·ID 생성기는 순수 TypeScript 로 쓸 수 있다.

디렉터리를 먼저 다 만들고 테스트를 나중에 쓴다. 테스트 3개를 쓰기 전에 계층을 여섯 개 만든 저장소는 거의 틀린 모양이 된다. 테스트 5~10개를 먼저 쓰고 중복이 나타난 지점 에 경계를 긋는다.

참고 자료

  • Playwright 공식 문서 — Test configuration, Fixtures, Parallelism and sharding, Authentication(storageState), Projects and dependencies. 버전마다 기본값이 달라 원문 확인 필요
  • ISTQB, Certified Tester Test Automation Engineering (CT-TAE) Syllabus v2.0, 2024 — 3.1.3 TAF 계층 구성과 계층 수 권고
  • 모듈 13 레슨 3·4·5·6·10 — 로케이터, POM, 대기 전략, 플래키, API 픽스처. 기법은 그쪽, 구조는 이쪽 으로 읽는다
  • 모듈 14 레슨 2·5 — GitHub Actions, False Alarm 줄이기
  • 레슨 1(gTAA 계층), 레슨 3(보고·로깅), 레슨 4(테스트 용이성 협상)

낯선 말 풀이

이 레슨에 나온 약어입니다. 본문에서도 처음 나올 때 점선으로 표시해 두었습니다 (제목과 링크 글자는 그대로 둡니다).

ID — 식별자
무엇 하나를 다른 것과 구별하기 위해 붙인 이름이나 번호.
API — 응용 프로그램 인터페이스
프로그램끼리 주고받는 창구. 사람이 화면 버튼을 누르는 대신, 프로그램이 정해진 주소로 요청을 보내 답을 받는다.
DB — 데이터베이스
데이터를 넣어 두고 꺼내 쓰는 저장소. 보통 표 여러 개가 서로 연결된 형태다.
UI — 사용자 인터페이스
사람이 직접 보고 누르는 부분 — 화면, 버튼, 입력창.
HTTP — 하이퍼텍스트 전송 규약
웹에서 요청과 응답을 주고받는 약속. "무엇을 달라"고 보내면 "여기 있다" 또는 "그런 건 없다"가 돌아온다.
CI — 지속적 통합
코드를 올릴 때마다 자동으로 합쳐서 빌드하고 시험까지 돌리는 방식.
PR — 풀 리퀘스트
"내가 고친 코드를 본체에 합쳐 달라"고 올리는 요청. 합치기 전에 사람들이 보고 의견을 단다.
ISTQB — 국제 소프트웨어 테스팅 자격 위원회
테스터 자격 시험을 만들고 용어를 정리하는 국제 단체.
CT-TAE — 전문 자격 · 테스트 자동화
자동화 설계를 다루는 과목. 상급 자격의 같은 과목을 이렇게도 표기한다.
POM — 페이지 객체 모델
화면마다 "이 화면에서 할 수 있는 일"을 모아 둔 코드 조각을 만들어 두고, 시험은 그것만 호출하게 하는 설계.

용어집 전체 보기