6. 네트워크 경계
실제 화면 처리와 provider는 유지하며 응답 경계에서 필요한 상태를 만듭니다. 생성 계약과 소유 mock을 조립해 브라우저와 Node가 함께 쓰고 미처리 요청을 검출합니다.
6.1 응답 경계에서 교체하고 화면의 실제 처리를 유지합니다
요청 조립과 응답 변환, provider, 화면 상태의 실제 연결을 확인합니다.
화면 상태를 검증할 때는 네트워크 경계에서 필요한 응답만 교체합니다. 화면의 hook, 응답 매퍼, provider를 대체하거나 DOM을 직접 써서 기대 화면을 만들지 않습니다.
기본 응답과 케이스별 덮어쓰기를 구분하고 덮어쓰기는 요청이 시작되기 전에 등록합니다. 요청의 필드와 헤더가 계약이면 직접 단언하고 화면 결과가 계약이면 그 결과까지 확인합니다. 기존 프로젝트의 mock 통로를 사용하며 특정 생성기나 폴더 구성을 공통 요구사항으로 삼지 않습니다.
service worker와 page.route가 동시에 있으면 어느 쪽이 요청을 처리하는지 확인합니다. page.route로 제어할 프로젝트는 service worker 차단 등 실제 가로채기 경계를 맞춥니다.
참고: 공식 문서.
응답 처리 대신 DOM에 기대 문구를 직접 씁니다.
import { expect, test } from "@playwright/test";
test("빈 주문 목록에 안내가 표시된다", async ({ page }) => {
/**
* GIVEN
* 실제 주문 화면
*/
await page.goto("/orders");
/**
* WHEN
* DOM에 정답 문구 직접 삽입
*/
await page.getByRole("main").evaluate((element) => {
element.innerHTML = '<p role="status">주문이 없습니다</p>';
});
/**
* THEN
* 삽입한 문구의 존재
*/
await expect(page.getByRole("status")).toHaveText("주문이 없습니다");
});API 응답을 바꾸고 실제 화면의 표시를 확인합니다.
import { expect, test } from "@playwright/test";
test("빈 주문 목록에 안내가 표시된다", async ({ page }) => {
/**
* GIVEN
* 빈 주문 목록을 응답하는 서버
*/
await page.route("**/api/orders", async (route) => {
await route.fulfill({ json: { items: [], total: 0 } });
});
/**
* WHEN
* 실제 주문 화면 진입
*/
await page.goto("/orders");
/**
* THEN
* 실제 응답 처리 뒤 빈 목록 안내
*/
await expect(page.getByRole("status")).toHaveText("주문이 없습니다");
});6.2 mock에서 빠진 API 요청을 검출합니다
누락된 mock이 외부 네트워크나 무관한 기본 응답으로 숨지 않습니다.
mock으로 검증하는 API 요청은 처리 누락이 드러나게 설정합니다. 정적 자원이나 명시적으로 실제 호출할 경로는 허용 목록으로 구분하고 전체 우회를 기본값으로 숨기지 않습니다.
handler의 매칭 조건과 우선순위, 테스트별 덮어쓰기의 초기화를 확인합니다. 미처리 요청의 오류 로그만으로 테스트 실패가 보장된다고 가정하지 않고 실행 결과에 연결되는지도 확인합니다. page.route의 fallback은 앞서 등록된 handler로 넘기는 용도와 실제 네트워크로 빠지는 경우를 구분합니다.
참고: 공식 문서.
처리되지 않은 API 요청을 모두 우회합니다.
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
const server = setupServer(
http.get("https://app.example.test/api/orders", () => {
return HttpResponse.json({ items: [], total: 0 });
}),
);
beforeAll(() => {
server.listen({ onUnhandledRequest: "bypass" });
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
test("주문 목록 응답을 받는다", async () => {
/**
* GIVEN
* 모듈 fixture의 주문 목록 handler
*/
const url = "https://app.example.test/api/orders";
/**
* WHEN
* 주문 API 요청
*/
const response = await fetch(url);
/**
* THEN
* fixture에서 정한 빈 목록과 건수
*/
expect(await response.json()).toStrictEqual({ items: [], total: 0 });
});미처리 요청을 오류로 드러내고 handler 수명을 정리합니다.
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
import { HttpResponse, http } from "msw";
import { setupServer } from "msw/node";
const server = setupServer(
http.get("https://app.example.test/api/orders", () => {
return HttpResponse.json({ items: [], total: 0 });
}),
);
beforeAll(() => {
server.listen({ onUnhandledRequest: "error" });
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
test("주문 목록 응답을 받는다", async () => {
/**
* GIVEN
* 모듈 fixture의 주문 목록 handler
*/
const url = "https://app.example.test/api/orders";
/**
* WHEN
* 주문 API 요청
*/
const response = await fetch(url);
/**
* THEN
* fixture에서 정한 빈 목록과 건수
*/
expect(await response.json()).toStrictEqual({ items: [], total: 0 });
});6.3 실제 시스템과 mock의 경계를 명시합니다
브라우저 테스트가 보장하는 범위와 별도 서버 검증이 필요한 범위를 구분합니다.
테스트에서 실제로 실행한 앱, 서버, 저장소, 외부 서비스를 구분합니다. 전체 앱을 연 테스트도 API를 대체했다면 실제 백엔드 인증이나 데이터 저장의 근거가 되지 않습니다.
| 검증한 경계 | 보고할 수 있는 범위 |
|---|---|
| 인증 응답 mock과 보호 화면 | 해당 응답에 따른 브라우저 상태와 이동 |
| 저장 응답 mock과 목록 갱신 | 요청 계약과 응답에 따른 화면 반영 |
| 실제 서버와 후속 조회 | 실제로 단언한 인증, 저장, 응답 계약 |
실제 연동이 필요한 요청이면 별도 실행 환경과 데이터를 준비합니다. mock의 타입 일치와 실제 서버의 런타임 응답 일치는 별도로 확인합니다.
참고: 공식 문서.
mock 실행으로 서버 인증과 저장까지 검증했다고 적습니다.
E2E 전체 통과
백엔드 인증과 주문 DB 저장 검증 완료
실행한 브라우저 범위와 남은 서버 검증을 구분합니다.
실행: npm run test:e2e
경계: 실제 앱과 브라우저, API 응답은 mock
검증: 세션 응답에 따른 보호 화면, 저장 요청과 성공 응답의 화면 반영
미확인: 실제 서버 인증과 DB 저장, 후속 실제 조회
6.4 생성 계약 위에 도메인 mock과 endpoint handler를 조립합니다
API 재생성에 맞춰 타입과 응답 형식을 유지하고 시나리오 값을 일관되게 만듭니다.
작성 순서와 소유 경계
생성기를 사용하는 프로젝트에서는 다음 순서로 실제 산출물의 계약을 조립합니다. 생성하지 않는 프로젝트는 기존의 타입 기반 fixture와 handler를 사용합니다.
- OpenAPI와 생성 설정에서 DTO, status별 factory, handler signature를 확인합니다.
- 소유 domain builder에서 DTO faker를 호출하고 관찰되는 값과 관계를 고정합니다.
- 소유 endpoint handler에서 요청을 읽고 상태와 해당 status의 응답 factory를 선택합니다.
- registry에서 소유 handler를 생성 fallback 앞에 둡니다.
- 브라우저와 Node에 같은 registry를 연결한 뒤 story와 spec에서 필요한 차이만 덮습니다.
생성 폴더는 재생성으로 갱신하고 손으로 보정하지 않습니다. 도메인 builder는 값과 관계를 계산하고 Response, handler, HTTP 상태를 가져오지 않습니다. endpoint handler는 요청과 상태를 맡고 표, 정렬, 수치 계산은 builder에 둡니다. 입력 파싱을 handler가 먼저 하거나 URL을 builder에 넘기는 방식은 프로젝트 계약에 맞춥니다.
결정적인 응답과 재생성
실제 화면이 호출하는 endpoint는 소유 handler에서 관찰되는 값을 결정합니다. 생성 fallback이 존재한다는 이유로 난수 응답을 정상 시나리오에 그대로 사용하지 않습니다. 정상 fixture에는 성공 status factory를 쓰고 오류 사례에는 그 오류 status factory를 씁니다. 여러 상태의 union을 만드는 범용 Response factory가 기본값을 합쳐 준다고 가정하지 않습니다.
재사용할 정상 본문은 builder에 두고 빈 목록, 401, 500 등 사례의 차이는 spec에서 덮습니다. spec에서 같은 정상 본문이나 endpoint URL을 반복 작성하지 않습니다. 스펙 밖 API만 직접 handler를 만들고 생성된 endpoint와 그 이유를 구분합니다.
재생성 뒤에는 생성물 diff, 소유 builder의 타입, handler의 응답, 실제 소비 테스트를 확인합니다. factory 이름과 합치기 방식은 설치 버전과 산출물에서 확인하며 예제 이름을 그대로 추측하지 않습니다.
참고: Kubb MSW, Kubb Faker.
spec에서 생성 계약과 무관한 정상 본문을 직접 작성합니다.
import { expect, test } from "@playwright/test";
test("기본 주문 목록의 수량을 표시한다", async ({ page }) => {
/**
* GIVEN
* spec마다 직접 복사한 정상 응답
*/
await page.route("**/api/orders", async (route) => {
await route.fulfill({ json: { items: [{ id: "order-1", quantity: 1234 }], total: 1 } });
});
/**
* WHEN
* 주문 화면 진입
*/
await page.goto("/orders");
/**
* THEN
* 표시된 수량
*/
await expect(page.getByRole("cell", { name: "1,234.00", exact: true })).toBeVisible();
});생성 DTO, 성공 응답, handler를 소유 경계별 파일에서 조립합니다.
import { createOrderDto } from "@/service/__kubb/mock/order-dto";
/**
* 주문 목록의 정상 본문. 화면이 읽는 값과 목록 건수 고정
*/
export const mockOrders = () => {
const items = [createOrderDto({ id: "order-1", quantity: 1234, status: "open" })];
return { items, total: items.length, page: 1, updatedAt: "2026-03-01T03:00:00Z" };
};import { createGetOrdersStatus200 } from "@/service/__kubb/mock/orders/get-orders";
import { getOrdersHandler, getOrdersHandlerResponse200 } from "@/service/__kubb/msw/orders/get-orders-handler";
import { mockOrders } from "@/service/mock/mock-orders";
/**
* 주문 조회의 성공 응답. 본문 계산은 도메인 builder 소유
*/
export const get_orders_handler = getOrdersHandler(() => {
return getOrdersHandlerResponse200(createGetOrdersStatus200(mockOrders()));
});import type { RequestHandler } from "msw";
import { handlers } from "@/service/__kubb/msw/handlers";
import { get_orders_handler } from "@/service/msw/orders/get-orders";
/**
* 개발, mock 앱 E2E, story 통합의 반복 응답. once, generator, 공유 가변 상태 제외
*/
export const mock_handlers = [get_orders_handler, ...handlers] satisfies RequestHandler[];오류 상태만 생성된 status factory로 덮고 story의 실제 화면을 확인합니다.
import { expect, test } from "@test/_fixture/test";
import { createGetOrdersStatus500 } from "@/service/__kubb/mock/orders/get-orders";
import { getOrdersHandler, getOrdersHandlerResponse500 } from "@/service/__kubb/msw/orders/get-orders-handler";
test("주문 조회가 실패하면 오류 안내가 표시된다", async ({ mockApi, mount }) => {
/**
* GIVEN
* 정상 registry에서 주문 조회만 500으로 실패하는 서버
*/
mockApi.use(getOrdersHandler(() => {
return getOrdersHandlerResponse500(createGetOrdersStatus500({ message: "목록을 불러오지 못했습니다" }));
}));
/**
* WHEN
* 실제 provider와 주문 화면을 가진 story 진입
*/
const view = await mount("orders/Default");
/**
* THEN
* 실패 응답 처리 뒤 오류 안내
*/
await expect(view.getByRole("alert")).toHaveText("목록을 불러오지 못했습니다");
});함께 보기
6.5 같은 handler를 브라우저와 Node의 단일 응답 경계로 연결합니다
실행 환경에 따라 정상 데이터와 handler 우선순위가 달라지는 문제를 막습니다.
브라우저 mock과 Node 테스트는 같은 소유 handler registry를 가져옵니다. 응답 본문을 환경마다 복사하지 않고 어느 실행 경로가 요청을 처리하는지 하나로 정합니다.
| 소비 환경 | 연결 방식 |
|---|---|
| 개발, mock 앱 E2E | mock 모드에서 setupWorker에 공유 registry를 연결합니다 |
| Node 함수 통합 | setupServer에 같은 registry를 연결합니다 |
| 브라우저 story 통합 | SW를 차단하고 page.route에서 getResponse로 registry를 해석합니다 |
Node bridge는 메서드, URL, 헤더, 요청 본문과 응답 상태, 헤더, 본문을 보존합니다. cookie를 생략하는 headers 대신 allHeaders를 사용하고 상대 handler의 baseUrl도 맞춥니다. 여러 Set-Cookie는 getSetCookie로 읽고 Playwright가 받는 줄바꿈 구분으로 모두 전달합니다. 처리되지 않은 API 요청은 실패로 연결하고, 실제 호출 허용 목록만 명시적으로 통과시킵니다. passthrough를 무조건 네트워크로 보내지 않고 프로젝트의 실제 호출 계약을 확인합니다.
worker의 요청 거부나 오류 로그만으로 mock 앱 E2E가 실패한다고 가정하지 않습니다. 미처리 API를 구별할 표식을 기록하고 실행 fixture가 테스트 종료 때 누락을 단언합니다. 화면이 fetch 실패를 처리하거나 다른 화면으로 이동해도 기록이 사라지지 않게 합니다.
case handler는 기본 handler보다 먼저 해석하고 요청 시작 전에 등록합니다. handler 목록과 가로채기는 테스트마다 만들고 종료 시 해제합니다. 배열 복사는 handler 인스턴스의 상태를 초기화하지 않습니다. 공유 기본 registry는 상태 없는 반복 응답으로 한정합니다. once, generator, 변경 가능한 저장소가 필요하면 테스트마다 factory에서 새 인스턴스를 만듭니다. context를 재사용하면 cache, storage, 앱 전역 상태의 초기화도 별도로 확인합니다. 브라우저 clock은 Node builder의 시간이나 난수 상태까지 바꾸지 않으므로 입력을 함께 전달합니다.
참고: MSW getResponse, Playwright의 service worker와 네트워크, Playwright의 다중 cookie 표현.
Node registry와 별개인 목록을 브라우저에서 다시 작성합니다.
import { http, HttpResponse } from "msw";
import { setupWorker } from "msw/browser";
import { mock_unhandled_api_prefix } from "@/service/msw/_constant/mock";
/**
* Node registry와 따로 만든 브라우저 전용 정상 데이터
*/
const browser_handlers = [http.get("*/api/orders", () => {
return HttpResponse.json({ items: [], total: 0 });
})];
/**
* mock 모드 진입에서만 호출하는 브라우저 worker 시작
*/
export const startMockWorker = async () => {
return setupWorker(...browser_handlers).start({
onUnhandledRequest: (request, print) => {
if (new URL(request.url).pathname.startsWith("/api/")) {
console.error(mock_unhandled_api_prefix, request.method, request.url);
print.error();
}
},
});
};공유 registry를 mock 모드의 브라우저 worker에 연결합니다.
/**
* 브라우저 worker와 실행 fixture가 함께 읽는 미처리 API 표식
*/
export const mock_unhandled_api_prefix = "[mock-api-unhandled]";import { setupWorker } from "msw/browser";
import { mock_unhandled_api_prefix } from "@/service/msw/_constant/mock";
import { mock_handlers } from "@/service/msw/handlers";
/**
* mock 모드 진입에서만 호출하는 브라우저 worker 시작
*/
export const startMockWorker = async () => {
return setupWorker(...mock_handlers).start({
onUnhandledRequest: (request, print) => {
if (new URL(request.url).pathname.startsWith("/api/")) {
console.error(mock_unhandled_api_prefix, request.method, request.url);
print.error();
}
},
});
};Node bridge가 요청과 응답을 보존하고 미처리 API를 거부합니다.
import { Buffer } from "node:buffer";
import { getResponse } from "msw";
import type { Route } from "@playwright/test";
import type { RequestHandler } from "msw";
/**
* API 전용 bridge. 미처리와 passthrough는 실제 호출 허용 없이 외부로 보내지 않음
*/
export const toMockRoute = (handlers: RequestHandler[]) => {
return async (route: Route) => {
const request = route.request();
const requestBody = request.postDataBuffer();
const response = await getResponse(handlers, new Request(request.url(), {
method: request.method(),
headers: await request.allHeaders(),
body: requestBody === null ? undefined : new Uint8Array(requestBody),
}), { baseUrl: new URL(request.url()).origin });
if (!response || response.headers.get("x-msw-intention") === "passthrough") {
throw new Error(`Unhandled mock API: ${request.method()} ${request.url()}`);
}
const cookies = response.headers.getSetCookie();
await route.fulfill({
status: response.status,
headers: {
...Object.fromEntries(response.headers),
...(cookies.length > 0 ? { "set-cookie": cookies.join("\n") } : {}),
},
body: Buffer.from(await response.arrayBuffer()),
});
};
};fixture가 테스트별 우선순위와 가로채기 수명을 소유합니다.
import { test as base, expect } from "@playwright/test";
import { toMockRoute } from "@test/_fixture/to-mock-route";
import type { RequestHandler } from "msw";
import { mock_handlers } from "@/service/msw/handlers";
/**
* 사례별 응답 덮어쓰기
*/
interface Fixtures {
/**
* 기본 registry보다 먼저 해석할 handler의 등록
*/
mockApi: {
/**
* 요청 시작 전에 추가할 사례 handler
*/
use: (...handlers: RequestHandler[]) => void;
};
}
export { expect };
/**
* 테스트마다 공유 registry 복사와 route 수명 소유. 새 context 사용을 기본값으로 유지
*/
export const test = base.extend<Fixtures>({
mockApi: [async ({ page }, use) => {
const handlers: RequestHandler[] = [...mock_handlers];
const route = toMockRoute(handlers);
await page.route("**/api/**", route);
try {
await use({
use: (...overrides) => {
handlers.unshift(...overrides);
},
});
} finally {
await page.unroute("**/api/**", route);
}
}, { auto: true }],
});화면이 실패 응답을 처리해도 미처리 API 기록으로 mock 앱 E2E를 실패시킵니다.
import { test as base, expect } from "@playwright/test";
import type { ConsoleMessage } from "@playwright/test";
import { mock_unhandled_api_prefix } from "@/service/msw/_constant/mock";
/**
* mock 앱 E2E에서 자동 적용하는 미처리 API 검사
*/
interface Fixtures {
/**
* 테스트의 모든 이동을 통틀어 수집하는 오류
*/
mockApiFailures: undefined;
}
export { expect };
/**
* mock 앱 E2E spec이 가져오는 test. 화면의 오류 처리와 별개로 API 누락을 검출
*/
export const test = base.extend<Fixtures>({
mockApiFailures: [async ({ page }, use) => {
const unhandled: string[] = [];
/**
* 브라우저 문서가 교체돼도 보존되는 Node 기록
*/
const collectMockError = (message: ConsoleMessage) => {
if (message.type() === "error" && message.text().startsWith(mock_unhandled_api_prefix)) {
unhandled.push(message.text());
}
};
page.on("console", collectMockError);
try {
await use(undefined);
expect(unhandled, "미처리 mock API 요청").toStrictEqual([]);
} finally {
page.off("console", collectMockError);
}
}, { auto: true }],
});함께 보기