콘텐츠로 이동

세 트랙

타겟 종류에 따라 도구와 셀렉터, config가 갈립니다. 세 트랙이 같은 Phase 흐름을 공유하니까 페이지를 쪼개지 않고 여기 한 자리에 모았습니다. 본인 SUT가 어느 트랙에 속하는지는 /3-analyze-target에서 정해지고, 도구 결정은 /4-design-suite에서 e2e-suite-config.json에 박힙니다.

기본 도구는 Playwright입니다. Cypress는 기존 Cypress 자산이 있을 때만 고려합니다.

config의 핵심 자리입니다.

import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/web',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [['blob']] : [['html']],
use: { baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:5173', trace: 'on-first-retry' },
webServer: process.env.E2E_BASE_URL ? undefined : {
command: 'npm run dev', url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI, timeout: 120_000,
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{ name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: 'tests/web/.auth/user.json' }, dependencies: ['setup'] },
],
});

webServer가 dev server를 자동 기동하고, E2E_BASE_URL이 있으면 그 URL을 그대로 씁니다. trace: 'on-first-retry'라 첫 재시도에서만 trace가 남아 아티팩트가 부풀지 않습니다. setup project가 1회 로그인해 storageState를 저장하고 chromium project가 dependencies: ['setup']으로 재사용합니다.

셀렉터는 getByRole이 먼저, 그다음 getByLabelgetByText, 최후수단이 getByTestId입니다. CSS나 XPath는 DOM 변경에 취약해서 쓰지 않습니다. 대기는 web-first assertion(await expect(...).toBeVisible())과 waitForResponse로 잡고, 하드 waitForTimeout(n)은 flaky의 1순위 원인이라 금지입니다.

POM은 로케이터를 클래스 안에 가둡니다.

tests/web/pages/LoginPage.ts
export class LoginPage {
constructor(private page: Page) {}
goto = () => this.page.goto('/login');
login = async (email: string, pw: string) => {
await this.page.getByLabel('Email').fill(email);
await this.page.getByLabel('Password').fill(pw);
await this.page.getByRole('button', { name: /sign in/i }).click();
};
}

SUT 스택에 따라 두 함정이 있습니다. SUT가 Supabase면 세션이 쿠키가 아니라 localStorage에 들어갑니다. auth.setup.ts에서 REST(/auth/v1/token?grant_type=password)로 로그인한 다음 addInitScriptsb-<ref>-auth-token 키를 주입하고 storageState로 저장합니다. SUT가 Stripe 결제를 쓰면 Hosted Checkout(checkout.stripe.com)이 cross-origin이라 페이지를 직접 제어할 수 없습니다. 결제 성공은 클릭이 아니라 webhook으로 검증하고(Stripe CLI stripe listen/stripe trigger), 주기 결제는 Test Clocks로 봅니다.

기본은 Playwright의 _electron입니다. 개발 중 빠른 검증에 맞고, 패키징된 배포 빌드까지 검증해야 하면 @wdio/electron-service를 옵션으로 얹습니다.

launch가 앱을 띄우면 렌더러와 메인 프로세스 두 자리에 닿습니다.

import { test, expect, _electron as electron } from '@playwright/test';
test('앱 실행 + 첫 창', async () => {
const app = await electron.launch({ args: ['.'] }); // package.json main 엔트리
const win = await app.firstWindow(); // 렌더러 → 일반 Page
await expect(win).toHaveTitle(/My App/);
const isPackaged = await app.evaluate(async ({ app }) => app.isPackaged);
expect(isPackaged).toBe(false);
await app.close();
});

firstWindow()가 주는 건 일반 Page라 렌더러는 web처럼 getByRole로 다룹니다. 메인 프로세스 코드는 app.evaluate(({ app, dialog, BrowserWindow, Menu }) => ...) 안에서 돕니다.

파일 다이얼로그, 메뉴, 트레이, autoUpdater는 OS 네이티브 UI라 클릭으로 자동화할 수 없습니다. 메인 프로세스 API를 stub 하거나 직접 호출합니다.

await app.evaluate(({ dialog }, paths) => {
dialog.showOpenDialog = () => Promise.resolve({ canceled: false, filePaths: paths });
}, ['/tmp/a.txt']);

config는 web과 달리 fullyParallel: false, workers: 1입니다. 단일 인스턴스라 병렬로 띄우면 충돌합니다. CI에서는 Linux 잡만 디스플레이가 없어 xvfb-run으로 감싸고, mac과 windows는 그대로 둡니다. 자세한 CI 분기는 ci에 있습니다.

기본은 Maestro입니다. 셋업과 유지보수 비용이 가장 낮고 크로스플랫폼입니다. 앱 종류에 따라 도구가 갈립니다.

앱 종류도구
ExpoMaestro (Expo 공식)
React Native(bare)Detox(graybox) 또는 Maestro
iOS 네이티브XCUITest
Android 네이티브Espresso
Capacitor/IonicIonic E2E (WDIO + Appium, 컨텍스트 스위칭)
범용/시스템 흐름Appium

Maestro 플로우는 .yml 선언형입니다.

tests/mobile/flows/login.yml
appId: com.example.app
---
- launchApp: { clearState: true }
- tapOn: { id: "email_field" } # testID 우선 (텍스트보다 안정적)
- inputText: "[email protected]"
- tapOn: "Sign in"
- assertVisible: "Welcome"
- runFlow: subflows/logged-in-check.yml

maestro test tests/mobile/flows/login.yml로 돌리고, 작성은 maestro studio(비주얼)나 maestro record(녹화)로 보조합니다. 자동 대기와 재시도가 내장이라 하드 sleep이 필요 없습니다. 셀렉터는 testID가 먼저입니다. 텍스트 기반은 i18n에 취약합니다.

RN bare 앱에서 Detox를 고르면 graybox 자동 동기화가 네트워크와 애니메이션, RN 스레드 idle을 기다려 flaky를 줄입니다.

await element(by.id('email')).typeText('[email protected]');
await element(by.text('Login')).tap();
await expect(element(by.label('Welcome'))).toBeVisible();

Capacitor/Ionic 같은 하이브리드는 웹뷰 컨텍스트가 끼는데, 컨텍스트 ID를 하드코딩하면 실행마다 달라져 깨집니다. getContextHandles()로 조회한 뒤 전환합니다.

인증은 세 트랙 모두 1회 로그인 후 재사용이지만 방식이 다릅니다. mobile은 사전 로그인 subflow(Maestro runFlow)나 Detox beforeAll로 잡습니다. 전략 결정은 analyze-and-design에 있습니다.

다음 섹션은 analyze-and-design입니다. /3-analyze-target이 SUT와 크리티컬 플로우를 어떻게 끌어내는지, /4-design-suite가 도구와 인증, 환경, CI를 어떻게 e2e-suite-config.json에 박는지, 어떤 결정에 ADR이 붙는지를 봅니다.