개념
이 레슨이 만드는 것과 만들지 않는 것
레슨 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 가 있다" 같은
지적은 기준이 있을 때만 나온다.