Skip to content

Latest commit

 

History

History
628 lines (487 loc) · 19.8 KB

File metadata and controls

628 lines (487 loc) · 19.8 KB

Testing

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}"],
        },
    },
});

Setup 파일

// 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    # 테스트 파일 (폴더명과 같게)

Vanilla 번들 테스트

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 / unlockScrollModal · Alert 를 열고 닫으며 간접 검사한다 - 통합 경로를 함께 보게 되어 오히려 낫다.
  • 문서화된 마크업으로 시작한다. 서버 템플릿이 렌더하는 형태(평면 <ul>, 서버가 심어둔 hidden input 등)를 그대로 세워야 실제 회귀를 잡는다 - 실제로 그 두 경로에서 버그가 나왔다.

커버리지 집계(vitest.config.tscoverage.include)는 아직 src/ui/** · src/utils/** 만 본다. Vanilla 를 넣으면 전체 수치가 크게 떨어지므로 별건으로 다룬다 - 테스트 자체는 이미 돈다.

앵커 팝업(Dropdown·Combobox·Menu·Popover·Tooltip) 테스트

배치·닫힘이 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 동작
  • 이벤트 핸들러
  • 비활성화 상태
  • 접근성 속성
  • 조건부 렌더링
  • 에러 상태

테스트 패턴

1. 렌더링 테스트

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");
});

2. Props 테스트

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");
});

3. 이벤트 테스트

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();
});

4. 상태 테스트 (Controlled/Uncontrolled)

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");
    });
});

5. 폼 컴포넌트 테스트

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();
});

6. 접근성 테스트

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);
});

7. Provider 테스트

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();
});

8. Mock 사용

// 외부 모듈 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 시절 수치로 굳어 있었다.

컴포넌트별 테스트 예시

Button 테스트

// 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);
    });
});

Modal 테스트

// 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");
    });
});

접근성(a11y) 테스트

개요

모든 Storybook 스토리는 @storybook/addon-a11y를 통해 axe-core 기반 접근성 검사를 자동으로 수행합니다. CI에서는 Playwright(headless Chromium)로 실제 DOM에서 렌더링하여 테스트합니다.

동작 방식

  1. Storybook 스토리가 Playwright 브라우저에서 렌더링
  2. axe-core가 각 스토리에 대해 WCAG 위반 검사
  3. 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"],
};

CI 파이프라인

# .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

관련 문서