메인 콘텐츠로 건너뛰기

테스팅 가이드

Dropup을 사용하는 컴포넌트를 테스트하는 방법입니다.

설정

테스팅 의존성 설치:

npm install -D vitest @testing-library/react @testing-library/user-event jsdom

Vitest 설정:

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
});
// src/test/setup.ts
import '@testing-library/jest-dom';

기본 컴포넌트 테스트

// components/Uploader.tsx
import { useDropup } from '@samithahansaka/dropup';

export function Uploader() {
const { files, getDropProps, getInputProps } = useDropup({
accept: 'image/*',
});

return (
<div {...getDropProps()} data-testid="dropzone">
<input {...getInputProps()} data-testid="file-input" />
<p>이미지를 여기에 드롭</p>

<ul data-testid="file-list">
{files.map(file => (
<li key={file.id} data-testid="file-item">
{file.name}
</li>
))}
</ul>
</div>
);
}
// components/Uploader.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { Uploader } from './Uploader';

describe('Uploader', () => {
it('드롭존을 렌더링합니다', () => {
render(<Uploader />);
expect(screen.getByTestId('dropzone')).toBeInTheDocument();
expect(screen.getByText('이미지를 여기에 드롭')).toBeInTheDocument();
});

it('파일 입력을 받습니다', async () => {
const user = userEvent.setup();
render(<Uploader />);

const input = screen.getByTestId('file-input');
const file = new File(['test'], 'test.png', { type: 'image/png' });

await user.upload(input, file);

expect(screen.getByTestId('file-item')).toHaveTextContent('test.png');
});

it('여러 파일을 받습니다', async () => {
const user = userEvent.setup();
render(<Uploader />);

const input = screen.getByTestId('file-input');
const files = [
new File(['test1'], 'photo1.png', { type: 'image/png' }),
new File(['test2'], 'photo2.png', { type: 'image/png' }),
];

await user.upload(input, files);

const items = screen.getAllByTestId('file-item');
expect(items).toHaveLength(2);
});
});

드래그 앤 드롭 테스트

import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { Uploader } from './Uploader';

describe('드래그 앤 드롭', () => {
it('드래그할 때 드래그 상태를 표시합니다', () => {
render(<Uploader />);

const dropzone = screen.getByTestId('dropzone');

fireEvent.dragEnter(dropzone, {
dataTransfer: {
types: ['Files'],
items: [{ kind: 'file' }],
},
});

expect(dropzone).toHaveAttribute('data-dragging', 'true');
});

it('파일 드롭을 처리합니다', () => {
render(<Uploader />);

const dropzone = screen.getByTestId('dropzone');
const file = new File(['test'], 'dropped.png', { type: 'image/png' });

fireEvent.drop(dropzone, {
dataTransfer: {
files: [file],
types: ['Files'],
},
});

expect(screen.getByText('dropped.png')).toBeInTheDocument();
});
});

유효성 검사 테스트

// components/ValidatedUploader.tsx
import { useDropup } from '@samithahansaka/dropup';
import { useState } from 'react';

export function ValidatedUploader() {
const [errors, setErrors] = useState<string[]>([]);

const { files, getDropProps, getInputProps } = useDropup({
accept: 'image/*',
maxSize: 1024 * 1024, // 1MB

onValidationError: (validationErrors) => {
const messages = validationErrors.flatMap(e => e.errors);
setErrors(messages);
},
});

return (
<div {...getDropProps()} data-testid="dropzone">
<input {...getInputProps()} data-testid="file-input" />

{errors.length > 0 && (
<ul data-testid="error-list">
{errors.map((error, i) => (
<li key={i} data-testid="error-item">{error}</li>
))}
</ul>
)}
</div>
);
}
// components/ValidatedUploader.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { ValidatedUploader } from './ValidatedUploader';

describe('유효성 검사', () => {
it('잘못된 파일 타입을 거부합니다', async () => {
const user = userEvent.setup();
render(<ValidatedUploader />);

const input = screen.getByTestId('file-input');
const file = new File(['test'], 'document.pdf', { type: 'application/pdf' });

await user.upload(input, file);

expect(screen.getByTestId('error-list')).toBeInTheDocument();
});

it('크기 제한을 초과하는 파일을 거부합니다', async () => {
const user = userEvent.setup();
render(<ValidatedUploader />);

const input = screen.getByTestId('file-input');
// 2MB 파일 생성 (1MB 제한 초과)
const content = new Array(2 * 1024 * 1024).fill('a').join('');
const file = new File([content], 'large.png', { type: 'image/png' });

await user.upload(input, file);

expect(screen.getByTestId('error-list')).toBeInTheDocument();
});
});

업로드 테스트

// components/UploadingComponent.tsx
import { useDropup } from '@samithahansaka/dropup';

export function UploadingComponent() {
const { files, actions, state, getDropProps, getInputProps } = useDropup({
upload: { url: '/api/upload' },
});

return (
<div {...getDropProps()} data-testid="dropzone">
<input {...getInputProps()} data-testid="file-input" />

{files.map(file => (
<div key={file.id} data-testid="file-status">
{file.status === 'uploading' && `업로드 중: ${file.progress}%`}
{file.status === 'complete' && '완료!'}
{file.status === 'error' && `오류: ${file.error?.message}`}
</div>
))}

<button
onClick={() => actions.upload()}
disabled={state.isUploading}
data-testid="upload-btn"
>
업로드
</button>
</div>
);
}
// components/UploadingComponent.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UploadingComponent } from './UploadingComponent';

// fetch 모킹
const mockFetch = vi.fn();
global.fetch = mockFetch;

describe('업로드', () => {
beforeEach(() => {
mockFetch.mockReset();
});

it('파일을 성공적으로 업로드합니다', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ url: 'https://example.com/file.png' }),
});

const user = userEvent.setup();
render(<UploadingComponent />);

// 파일 추가
const input = screen.getByTestId('file-input');
const file = new File(['test'], 'test.png', { type: 'image/png' });
await user.upload(input, file);

// 업로드 클릭
await user.click(screen.getByTestId('upload-btn'));

// 완료 대기
await waitFor(() => {
expect(screen.getByTestId('file-status')).toHaveTextContent('완료!');
});

expect(mockFetch).toHaveBeenCalledWith('/api/upload', expect.anything());
});

it('업로드 오류를 처리합니다', async () => {
mockFetch.mockRejectedValue(new Error('네트워크 오류'));

const user = userEvent.setup();
render(<UploadingComponent />);

const input = screen.getByTestId('file-input');
await user.upload(input, new File(['test'], 'test.png', { type: 'image/png' }));

await user.click(screen.getByTestId('upload-btn'));

await waitFor(() => {
expect(screen.getByTestId('file-status')).toHaveTextContent('오류');
});
});
});

useDropup 모킹

독립적인 단위 테스트를 위해:

// __mocks__/@samithahansaka/dropup.ts
import { vi } from 'vitest';

export const useDropup = vi.fn(() => ({
files: [],
state: {
isDragging: false,
isDragAccept: false,
isDragReject: false,
isUploading: false,
progress: 0,
status: 'idle',
},
actions: {
upload: vi.fn(),
cancel: vi.fn(),
remove: vi.fn(),
reset: vi.fn(),
retry: vi.fn(),
addFiles: vi.fn(),
updateFileMeta: vi.fn(),
},
getDropProps: vi.fn(() => ({})),
getInputProps: vi.fn(() => ({ type: 'file' })),
openFileDialog: vi.fn(),
}));
// 테스트에서 사용
import { vi } from 'vitest';
import { useDropup } from '@samithahansaka/dropup';

vi.mock('@samithahansaka/dropup');

const mockUseDropup = vi.mocked(useDropup);

describe('모킹된 훅 사용', () => {
it('파일과 함께 렌더링합니다', () => {
mockUseDropup.mockReturnValue({
files: [
{ id: '1', name: 'test.png', status: 'idle', progress: 0, size: 1000, type: 'image/png' },
],
// ... 다른 값들
});

render(<Uploader />);
expect(screen.getByText('test.png')).toBeInTheDocument();
});
});

MSW로 테스트하기

실제적인 API 테스트를 위해 Mock Service Worker 사용:

// src/test/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
http.post('/api/upload', async ({ request }) => {
const formData = await request.formData();
const file = formData.get('file') as File;

return HttpResponse.json({
url: `https://example.com/uploads/${file.name}`,
});
}),

http.post('/api/upload-error', () => {
return HttpResponse.json(
{ error: '업로드 실패' },
{ status: 500 }
);
}),
];
// src/test/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Playwright를 사용한 E2E 테스트

// e2e/upload.spec.ts
import { test, expect } from '@playwright/test';

test.describe('파일 업로드', () => {
test('파일을 업로드합니다', async ({ page }) => {
await page.goto('/upload');

// 파일 입력 가져오기
const fileInput = page.locator('input[type="file"]');

// 파일 업로드
await fileInput.setInputFiles({
name: 'test.png',
mimeType: 'image/png',
buffer: Buffer.from('가짜 이미지 콘텐츠'),
});

// 파일이 목록에 나타나는지 확인
await expect(page.locator('text=test.png')).toBeVisible();

// 업로드 버튼 클릭
await page.click('button:has-text("업로드")');

// 성공 대기
await expect(page.locator('text=완료')).toBeVisible();
});

test('드래그 앤 드롭 업로드', async ({ page }) => {
await page.goto('/upload');

const dropzone = page.locator('[data-testid="dropzone"]');

// 파일 데이터 생성
const buffer = Buffer.from('테스트 콘텐츠');

// 드래그 앤 드롭 시뮬레이션
const dataTransfer = await page.evaluateHandle(() => new DataTransfer());

await page.dispatchEvent('[data-testid="dropzone"]', 'drop', {
dataTransfer,
});

// 파일이 추가되었는지 확인
await expect(page.locator('[data-testid="file-item"]')).toBeVisible();
});
});

모범 사례

  1. 구현이 아닌 동작을 테스트하세요 - 사용자가 보고 하는 것에 집중하세요
  2. 신뢰성을 위해 data-testid를 사용하세요 - CSS 클래스로 테스트하지 마세요
  3. 네트워크 요청을 모킹하세요 - API 호출에 MSW 또는 vi.mock 사용
  4. 엣지 케이스를 테스트하세요 - 빈 상태, 오류, 큰 파일
  5. 접근성을 테스트하세요 - 키보드 탐색, 스크린 리더