브라우저 흐름 테스트: 컴포넌트 테스트가 놓치는 연결을 어떻게 검증하는가
컴포넌트 테스트가 통과해도 API 경로와 페이지 이동이 끊길 수 있는 사례를 실제 Chromium에서 재현하고, 브라우저 테스트가 증명하는 범위와 남는 서버 경계를 구분한다.
목차
표준·구현·측정·해석 표시는 무엇인가요?
- 표준웹 표준이나 언어 명세가 정한 동작
- 구현특정 기술이나 브라우저가 실제로 구현한 동작
- 측정명시한 환경에서 직접 실행해 관찰한 결과
- 해석앞선 근거에서 도출한 설계 판단
- 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용
30초 요약
컴포넌트 테스트는 한 컴포넌트의 입력·버튼·상태 전이를 빠르게 확인한다. 그러나 테스트가 저장 함수를 대역으로 바꾸면 실제 페이지가 호출하는 API 주소와 저장 뒤 URL 이동은 실행하지 않는다. 브라우저 흐름 테스트는 조립된 앱을 실제 브라우저에서 열고 사용자 입력부터 요청, 새 문서 로드와 최종 화면까지 연결한다.
이 글을 관통하는 상황: 저장은 성공하는데 완료 페이지로 가지 않는다
21편의 ProfileNameForm 컴포넌트 테스트는 통과했다. 입력값은 saveName으로 전달되고, 저장 중 중복
클릭도 막히며, 성공과 실패 문구도 맞다. 이제 운영 페이지는 저장 함수를 다음처럼 조립한다.
import ProfileNameForm from './profile-name-form'
async function saveName(name: string) {
const response = await fetch('/api/profiles', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name }),
})
if (!response.ok) {
throw new Error('프로필 저장에 실패했습니다.')
}
window.location.assign('/profile/saved')
}
export default function ProfilePage() {
return <ProfileNameForm initialName="이전 이름" saveName={saveName} />
}오류는 한 글자 차이다. 서버 계약은 /api/profile인데 페이지는 /api/profiles를 호출한다. 컴포넌트
테스트는 saveName 자체를 기록 가능한 대체 함수로 바꿨으므로 이 오타를 볼 수 없다.
실험 앱은 새 URL에서 완료 화면을 그리도록 다음 분기만 둔다. Vite 개발 서버는 두 경로에 앱의
index.html을 제공하고, 앱은 현재 경로에 맞는 화면을 선택한다고 가정한다.
import ProfilePage from './profile-page'
export default function App() {
if (window.location.pathname === '/profile/saved') {
return <h1>프로필 저장 완료</h1>
}
return <ProfilePage />
}20편에서 사용하던 상품 카드 진입점은 이 실험에서 제거한다. index.html이 이미 불러오는 src/main.tsx를
다음 코드로 교체해야 /profile에서도 실제로 App이 렌더된다.
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './app'
const rootElement = document.getElementById('root')
if (!rootElement) {
throw new Error('root 요소를 찾을 수 없습니다.')
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
)pnpm exec tsc --noEmit타입 검사가 통과한 뒤 브라우저 실험으로 넘어간다. 이 교체가 없으면 이전 상품 카드 화면이 열리므로
이름 입력을 찾는 단계에서 실패해 API 경로 오타를 재현하지 못한다.
컴포넌트 테스트가 제거한 경계에서 실패한다
컴포넌트 테스트가 실제로 둔 부분과 바꾼 부분을 먼저 나눈다.
실제로 실행함 ProfileNameForm, React state, 입력·버튼·상태 문구
대역으로 바꿈 페이지가 만든 saveName 함수
실행하지 않음 fetch URL, HTTP 응답, window.location.assign, 새 페이지 로드즉 브라우저 테스트를 추가하는 이유는 같은 검사를 더 크게 반복하기 위해서가 아니다. 컴포넌트 테스트가 의도적으로 제거한 페이지 조립, 네트워크 주소와 브라우저 이동 경계를 실행하기 위해서다.
실제 Chromium 실행 조건을 고정한다
21편의 TypeScript Vite 앱을 그대로 사용하되, 이 글의 전체 실험은 Node.js 22.19.0과 Playwright Test 1.58.2로 고정한다. Playwright 현재 공식 설치 문서는 Node.js 22.x·24.x·26.x를 지원 대상으로 안내한다. 패키지 설치와 별개로 해당 버전이 제어할 Chromium 실행 파일도 내려받아야 한다.
pnpm add -D @playwright/[email protected]
pnpm exec playwright install chromium
node --version
pnpm exec playwright --version두 버전 명령은 각각 v22.19.0, Version 1.58.2를 출력해야 한다. 다음 설정은 테스트 전에 Vite를 4173 포트로 시작하고,
상대 URL의 기준을 같은 주소로 둔다.
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
retries: 1,
reporter: [['html', { open: 'never' }]],
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'pnpm exec vite --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173/profile',
reuseExistingServer: false,
},
})baseURL은 page.goto('/profile') 같은 상대 경로를 위 주소와 결합한다. reuseExistingServer: false는
다른 프로세스가 같은 포트를 쓰고 있을 때 그 서버를 조용히 재사용하지 않고 실패하게 해, 어떤 앱을
검사했는지 모호해지는 일을 막는다.
API 경로와 이동을 한 흐름에서 관찰한다
먼저 외부 서버 대신 이 테스트가 통제할 HTTP 응답을 준비한다. page.route는 페이지가 보낸 요청을
가로채는 Playwright API다. /api/profile이면 요청 본문을 기록하고 성공을 응답하며, 다른 /api/
경로는 404를 돌려준다.
import { expect, test } from '@playwright/test'
test('프로필 이름을 저장하고 완료 페이지로 이동한다', async ({ page }) => {
let receivedName: string | undefined
await page.route('**/api/**', async (route) => {
const request = route.request()
const pathname = new URL(request.url()).pathname
if (pathname !== '/api/profile' || request.method() !== 'PUT') {
await route.fulfill({
status: 404,
json: { message: 'not found' },
})
return
}
const body = request.postDataJSON() as { name?: string }
receivedName = body.name
await route.fulfill({
status: 200,
json: { name: receivedName },
})
})
await page.goto('/profile')
await page.getByLabel('이름').fill('새 이름')
await page.getByRole('button', { name: '저장' }).click()
await expect(page).toHaveURL(/\/profile\/saved$/)
await expect(
page.getByRole('heading', { name: '프로필 저장 완료' }),
).toBeVisible()
expect(receivedName).toBe('새 이름')
})pnpm exec playwright test e2e/profile-save.spec.ts --project=chromium잘못된 /api/profiles 코드에서는 route가 404를 응답한다. ProfileNameForm은 저장 실패를 표시하고
현재 URL은 /profile에 머문다. 따라서 URL 검사가 시간 안에 만족되지 않아 실패한다. 이것이 컴포넌트
테스트에서는 보이지 않았던 페이지 연결 실패의 재현 증거다.
한 글자 수정 뒤 같은 증거를 다시 확인한다
운영 페이지의 API 경로를 서버 계약과 일치시킨다. 나머지 컴포넌트와 테스트는 바꾸지 않는다.
async function saveName(name: string) {
- const response = await fetch('/api/profiles', {
+ const response = await fetch('/api/profile', {
method: 'PUT',같은 명령을 다시 실행하면 다음 연결을 한 번에 확인한다.
1. 실제 Chromium이 /profile 문서를 연다.
2. 사용자가 이름 입력을 지우고 새 값을 쓴다.
3. 저장 버튼이 React 폼 제출을 시작한다.
4. 페이지가 PUT /api/profile과 { name: "새 이름" }을 보낸다.
5. 통제한 200 응답 뒤 브라우저가 /profile/saved로 이동한다.
6. 새 문서에서 프로필 저장 완료 제목이 보인다.이 검증은 “저장 함수가 호출됐다”에서 끝나지 않는다. 실제 브라우저에서 사용자가 시작한 행동이 페이지 조립과 네트워크 경계를 지나 최종 URL과 화면으로 이어졌음을 보여 준다.
시간 대신 사용자가 보는 결과를 기다린다
getByLabel('이름')과 getByRole('button', { name: '저장' })은 CSS 클래스나 DOM 깊이가 아니라 사용자가
인식하는 label·역할·이름으로 요소를 찾는다. 21편과 같은 질문을 쓰되 실제 브라우저 페이지에 묻는 셈이다.
다음처럼 임의의 시간을 기다리지 않는다.
// 피한다. 1초가 충분하다는 보장이 없다.
await page.waitForTimeout(1_000)
expect(page.url()).toContain('/profile/saved')
// 실제 완료 조건을 제한 시간까지 다시 확인한다.
await expect(page).toHaveURL(/\/profile\/saved$/)고정 대기는 느린 환경에서 부족하고 빠른 환경에서는 불필요하게 오래 기다린다. 결과 조건을 기다리면 테스트의 문장이 사용자 계약과도 직접 이어진다.
실제 브라우저와 실제 백엔드를 구분한다
테스트 이름이나 설명에 이 경계를 남긴다.
브라우저 + 통제 API 응답 프론트엔드 요청 모양, 로딩·오류 처리, URL 이동을 빠르게 검증
브라우저 + 실제 테스트 API 프론트엔드와 서버 계약, 인증, 저장 결과까지 통합 검증
운영 점검 실제 배포 주소, 비밀값, 네트워크와 운영 데이터 경계를 확인모든 브라우저 테스트를 실제 외부 서버에 연결하면 데이터와 장애를 통제하기 어렵고 느려질 수 있다. 반대로 모든 응답을 가로채면 프론트엔드와 서버 사이의 실제 계약 오류가 남는다. 핵심 흐름 가운데 어느 소수는 격리된 테스트 서버와 데이터까지 실제로 두고, 입력 조합과 화면 상태의 대부분은 더 좁은 계층에서 확인한다.
실패하면 실행 기록으로 경계를 좁힌다
로컬에서 첫 실행부터 기록하려면 다음처럼 실행한다.
pnpm exec playwright test e2e/profile-save.spec.ts --project=chromium --trace=on
pnpm exec playwright show-report실패할 때는 단순히 재시도 횟수부터 늘리지 않는다.
- 입력과 클릭이 의도한 locator에 연결됐는가?
- 실제 요청 URL, 메서드와 본문은 무엇이었는가?
- 응답 뒤 URL이 바뀌었는가, 오류 화면에 머물렀는가?
- 이전 테스트의 쿠키·저장소·서버 데이터에 기대고 있지 않은가?
- 고정 시간 대기나 통제하지 않은 외부 서비스 때문에 결과가 흔들리는가?
재시도에서 통과했다면 첫 실패가 없던 일이 되지 않는다. 코드와 선언한 조건이 같은데 결과가 오락가락한 원인을 실행 기록으로 좁히고, 공유 상태·시간·외부 의존성을 통제한 뒤 단독 반복 실행에서도 안정적인지 확인한다.
문제 컴포넌트 테스트는 통과하지만 저장 뒤 완료 페이지로 이동하지 않음
잘못된 판단 사용자처럼 버튼을 눌렀으므로 실제 페이지 연결도 검증됐다고 생각함
관찰 Chromium 흐름에서 PUT /api/profiles가 404이고 URL이 /profile에 머묾
원인 컴포넌트 테스트가 페이지의 실제 saveName, fetch URL과 이동을 대역으로 제거함
행동 실제 페이지를 열고 API 경로·본문·최종 URL·완료 제목을 한 흐름에서 확인함
검증 /api/profile 수정 뒤 요청 본문과 /profile/saved 화면이 모두 통과함PR에서 바로 확인할 항목
- 이 흐름에서만 발견할 수 있는 브라우저·페이지·네트워크 경계가 분명한가?
- 컴포넌트 테스트와 같은 세부 입력 조합을 브라우저에서 불필요하게 반복하지 않는가?
- 역할·label·보이는 이름으로 요소를 찾고 CSS 구조에 기대지 않는가?
- 고정 시간 대기 대신 URL·문구·활성 상태 같은 실제 완료 조건을 기다리는가?
- 각 테스트가 자신의 쿠키·저장소·데이터 시작 상태를 준비하는가?
- 통제한 API 응답과 실제로 연결한 서버를 테스트 설명에서 구분하는가?
- 실패 시 요청·URL·화면을 확인할 실행 기록이 남는가?
- 재시도 통과를 해결로 간주하지 않고 플레이키 원인을 조사하는가?
기억할 질문은 하나다.
이 사용자 흐름에서 컴포넌트 테스트가 의도적으로 제거한 실제 경계는 무엇인가?
Active recall
기억에서 꺼내 보기
답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.
01saveName 대역을 주입한 컴포넌트 테스트가 통과하면 실제 API 주소와 완료 페이지 이동도 맞다고 볼 수 있는가?
정답
아니다. 대역은 컴포넌트 바깥의 실제 fetch 경로와 브라우저 이동을 실행하지 않으므로, 조립된 페이지를 실제 브라우저에서 별도로 검증해야 한다.
관련 설명 다시 읽기왜 그런가
이 글의 오타 난 /api/profiles 경로는 컴포넌트 테스트에서 제거되지만 브라우저 흐름에서는 404 응답과 이동 실패로 드러난다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
02저장 뒤 1초를 기다리고 URL을 즉시 읽는 방식이 안정적인가?
정답
아니다. 역할·이름 기반 locator와 자동 재시도하는 toHaveURL·toBeVisible로 실제 결과 조건을 기다린다.
관련 설명 다시 읽기왜 그런가
고정 시간 대기는 빠른 환경에서는 낭비되고 느린 환경에서는 부족해 코드가 같아도 결과가 흔들릴 수 있다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
03page.route로 API 응답을 만든 브라우저 테스트가 실제 서버·인증·데이터베이스까지 증명하는가?
정답
아니다. 실제 브라우저와 프론트엔드 요청 코드는 실행하지만, route.fulfill이 응답을 대신하므로 서버 경계는 별도 통합 흐름에서 확인해야 한다.
관련 설명 다시 읽기왜 그런가
테스트가 실제로 둔 부분과 대체한 부분을 구분해야 통과 결과의 의미를 과장하지 않는다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
04한 번 실패하고 재시도에서 통과한 테스트를 그대로 성공으로 취급해도 되는가?
정답
아니다. 실행 기록에서 요청·URL·화면 상태를 확인하고 공유 상태, 고정 시간 대기와 외부 의존성 같은 불안정 원인을 제거해야 한다.
관련 설명 다시 읽기왜 그런가
재시도는 실패 증거를 더 모을 수 있지만 같은 코드의 결과가 오락가락하는 문제 자체를 고치지 않는다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
출처와 검증 범위
아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.
- Playwright - InstallationPlaywright · 공식 문서 · 확인 2026-08-09
- Playwright - Web serverPlaywright · 공식 문서 · 확인 2026-08-09
- Playwright - LocatorsPlaywright · 공식 문서 · 확인 2026-08-09
- Playwright - AssertionsPlaywright · 공식 문서 · 확인 2026-08-09
- Playwright - IsolationPlaywright · 공식 문서 · 확인 2026-08-09
- Playwright - Mock APIsPlaywright · 공식 문서 · 확인 2026-08-09
- Playwright - Trace viewerPlaywright · 공식 문서 · 확인 2026-08-09
- Playwright - ReportersPlaywright · 공식 문서 · 확인 2026-08-09
- Playwright - Best PracticesPlaywright · 공식 문서 · 확인 2026-08-09
- HTML Standard - Location assignWHATWG · 표준 · 확인 2026-08-09