Navigation

9. Story와 harness

실제 화면과 provider로 재현 가능한 상태를 만들고 story를 테스트에서 소비합니다. 브라우저 경계의 props, 콜백, 정리와 자동 단언의 책임을 구분합니다.

9.1 Story는 실제 화면과 provider로 재현할 상태를 구성합니다

화면의 실제 처리 흐름을 유지하면서 상태를 작게 재현합니다.

story는 화면, 위젯의 특정 상태를 만드는 브라우저 지원 코드입니다. 실제 컴포넌트와 provider를 사용하고 hook, mapper를 대체해 정답 화면을 만들지 않습니다. 앱 초기 설정은 gallery가 공통으로 맡거나 story가 맡되 provider를 중복 설치하지 않습니다.

구성책임
named export기본, 긴 이름, 선택 상태처럼 의미 있는 재현 상태 하나
props같은 상태의 값 차이. Node에서 넘길 때 직렬화 가능해야 합니다
router실제로 읽는 경로, params, search와 이동에 필요한 경계
provider앱과 같은 query, theme, modal 등 실제 문맥
gallerystory 발견, mount, update, unmount와 렌더 실패의 전파

상태를 가진 page 소유자와 우리 동작이 있는 위젯부터 story를 만듭니다. 서드파티 자체의 속성이나 단순 래퍼를 모두 별도 story로 만들지 않습니다. 콜백은 브라우저 story 안에서 만들고 결과를 DOM, URL 등 테스트가 볼 수 있는 값으로 남깁니다. Node spy나 함수를 props로 넘겨 브라우저에서 실행될 것으로 가정하지 않습니다.

story 파일은 앱의 production 진입 경로로 가져오지 않고 테스트 gallery에서만 발견합니다. 하나의 story에 모든 앱 여정을 붙이지 않고 세션과 라우트 트리 자체가 대상일 때만 범위를 넓힙니다. storybook은 선택 사항이며 현재 프로젝트의 gallery와 story ID 계약을 유지합니다.

참고: Playwright의 story와 gallery.

실제 화면 대신 기대 문구만 렌더합니다.

test/orders/orders.story.tsxtsx
/**
 * 실제 주문 화면의 빈 상태를 대신한 문구
 */
export const Default = () => {
	return <p role="status">주문이 없습니다</p>;
};

실제 화면을 앱 provider와 초기 라우트 안에 렌더합니다.

test/orders/orders.story.tsxtsx
import { StoryRoute } from "@test/_gallery/story-route";

import { AppProviders } from "@/app-providers";
import { PgOrders } from "@/page/(main)/orders/pg-orders";

/**
 * 주문 첫 페이지. gallery가 provider를 제공하는 프로젝트에서는 AppProviders 중복 제외
 */
export const Default = () => {
	return (
		<AppProviders>
			<StoryRoute entry="/orders" path="/orders">
				<PgOrders />
			</StoryRoute>
		</AppProviders>
	);
};

/**
 * URL 정렬 상태를 재현하는 입력
 */
export interface WithSearchProps {
	/**
	 * 화면이 처음 읽을 search 문자열
	 */
	search: string;
}

/**
 * 같은 화면의 URL 입력만 바꾼 story
 */
export const WithSearch = (props: WithSearchProps) => {
	return (
		<AppProviders>
			<StoryRoute entry={`/orders${props.search}`} path="/orders">
				<PgOrders />
			</StoryRoute>
		</AppProviders>
	);
};

9.2 Story를 mount하는 테스트에서 행동과 결과를 검증합니다

재현용 표본과 자동 검증을 연결하고 실행되지 않은 상태를 통과로 오인하지 않습니다.

story 파일이 있다는 이유로 자동화 integration 테스트가 있다고 보고하지 않습니다. 러너가 수집하는 spec에서 story를 mount하고 관찰 가능한 결과를 단언해야 검증됩니다. 실제 provider, 응답 처리와 구역 안의 조작을 함께 실행하는 소비 경계를 기록합니다.

작성과 실행 순서

  1. 구역의 정상, 빈 값, 로딩, 실패 중 변경 계약에 필요한 상태를 고릅니다.
  2. 공유 registry를 fixture에 연결하고 사례의 API 차이를 요청 시작 전에 등록합니다.
  3. 초기 props와 라우트가 있는 story를 mount하고 반환된 locator로 범위를 좁힙니다.
  4. GIVEN, WHEN, THEN으로 준비, 행동, 결과를 설명하고 DOM, URL, 파일 결과를 단언합니다.
  5. gallery를 포함한 테스트 빌드, 수집 목록과 해당 spec의 실제 실행을 확인합니다.

gallery의 baseURL과 앱 E2E의 baseURL, 두 project의 수집 패턴을 구분합니다. 브라우저 mock을 Node bridge가 맡으면 serviceWorkers를 block으로 설정합니다. 실패한 story 로드와 렌더가 mount 성공으로 숨지 않게 하고 fixture가 종료 자원을 정리합니다.

상태와 여정의 분담

구역의 상태 조합은 integration에서, 실제 앱 진입과 화면 연결은 E2E에서 확인합니다. story에 넣은 router만으로 production 라우트 등록과 앱 초기화까지 검증했다고 하지 않습니다. callback 결과는 브라우저에서 기록한 값을 읽고, 픽셀 결과는 별도 시각 비교 사례로 선택합니다. 수집 목록에 나타난 것은 실행 증거가 아니며 CI에서 실행한 범위와 결과를 따로 확인합니다.

참고: Playwright의 component 소비 테스트.

story가 그려졌다는 사실만 확인합니다.

test/orders/orders.integration.spec.tsts
import { expect, test } from "@test/_fixture/test";

import { createGetOrdersStatus200 } from "@/service/__kubb/mock/orders/get-orders";
import { getOrdersHandler } from "@/service/__kubb/msw/orders/get-orders-handler";

test("빈 주문 목록에 안내가 표시된다", async ({ mockApi, mount }) => {
	/**
	 * GIVEN
	 * 빈 주문 목록을 응답하는 서버
	 */
	mockApi.use(getOrdersHandler(createGetOrdersStatus200({
		items: [], total: 0, page: 1, updatedAt: "2026-03-01T03:00:00Z",
	})));

	/**
	 * WHEN
	 * 주문 story 진입
	 */
	const view = await mount("orders/Default");

	/**
	 * THEN
	 * 빈 상태와 관계없는 root 존재만 검사
	 */
	await expect(view).toBeVisible();
});

빈 응답을 등록하고 실제 화면의 상태를 확인합니다.

test/orders/orders.integration.spec.tsts
import { expect, test } from "@test/_fixture/test";

import { createGetOrdersStatus200 } from "@/service/__kubb/mock/orders/get-orders";
import { getOrdersHandler } from "@/service/__kubb/msw/orders/get-orders-handler";

test("빈 주문 목록에 안내가 표시된다", async ({ mockApi, mount }) => {
	/**
	 * GIVEN
	 * 빈 주문 목록을 응답하는 서버
	 */
	mockApi.use(getOrdersHandler(createGetOrdersStatus200({
		items: [], total: 0, page: 1, updatedAt: "2026-03-01T03:00:00Z",
	})));

	/**
	 * WHEN
	 * 실제 provider와 주문 화면을 가진 story 진입
	 */
	const view = await mount("orders/Default");

	/**
	 * THEN
	 * 실제 응답 처리 뒤 빈 목록 안내
	 */
	await expect(view.getByRole("status")).toHaveText("주문이 없습니다");
});

콜백 결과는 브라우저 story의 상태로 기록하고 테스트에서 읽습니다.

test/orders/order-dialog.story.tsxtsx
import { Fragment, useState } from "react";

import { WgOrderDialog } from "@/component/widget/order-dialog/wg-order-dialog";

/**
 * 저장 callback의 결과를 브라우저 DOM에 남기는 story
 */
export const SaveResult = () => {
	const [savedOrderId, setSavedOrderId] = useState("");

	return (
		<Fragment>
			{/*
			 * 브라우저 안에서 생성한 callback
			 */}
			<WgOrderDialog orderId="order-1" onSave={setSavedOrderId} />
			{/*
			 * 테스트에서 읽을 저장 결과
			 */}
			<output data-testid="saved-order">{savedOrderId}</output>
		</Fragment>
	);
};
test/orders/order-dialog.integration.spec.tsts
import { expect, test } from "@playwright/test";

test("주문을 저장하면 callback에 선택한 ID를 전달한다", async ({ mount }) => {
	/**
	 * GIVEN
	 * 브라우저에서 callback 결과를 기록하는 story
	 */
	const storyId = "orders/order-dialog/SaveResult";

	/**
	 * WHEN
	 * 모달을 mount하고 저장
	 */
	const view = await mount(storyId);
	await view.getByRole("button", { name: "저장", exact: true }).click();

	/**
	 * THEN
	 * story가 실제 callback으로 받은 주문 ID
	 */
	await expect(view.getByTestId("saved-order")).toHaveText("order-1");
});

gallery와 앱의 수집 범위와 service worker 처리를 분리합니다.

playwright.config.tsts
import { defineConfig } from "@playwright/test";

export default defineConfig({
	projects: [
		{ name: "e2e", testMatch: "**/*.e2e.spec.ts", use: { baseURL: "http://localhost:5175" } },
		{
			name: "integration",
			testMatch: "**/*.integration.spec.ts",
			use: { baseURL: "http://localhost:5175/test/_gallery/index.html", serviceWorkers: "block" },
		},
	],
	webServer: {
		// 프로젝트에서 앱과 gallery를 같은 테스트 빌드로 만드는 명령
		command: "npm run preview:test",
		url: "http://localhost:5175/test/_gallery/index.html",
	},
});

함께 보기