# 테스트 작성 가이드

> `/review` 에서 테스트 누락과 테스트 구조를 점검할 때 참고하는 기준이다.

## 테스트 프레임워크

- **단위/통합 테스트**: Vitest + Testing Library
- **E2E 테스트**: Playwright

---

## 파일 규칙

### 파일 위치 및 네이밍

테스트 파일은 대상 파일과 **같은 디렉토리**에 위치한다.

```
some-feature/
├── some-feature.tsx
├── some-feature.test.tsx    ← 컴포넌트/훅 테스트
├── some-utils.ts
└── some-utils.test.ts       ← 순수 함수 테스트
```

| 종류        | 대상 파일 확장자 | 테스트 파일 확장자 |
| ----------- | ---------------- | ------------------ |
| 단위/통합   | `.ts`            | `.test.ts`         |
| 단위/통합   | `.tsx`           | `.test.tsx`        |
| E2E         | —                | `.spec.ts`         |

- 단위/통합: `.test.ts` 통일, React 관련은 `.test.tsx`
- E2E: `.spec.ts` 통일 (JSX 불필요)

---

## 환경 분류

패키지별 vitest.config.ts에서 `createConfig()`로 환경을 설정한다.

| 환경        | 대상 패키지                              | 용도                 |
| ----------- | ---------------------------------------- | -------------------- |
| `node`      | utils, stores, server-side-helper        | 순수 함수, 상태 로직 |
| `happy-dom` | design-system, components, hooks, bridge | React 컴포넌트, 훅   |

```typescript
// vitest.config.ts 예시
import { createConfig } from 'sales-frontend-vitest-config';

export default createConfig({
  name: 'design-system',
  environment: 'happy-dom',
  scss: true,   // SCSS module mock 활성화
  react: true,  // React JSX transform 활성화
});
```

---

## 테스트 구조

### 기본 구조

```typescript
import { describe, it, expect } from 'vitest';

describe('대상명', () => {
  describe('메서드/기능명', () => {
    it('기대 동작을 설명한다', () => {
      // given
      // when
      // then
    });
  });
});
```

- `describe` 중첩은 **최대 2단계**
- `it` 설명은 **기대 동작**을 명확히 서술 (한국어 가능)
- `describe` 그룹핑은 메서드/기능/시나리오 단위

### 데이터 기반 테스트

반복 시나리오는 `it.each()`를 사용한다.

```typescript
it.each([
  { input: '19900101', expected: 35 },
  { input: '20000315', expected: 25 },
  { input: '', expected: null },
])('생년월일 $input → 나이 $expected', ({ input, expected }) => {
  expect(calculateAge(input)).toBe(expected);
});
```

---

## 순수 함수 테스트

```typescript
import { describe, it, expect, vi, afterEach } from 'vitest';
import { formatDate } from './date-utils';

describe('formatDate', () => {
  afterEach(() => {
    vi.restoreAllMocks();
  });

  it('yyyy-MM-dd 형식으로 변환한다', () => {
    expect(formatDate('20240101')).toBe('2024-01-01');
  });
});
```

### 시간 의존 테스트

```typescript
import { vi, beforeEach, afterEach } from 'vitest';

beforeEach(() => {
  vi.useFakeTimers();
  vi.setSystemTime(new Date('2025-03-20'));
});

afterEach(() => {
  vi.useRealTimers();
});
```

---

## 컴포넌트 테스트

### 기본 패턴

```typescript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './button';

describe('Button', () => {
  it('클릭 시 onClick을 호출한다', async () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>확인</Button>);

    await userEvent.click(screen.getByRole('button', { name: '확인' }));

    expect(handleClick).toHaveBeenCalledOnce();
  });

  it('disabled 상태에서는 클릭이 동작하지 않는다', async () => {
    const handleClick = vi.fn();
    render(<Button onClick={handleClick} disabled>확인</Button>);

    await userEvent.click(screen.getByRole('button'));

    expect(handleClick).not.toHaveBeenCalled();
    expect(screen.getByRole('button')).toBeDisabled();
  });
});
```

### 쿼리 우선순위

접근성 기반 쿼리를 우선 사용한다.

| 우선순위 | 쿼리                        | 사용 시점              |
| -------- | --------------------------- | ---------------------- |
| 1        | `getByRole`                 | 기본 (버튼, 입력 등)  |
| 2        | `getByText` / `getByLabelText` | role로 특정 불가 시 |
| 3        | `getByTestId`               | 최후 수단              |

```typescript
// ✅ 접근성 기반
screen.getByRole('button', { name: '저장' })
screen.getByRole('textbox')

// ⚠️ 가급적 지양
screen.getByTestId('submit-button')
```

### 사용자 이벤트

`fireEvent` 대신 `userEvent`를 사용한다.

```typescript
import userEvent from '@testing-library/user-event';

// ✅ userEvent (실제 사용자 동작 시뮬레이션)
await userEvent.click(button);
await userEvent.type(input, '홍길동');
await userEvent.tab();

// ❌ fireEvent (저수준 이벤트)
fireEvent.click(button);
```

---

## 훅 테스트

```typescript
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { usePagination } from './use-pagination';

describe('usePagination', () => {
  it('초기 페이지는 1이다', () => {
    const { result } = renderHook(() => usePagination({ totalPages: 10 }));
    expect(result.current.page).toBe(1);
  });

  it('다음 페이지로 이동한다', () => {
    const { result } = renderHook(() => usePagination({ totalPages: 10 }));

    act(() => {
      result.current.goToNext();
    });

    expect(result.current.page).toBe(2);
  });

  it('props 변경 시 상태가 리셋된다', () => {
    const { result, rerender } = renderHook(
      ({ totalPages }) => usePagination({ totalPages }),
      { initialProps: { totalPages: 10 } },
    );

    act(() => {
      result.current.goToNext();
    });

    rerender({ totalPages: 5 });
    expect(result.current.page).toBe(1);
  });
});
```

---

## Mock 전략

### vi.fn() — 콜백/핸들러 mock

```typescript
const onChange = vi.fn();
render(<Input onChange={onChange} />);
expect(onChange).toHaveBeenCalledWith('새 값');
```

### vi.spyOn() — 기존 메서드 감시

```typescript
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

// 테스트 실행 후
expect(errorSpy).toHaveBeenCalled();
```

### console 에러 억제 헬퍼

의도적으로 에러를 발생시키는 테스트에서 사용:

```typescript
const suppressConsoleError = () => {
  vi.spyOn(console, 'error').mockImplementation(() => {});
};
```

### Mock 정리

```typescript
afterEach(() => {
  vi.restoreAllMocks();
});
```

- `vi.restoreAllMocks()`으로 모든 spy/mock을 원복
- 타이머 mock 사용 시 `vi.useRealTimers()`도 함께 호출

---

## 검증 패턴

### 존재 확인

```typescript
expect(screen.getByRole('button')).toBeInTheDocument();
expect(screen.queryByText('에러')).not.toBeInTheDocument();
```

### 속성 확인

```typescript
expect(screen.getByRole('textbox')).toBeDisabled();
expect(screen.getByRole('textbox')).toHaveAttribute('readonly');
expect(screen.getByRole('textbox')).toHaveValue('초기값');
```

### 함수 호출 확인

```typescript
expect(handleClick).toHaveBeenCalledOnce();
expect(handleClick).toHaveBeenCalledWith(expectedArg);
expect(handleClick).not.toHaveBeenCalled();
```

---

## 실행 스크립트

```bash
# 전체 테스트 실행
pnpm test

# 워치 모드 (로컬 개발)
pnpm test:watch

# 커버리지 리포트
pnpm test:coverage

# 특정 패키지만 실행
pnpm --filter <package-name> test
```

---

## E2E 테스트 (Playwright)

### 파일 위치

E2E 테스트는 Next.js 프로젝트 루트의 `e2e/` 디렉토리에 위치한다. 페이지/플로우 단위로 폴더를 구성한다.

```
프로젝트 루트/
  e2e/
    playwright.config.ts
    contracts/
      contracts-list.spec.ts
      contracts-create.spec.ts
    auth/
      login.spec.ts
      logout.spec.ts
```

### 기본 패턴

```typescript
import { test, expect } from '@playwright/test';

test.describe('계약 목록', () => {
  test('계약 목록이 정상적으로 표시된다', async ({ page }) => {
    await page.goto('/contracts');

    await expect(page.getByRole('heading', { name: '계약 목록' })).toBeVisible();
    await expect(page.getByRole('table')).toBeVisible();
  });

  test('필터 적용 시 목록이 갱신된다', async ({ page }) => {
    await page.goto('/contracts');

    await page.getByRole('combobox', { name: '상태' }).selectOption('ACTIVE');

    await expect(page.getByRole('row')).toHaveCount(5);
  });
});
```

### 네이밍 규칙

- 파일: `{기능명}.spec.ts` (kebab-case)
- `test.describe`: 페이지/기능 단위
- `test`: 사용자 시나리오를 명확히 서술 (한국어 가능)

### Locator 우선순위

단위 테스트와 동일하게 접근성 기반 locator를 우선 사용한다.

```typescript
// ✅ 접근성 기반
page.getByRole('button', { name: '저장' })
page.getByLabel('이메일')
page.getByText('계약이 등록되었습니다')

// ⚠️ 최후 수단
page.getByTestId('contract-table')
```

### 실행 스크립트

```bash
# E2E 전체 실행
pnpm e2e

# 특정 파일 실행
pnpm e2e e2e/contracts/contracts-list.spec.ts

# UI 모드 (디버깅)
pnpm e2e --ui

# headed 모드 (브라우저 표시)
pnpm e2e --headed
```

---

## 금지 패턴

- 스냅샷 테스트(`toMatchSnapshot`) 남용 — 의미 있는 assertion 작성
- 구현 세부사항 테스트 (내부 state 직접 검증) — 사용자 관점에서 검증
- `getByTestId` 우선 사용 — 접근성 쿼리 우선
- `fireEvent` 사용 — `userEvent` 사용
- mock 정리 누락 — `afterEach`에서 `vi.restoreAllMocks()` 필수
- `*.view.tsx` 단독 테스트 — container 단위로 테스트
