Bigtablet Design System의 테스트 작성 가이드입니다.
- Vitest - 테스트 러너 (multi-project:
unit+storybook) - React Testing Library - 컴포넌트 테스트
- jsdom - DOM 환경 (unit 테스트)
- Playwright - 브라우저 환경 (a11y 테스트, headless Chromium)
- @storybook/addon-a11y - axe-core 기반 접근성 자동 테스트
- v8 - 커버리지 프로바이더
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["text", "json", "json-summary", "html"],
include: ["src/ui/**/*.{ts,tsx}", "src/utils/**/*.{ts,tsx}"],
exclude: ["**/*.test.{ts,tsx}", "**/*.stories.{ts,tsx}"],
},
},
});// src/test/setup.ts
import "@testing-library/jest-dom";
import { vi } from "vitest";
// Mock window.matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});# 전체 테스트 실행
pnpm test
# Watch 모드
pnpm test:watch
# 특정 파일만 테스트
pnpm vitest run src/ui/general/button/button.test.tsx
# 커버리지 리포트
pnpm test:coverage
# a11y 테스트 (Storybook + Playwright)
pnpm test:storybook
# UI 모드
pnpm vitest --ui테스트 파일은 컴포넌트와 같은 디렉토리에 위치합니다:
src/ui/general/button/
├── index.tsx
├── style.scss
└── button.test.tsx # 테스트 파일 (폴더명과 같게)
src/vanilla/bigtablet.test.ts 가 Vanilla 번들(Dropdown / Modal / Toggle / Alert)을 덮는다.
React 쪽 unit 프로젝트에 그대로 포함되므로 pnpm test 로 함께 돈다.
// UMD 번들이지만 Vite 가 named export 로 노출해준다. 소비자는 `<script>` + 전역 `Bigtablet`
// 로 쓰지만 부착 경로만 다르고 검사 대상 로직은 같다. 빌드 산출물이 아니라 소스를 import 해서
// 소스 변경이 곧 테스트에 걸리게 한다.
import { Alert, Dropdown, Modal, Toggle } from "./bigtablet.js";주의할 점:
- jsdom 은 레이아웃을 하지 않는다. 스크롤바 폭(
innerWidth - documentElement.clientWidth)은 항상 0 이므로, 스크롤 잠금 보정을 검사하려면 두 값을 직접 세워야 한다 (setScrollbarWidth헬퍼 참고). - 내부 함수는 export 되지 않는다.
lockScroll/unlockScroll는Modal·Alert를 열고 닫으며 간접 검사한다 - 통합 경로를 함께 보게 되어 오히려 낫다. - 문서화된 마크업으로 시작한다. 서버 템플릿이 렌더하는 형태(평면
<ul>, 서버가 심어둔 hidden input 등)를 그대로 세워야 실제 회귀를 잡는다 - 실제로 그 두 경로에서 버그가 나왔다.
커버리지 집계(
vitest.config.ts의coverage.include)는 아직src/ui/**·src/utils/**만 본다. Vanilla 를 넣으면 전체 수치가 크게 떨어지므로 별건으로 다룬다 - 테스트 자체는 이미 돈다.
배치·닫힘이 getBoundingClientRect() 에 달려 있어 jsdom 에서 두 가지가 걸린다.
-
jsdom 의 rect 는 전부 0 이다. 그래서 "앵커가 뷰포트 밖" 판정(
bottom <= 0)이 아무것도 안 했는데 참이 된다.useAnchoredPosition이 앵커 크기가 0 이면anchorHidden을 false 로 두는 것은 이 때문이다(#624). 이 가드를 빼면 열자마자 닫혀 기존 테스트 6개가 무너진다 - 뮤테이션으로 확인된 수치다. 위치·가시성을 검사하려면 rect 를 직접 세운다:vi.spyOn(wrapper, "getBoundingClientRect").mockReturnValue({ top: -80, bottom: -40, left: 0, right: 200, width: 200, height: 40, } as DOMRect); await act(async () => { fireEvent.scroll(window); // 훅은 scroll(capture) 로 재계산하고 await new Promise((r) => requestAnimationFrame(() => r(null))); // rAF 로 배칭한다 });
-
포커스 복귀는
focus({ preventScroll: true })로 단언한다. 트리거가 화면 밖이라 닫는 경로에서 그냥focus()하면 브라우저가 방금 벗어난 트리거로 화면을 되감는다. jsdom 은 스크롤을 하지 않아 증상이 안 보이므로, 인자까지 단언해야 회귀가 잡힌다:expect(focusSpy).toHaveBeenCalledWith({ preventScroll: true }).
레이아웃 자체(높이 상한·정렬)는 jsdom 이 계산하지 않는다 - 실제 브라우저(Storybook)에서 재고 그 수치를 PR 에 남긴다.
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ComponentName } from "./index";
describe("ComponentName", () => {
// 기본 렌더링 테스트
it("renders correctly", () => {
render(<ComponentName>Content</ComponentName>);
expect(screen.getByText("Content")).toBeInTheDocument();
});
// Props 테스트
it("applies variant class", () => {
const { container } = render(<ComponentName variant="primary" />);
expect(container.firstChild).toHaveClass("variant_primary");
});
// 이벤트 테스트
it("calls onClick when clicked", () => {
const onClick = vi.fn();
render(<ComponentName onClick={onClick}>Click</ComponentName>);
fireEvent.click(screen.getByText("Click"));
expect(onClick).toHaveBeenCalledTimes(1);
});
// 접근성 테스트
it("has correct aria attributes", () => {
render(<ComponentName aria-label="Test" />);
expect(screen.getByLabelText("Test")).toBeInTheDocument();
});
});모든 컴포넌트는 다음 항목을 테스트해야 합니다:
- 기본 렌더링
- 모든 Props 동작
- 이벤트 핸들러
- 비활성화 상태
- 접근성 속성
- 조건부 렌더링
- 에러 상태
it("renders children", () => {
render(<Button>Click me</Button>);
expect(screen.getByText("Click me")).toBeInTheDocument();
});
it("renders with custom className", () => {
const { container } = render(<Button className="custom">Click</Button>);
expect(container.firstChild).toHaveClass("custom");
});it("applies size class", () => {
const { container } = render(<Button size="lg">Click</Button>);
expect(container.firstChild).toHaveClass("size_lg");
});
it("applies default props", () => {
const { container } = render(<Button>Click</Button>);
expect(container.firstChild).toHaveClass("variant_primary");
expect(container.firstChild).toHaveClass("size_md");
});it("calls onClick handler", () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click</Button>);
fireEvent.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("does not call onClick when disabled", () => {
const onClick = vi.fn();
render(<Button onClick={onClick} disabled>Click</Button>);
fireEvent.click(screen.getByRole("button"));
expect(onClick).not.toHaveBeenCalled();
});describe("controlled mode", () => {
it("uses value prop", () => {
const { rerender } = render(<Toggle checked={false} onChange={() => {}} />);
expect(screen.getByRole("switch")).not.toHaveClass("toggle_on");
rerender(<Toggle checked={true} onChange={() => {}} />);
expect(screen.getByRole("switch")).toHaveClass("toggle_on");
});
});
describe("uncontrolled mode", () => {
it("manages internal state", () => {
render(<Toggle defaultChecked={false} />);
fireEvent.click(screen.getByRole("switch"));
expect(screen.getByRole("switch")).toHaveClass("toggle_on");
});
});it("calls onChange with new value", () => {
const onChange = vi.fn();
render(<TextField onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "test" }
});
expect(onChange).toHaveBeenCalled();
});
it("shows error state", () => {
render(<TextField error supportingText="Error message" />);
expect(screen.getByRole("textbox")).toHaveClass("text_field_input_error");
expect(screen.getByText("Error message")).toBeInTheDocument();
});it("has correct ARIA attributes", () => {
render(<Modal open title="Test Modal" onClose={() => {}}>Content</Modal>);
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveAttribute("aria-modal", "true");
expect(dialog).toHaveAttribute("aria-labelledby");
});
it("is keyboard accessible", () => {
render(<Button>Click</Button>);
const button = screen.getByRole("button");
button.focus();
expect(document.activeElement).toBe(button);
});const renderWithProvider = (ui: React.ReactElement) => {
return render(<AlertProvider>{ui}</AlertProvider>);
};
it("shows alert when showAlert is called", () => {
const TestComponent = () => {
const { showAlert } = useAlert();
return (
<button onClick={() => showAlert({ title: "Test" })}>
Show Alert
</button>
);
};
renderWithProvider(<TestComponent />);
fireEvent.click(screen.getByText("Show Alert"));
expect(screen.getByText("Test")).toBeInTheDocument();
});// 외부 모듈 Mock
vi.mock("next/link", () => ({
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
// localStorage Mock
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
clear: vi.fn(),
};
Object.defineProperty(window, "localStorage", { value: localStorageMock });
// 함수 Mock
it("calls callback with correct arguments", () => {
const onChange = vi.fn();
render(<Dropdown options={options} onChange={onChange} />);
// Select option
fireEvent.click(screen.getByText("Option 1"));
expect(onChange).toHaveBeenCalledWith("value1", expect.objectContaining({
value: "value1",
label: "Option 1",
}));
});pnpm test:coverage (v8, unit 프로젝트) 기준 - 76 test files / 1246 passed · 8 skipped.
스토리북 러너를 커버리지와 함께 돌리면 훨씬 낮은 수치가 나온다 - 실측 58.95% stmts. 스토리는 컴포넌트를 렌더할 뿐 상호작용을 끝까지 몰지 않아서고, 회귀가 아니다. 예를 들어
src/utils/**는 스토리북 48.4% · unit 93.3% 다 - 훅과 유틸은 스토리가 거의 건드리지 않는다. 스토리북 커버리지는 목표 지표가 아니다 - 그 러너의 목적은 axe a11y 검증이고 커버리지는 부산물이다. 아래 표와 CI 리포트는 모두unit프로젝트 기준이다.
| 전체 | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| All files | 92.86% | 90.13% | 93.75% | 94.73% |
아래는 100% 미만인 것만 나열한 것이다 (38개는 전 지표 100% 라 빠져 있다).
| 파일 | Stmts | Branch | Funcs | Lines |
|---|---|---|---|---|
| ui/display/accordion | 100% | 80% | 100% | 100% |
| ui/display/avatar | 92.31% | 85.19% | 100% | 100% |
| ui/display/chip | 91.67% | 95.65% | 66.67% | 100% |
| ui/display/data-view | 100% | 97.87% | 100% | 100% |
| ui/display/hero | 92.86% | 88.89% | 100% | 100% |
| ui/display/list-item | 100% | 93.55% | 100% | 100% |
| ui/display/media-card | 100% | 96.3% | 100% | 100% |
| ui/display/prose | 76.32% | 53.33% | 100% | 79.41% |
| ui/display/table | 97.44% | 91.27% | 96.43% | 98.51% |
| ui/feedback/alert | 98.57% | 94.92% | 100% | 100% |
| ui/feedback/linear-progress | 100% | 66.67% | 100% | 100% |
| ui/feedback/toast | 100% | 86.36% | 100% | 100% |
| ui/forms/checkbox | 92.31% | 95.45% | 100% | 100% |
| ui/forms/combobox | 94.44% | 91.84% | 82.35% | 96.97% |
| ui/forms/date-picker | 93.22% | 91.67% | 100% | 100% |
| ui/forms/dropdown | 100% | 93.71% | 100% | 100% |
| ui/forms/file | 82% | 72.55% | 83.33% | 81.63% |
| ui/forms/image-cropper | 59.33% | 58.95% | 50% | 60.28% |
| ui/forms/otp-input | 90.22% | 92.65% | 100% | 90.12% |
| ui/forms/tag-input | 97.65% | 91.67% | 100% | 100% |
| ui/forms/textarea | 88.33% | 74.51% | 100% | 92.73% |
| ui/forms/textfield | 98.18% | 97.06% | 90.91% | 98.08% |
| ui/forms/time-picker | 93.33% | 91.14% | 100% | 100% |
| ui/navigation/bottom-nav | 95.83% | 91.67% | 100% | 95.83% |
| ui/navigation/menu | 97.73% | 90.32% | 100% | 100% |
| ui/navigation/nav-bar | 80.19% | 71.95% | 81.82% | 85.11% |
| ui/navigation/sidebar | 84.85% | 89.29% | 80% | 87.5% |
| ui/navigation/tabs | 91.43% | 78.18% | 88.89% | 98.9% |
| ui/overlay/popover | 91.3% | 85.71% | 100% | 92.68% |
| ui/overlay/tooltip | 93.33% | 87.18% | 93.75% | 92% |
| ui/system/theme-provider | 93.88% | 85.71% | 100% | 100% |
| utils/cn.ts | 100% | 87.5% | 100% | 100% |
| utils/overlay-stack.ts | 93.93% | 81.25% | 87.5% | 96.29% |
| utils/scroll-lock.ts | 97.59% | 95.23% | 100% | 100% |
| utils/split-aria-props.ts | 100% | 50% | 100% | 100% |
| utils/use-anchored-position.ts | 97.72% | 90% | 100% | 100% |
| utils/use-focus-trap.ts | 97.82% | 88% | 100% | 100% |
| utils/use-listbox-popup.ts | 94.52% | 93.25% | 100% | 94.95% |
| utils/use-reduced-motion.ts | 95.23% | 100% | 85.71% | 94.73% |
| utils/use-safe-layout-effect.ts | 100% | 50% | 100% | 100% |
이 표는
coverage/coverage-summary.json에서 뜬 실측이다. 손으로 고치지 말고pnpm test:coverage를 돌린 뒤 그 파일 기준으로 갱신한다 - 예전에 이 표가 788 tests 시절 수치로 굳어 있었다.
// src/ui/general/button/button.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { Button } from "./index";
describe("Button", () => {
it("renders children", () => {
render(<Button>Click me</Button>);
expect(screen.getByText("Click me")).toBeInTheDocument();
});
it("applies variant classes", () => {
const { container, rerender } = render(<Button variant="filled">Test</Button>);
expect(container.firstChild).toHaveClass("button_variant_filled");
rerender(<Button variant="outline">Test</Button>);
expect(container.firstChild).toHaveClass("button_variant_outline");
});
it("applies the danger modifier independently of variant", () => {
const { container } = render(<Button variant="outline" danger>Delete</Button>);
expect(container.firstChild).toHaveClass("button_variant_outline");
expect(container.firstChild).toHaveClass("button_danger");
});
it("applies size classes", () => {
const { container, rerender } = render(<Button size="sm">Test</Button>);
expect(container.firstChild).toHaveClass("button_size_sm");
rerender(<Button size="xl">Test</Button>);
expect(container.firstChild).toHaveClass("button_size_xl");
});
it("calls onClick when clicked", () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click</Button>);
fireEvent.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("is disabled when disabled prop is true", () => {
render(<Button disabled>Click</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
it("applies fullWidth class", () => {
const { container } = render(<Button fullWidth>Click</Button>);
expect(container.firstChild).toHaveClass("fullWidth");
});
it("forwards ref", () => {
const ref = { current: null };
render(<Button ref={ref}>Click</Button>);
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
});
});// src/ui/overlay/modal/modal.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { Modal } from "./index";
describe("Modal", () => {
it("renders when open is true", () => {
render(<Modal open onClose={() => {}}>Content</Modal>);
expect(screen.getByText("Content")).toBeInTheDocument();
});
it("does not render when open is false", () => {
render(<Modal open={false} onClose={() => {}}>Content</Modal>);
expect(screen.queryByText("Content")).not.toBeInTheDocument();
});
it("renders title", () => {
render(<Modal open title="Test Title" onClose={() => {}}>Content</Modal>);
expect(screen.getByText("Test Title")).toBeInTheDocument();
});
it("calls onClose when overlay is clicked", () => {
const onClose = vi.fn();
render(<Modal open onClose={onClose}>Content</Modal>);
fireEvent.click(screen.getByRole("dialog").parentElement!);
expect(onClose).toHaveBeenCalled();
});
it("does not close when panel is clicked", () => {
const onClose = vi.fn();
render(<Modal open onClose={onClose}>Content</Modal>);
fireEvent.click(screen.getByRole("dialog"));
expect(onClose).not.toHaveBeenCalled();
});
it("has correct accessibility attributes", () => {
render(<Modal open onClose={() => {}}>Content</Modal>);
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveAttribute("aria-modal", "true");
});
});모든 Storybook 스토리는 @storybook/addon-a11y를 통해 axe-core 기반 접근성 검사를 자동으로 수행합니다.
CI에서는 Playwright(headless Chromium)로 실제 DOM에서 렌더링하여 테스트합니다.
- Storybook 스토리가 Playwright 브라우저에서 렌더링
- axe-core가 각 스토리에 대해 WCAG 위반 검사
error모드로 설정되어 위반 시 테스트 실패
// .storybook/preview.ts
const preview: Preview = {
parameters: {
a11y: {
test: "error", // 위반 시 에러 발생
},
},
};의도적으로 접근성 위반을 보여주는 데모 스토리 등에서 사용:
// 스토리 단위 비활성화
export const DemoStory: Story = {
parameters: {
a11y: { test: "off" },
},
};
// 스토리 전체를 Vitest에서 제외
const meta: Meta = {
tags: ["autodocs", "!test"],
};# .github/workflows/ci.yml
- name: Install Playwright browsers
run: pnpm exec playwright install --with-deps chromium
- name: Run a11y tests (Storybook)
run: pnpm test:storybook- Contributing - 기여 가이드
- Architecture - 프로젝트 구조
- Components - 컴포넌트 API