3. 데이터와 격리
유효한 입력의 핵심 필드를 명시하고 시간과 실행 환경을 통제합니다. 테스트가 바꾼 상태와 자원의 수명을 격리하고 복구합니다.
3.1 작고 유효한 타입 기반 입력을 구성합니다
필수 필드 누락을 숨기지 않고 검증할 차이를 입력에서 읽을 수 있습니다.
정상 입력은 실제 계약 타입을 만족하는 작은 데이터로 만듭니다. 검증하거나 화면이 읽는 필드는 명시하고 타입 단언으로 비어 있는 객체를 계약에 맞는 것처럼 만들지 않습니다.
생성 산출물을 쓰는 API 응답 입력은 Kubb 등의 DTO와 faker factory 위에서 조립합니다. 생성 계약과 무관한 작은 함수 입력까지 factory로 전환하지 않습니다. 화면이 읽거나 분기, 정렬, 날짜 계산, 접근 가능한 이름에 쓰는 필드는 직접 덮어씁니다. 목록의 ID, 건수, 상세 참조처럼 서로 연결된 값도 일치시킵니다. 아무도 읽지 않는 필드만 생성기의 기본값에 맡깁니다.
seed는 기본 작성 절차가 아니며, seed만 고정해 필드의 의미를 대신하지 않습니다. 생성된 faker가 내부에서 난수를 사용해도 관찰되는 입력은 명시한 값으로 결정돼야 합니다. 외부의 잘못된 입력이 대상이면 그 입력 경계에서 unknown이나 실제 수신 형식으로 전달합니다. 반복되는 데이터만 생성기로 묶고 케이스의 핵심 값은 호출 자리에 남깁니다.
테스트의 표본값과 기대값은 제품 정책 상수를 가져오지 않고 직접 적을 수 있습니다. 요청 처리와 상태 코드 선택은 응답 데이터 계산과 구분하되 실제 폴더 구성은 프로젝트가 정합니다. 생성 계약과 소유 builder의 조립은 network-compose-generated-contracts-through-owned-builders를 따릅니다.
참고: 공식 문서.
타입 단언이 정상 입력의 필수 필드 누락을 숨깁니다.
import { expect, test } from "vitest";
import type { OrderDto } from "@/service/orders";
import { toOrderRows } from "@/page/orders/_function/to-order-rows";
test("수량을 행에 표시한다", () => {
/**
* GIVEN
* 표에 표시할 주문 입력
*/
const order = { quantity: 2 } as OrderDto;
/**
* WHEN
* 실제 주문 행 변환
*/
const rows = toOrderRows([order]);
/**
* THEN
* 입력 수량의 표시 문자열
*/
expect(rows[0].quantityLabel).toBe("2.00");
});실제 계약을 만족하는 입력에서 검증할 값을 명시합니다.
import { expect, test } from "vitest";
import type { OrderDto } from "@/service/orders";
import { toOrderRows } from "@/page/orders/_function/to-order-rows";
test("수량을 행에 표시한다", () => {
/**
* GIVEN
* 표에 표시할 주문 입력
*/
const order = { id: "order-1", quantity: 2, status: "open" } satisfies OrderDto;
/**
* WHEN
* 실제 주문 행 변환
*/
const rows = toOrderRows([order]);
/**
* THEN
* 입력 수량의 표시 문자열
*/
expect(rows[0].quantityLabel).toBe("2.00");
});3.2 시간과 환경을 명시적인 테스트 입력으로 통제합니다
실행 날짜와 실행 위치가 바뀌어도 같은 입력이 같은 결과를 냅니다.
결과에 영향을 주는 시각과 환경을 테스트 입력으로 고정합니다. 날짜 경계는 오프셋이 있는 기준 시각과 표시 타임존을 함께 정하고 모듈이 처음 값을 읽기 전에 적용합니다.
| 실행 위치 | 처리 |
|---|---|
| Vitest의 현재 날짜 | Date만 필요하면 Date를 대체하고 종료 후 복구합니다 |
| 브라우저 날짜 | 타이머를 진행시킬 때는 Playwright의 setFixedTime을 사용합니다 |
| Node에서 만드는 브라우저 테스트 응답 | 같은 기준 시각을 생성기에 전달합니다. 브라우저 clock이 Node의 Date를 바꾸지는 않습니다 |
| 로케일, 시간대, 화면 폭 | 계약에 영향을 줄 때 러너 설정으로 명시합니다 |
타이머 동작 자체를 검증할 때만 시간을 수동으로 진행합니다. 공유 프로세스의 시간대나 전역 난수 상태를 병렬 테스트가 서로 바꾸지 않게 합니다.
참고: 공식 문서.
실행하는 날의 날짜를 입력과 기대값에서 함께 읽습니다.
import dayjs from "dayjs";
import { expect, test } from "vitest";
import { normalizeDateRange } from "@/util/date/normalize-date-range";
test("빈 기간의 종료일은 오늘이다", () => {
/**
* GIVEN
* 종료일이 없는 기간
*/
const input = { startDate: "", endDate: "" };
/**
* WHEN
* 기간 보정
*/
const range = normalizeDateRange(input);
/**
* THEN
* 현재 날짜에서 다시 계산한 기대값
*/
expect(range.endDate).toBe(dayjs().format("YYYY-MM-DD"));
});Date만 고정하고 종료 시 원래 시간으로 복구합니다.
import dayjs from "dayjs";
import { afterEach, expect, test, vi } from "vitest";
import { normalizeDateRange } from "@/util/date/normalize-date-range";
// Node 시간대: 러너 시작 전 TZ=Asia/Seoul 지정
afterEach(() => {
vi.useRealTimers();
});
test("빈 기간의 종료일은 오늘이다", () => {
/**
* GIVEN
* 빈 기간과 고정된 서울 기준 날짜
*/
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(dayjs("2026-03-01T12:00:00+09:00").toDate());
const input = { startDate: "", endDate: "" };
/**
* WHEN
* 기간 보정
*/
const range = normalizeDateRange(input);
/**
* THEN
* 고정된 기준 날짜의 종료일
*/
expect(range.endDate).toBe("2026-03-01");
});브라우저 날짜와 Node 응답에 같은 시나리오 입력을 전달합니다.
import { expect, test } from "@playwright/test";
import dayjs from "dayjs";
const scenario_clock = {
instant: "2026-03-01T12:00:00+09:00",
date: "2026-03-01",
} as const;
test.use({ timezoneId: "Asia/Seoul" });
test("응답과 화면이 같은 기준 날짜를 사용한다", async ({ page }) => {
/**
* GIVEN
* 브라우저와 Node가 공유하는 날짜
*/
await page.clock.setFixedTime(dayjs(scenario_clock.instant).toDate());
await page.route("**/api/reports/range", async (route) => {
await route.fulfill({
json: { startDate: scenario_clock.date, endDate: scenario_clock.date },
});
});
/**
* WHEN
* 기간 화면 진입
*/
await page.goto("/reports");
/**
* THEN
* 시나리오 기준 날짜로 표시된 종료일
*/
await expect(page.getByLabel("종료일")).toHaveValue("2026-03-01");
});함께 보기
3.3 테스트 상태와 자원을 격리하고 수명을 끝냅니다
단독 실행과 전체 실행에서 같은 결과를 얻고 상태 누수를 막습니다.
각 테스트는 앞 테스트의 상태 없이 필요한 사전조건을 만들고 자신이 만든 자원을 종료합니다. mock의 호출 기록 초기화, 구현 초기화, spy 복구는 서로 다른 동작이므로 바꾼 상태에 맞게 복구합니다.
브라우저 context, 쿠키와 저장소, 요청 handler, provider의 캐시와 store가 각각 어디서 정리되는지 확인합니다. 러너가 제공하는 정리를 먼저 확인하고 필요한 부분만 추가합니다. 직접 연 context와 서버는 실패하더라도 finally나 fixture 종료 구간에서 닫습니다.
context 재사용은 격리가 확인된 프로젝트의 최적화로 선택합니다. 단독 실행, 순서 변화, 필요한 병렬 실행에서 결과가 같은지 확인하고 저장소 초기화가 검증 중인 탐색과 지속성을 지우지 않게 합니다.
참고: 공식 문서.
실패 경로에서 직접 연 context가 닫히지 않습니다.
import { expect, test } from "@playwright/test";
test("세션이 없으면 인증 안내가 표시된다", async ({ browser }) => {
/**
* GIVEN
* 세션을 넣지 않은 독립 context
*/
const context = await browser.newContext({ baseURL: "https://app.example.test" });
/**
* WHEN
* 보호된 주문 화면 진입
*/
const page = await context.newPage();
await page.goto("/orders");
/**
* THEN
* 단언 뒤에만 닫히는 context
*/
await expect(page.getByRole("alert")).toHaveText("로그인이 필요합니다");
await context.close();
});성공과 실패 모두에서 직접 연 context를 닫습니다.
import { expect, test } from "@playwright/test";
test("세션이 없으면 인증 안내가 표시된다", async ({ browser }) => {
/**
* GIVEN
* 세션을 넣지 않은 독립 context
*/
const context = await browser.newContext({ baseURL: "https://app.example.test" });
try {
/**
* WHEN
* 보호된 주문 화면 진입
*/
const page = await context.newPage();
await page.goto("/orders");
/**
* THEN
* 인증이 필요한 사용자 안내
*/
await expect(page.getByRole("alert")).toHaveText("로그인이 필요합니다");
} finally {
await context.close();
}
});