
검색창과 상태 선택이 각각 동작해도 두 조건을 함께 적용하면 오류가 날 수 있습니다. 앞의 검색 필터 예제를 대상으로 교집합, 빈 결과, 검색어 지우기와 원본 보존을 테스트합니다.
테스트 대상과 준비
React·TypeScript 검색 필터 실습의 App.tsx, App.css, filterProjects.ts를 대상으로 합니다. 같은 코드를 사용해야 아래 기대값과 역할 이름이 맞습니다.
이미 테스트 환경이 있다면 프로젝트의 도구를 유지하세요. 아래는 Vitest와 React Testing Library를 사용해 이 예제를 따로 실습하는 구성입니다. Jest 설정에 Vitest 문법을 그대로 섞지 않습니다.
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom
프로젝트 루트의 vitest.config.ts에는 다음을 둡니다. 기존 Vite 설정을 쓰고 있다면 중복 파일을 무조건 추가하지 말고 기존 설정에 test 옵션을 합칩니다.
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
전체 코드
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>테스트 실습</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
package.json
{
"name": "testing-practice-7809",
"private": true,
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite",
"test": "vitest",
"test:run": "vitest run",
"typecheck": "tsc --noEmit",
"build": "tsc --noEmit && vite build"
},
"devDependencies": {
"vite": "8.3.0",
"vitest": "4.1.11",
"typescript": "7.0.2",
"react": "19.3.0",
"react-dom": "19.3.0",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"jsdom": "30.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.7",
"@testing-library/jest-dom": "7.0.1"
}
}
src/App.css
* {
box-sizing: border-box;
}
body {
margin: 0;
color: #222;
background: #fff;
font-family: sans-serif;
}
.project-page {
width: min(100% - 32px, 960px);
margin: 40px auto;
}
.filters {
display: grid;
grid-template-columns: auto 1fr auto 160px;
gap: 12px;
align-items: center;
}
input,
select {
width: 100%;
min-width: 0;
min-height: 44px;
padding: 10px;
border: 1px solid #777;
font: inherit;
}
.table-scroll {
overflow-x: auto;
}
table {
width: 100%;
min-width: 540px;
border-collapse: collapse;
}
caption {
text-align: left;
padding: 12px 0;
}
th,
td {
padding: 12px;
border: 1px solid #aaa;
text-align: left;
}
th {
background: #f3f3f3;
}
input:focus-visible,
select:focus-visible,
.table-scroll:focus-visible {
outline: 2px solid #222;
outline-offset: 2px;
}
@media (max-width: 640px) {
.filters {
grid-template-columns: 1fr;
}
}
src/App.test.tsx
import { describe, expect, it } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App, { projects } from './App';
import { filterProjects } from './filterProjects';
describe('검색과 상태 필터', () => {
it('처음에는 전체 프로젝트 세 개를 보여준다', () => {
render(<App />);
expect(screen.getByRole('status')).toHaveTextContent('검색 결과 3개');
expect(screen.getAllByRole('row')).toHaveLength(4);
});
it('검색어의 앞뒤 공백과 대소문자를 정리한다', async () => {
const user = userEvent.setup();
render(<App />);
await user.type(screen.getByRole('searchbox', { name: '검색어' }), ' REACT ');
expect(screen.getByRole('status')).toHaveTextContent('검색 결과 2개');
expect(screen.queryByRole('cell', { name: '사진 모음' })).not.toBeInTheDocument();
});
it('검색과 상태 조건을 동시에 만족하는 항목만 보여준다', async () => {
const user = userEvent.setup();
render(<App />);
await user.type(screen.getByRole('searchbox', { name: '검색어' }), 'react');
await user.selectOptions(
screen.getByRole('combobox', { name: '진행 상태' }),
'done',
);
const table = screen.getByRole('table', { name: '검색 조건에 맞는 프로젝트' });
expect(within(table).getByRole('cell', { name: '초대장' })).toBeInTheDocument();
expect(
within(table).queryByRole('cell', { name: '작업 기록' }),
).not.toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('검색 결과 1개');
});
it('빈 결과를 알리고 검색어를 지우면 상태 필터는 유지한다', async () => {
const user = userEvent.setup();
render(<App />);
const search = screen.getByRole('searchbox', { name: '검색어' });
const status = screen.getByRole('combobox', { name: '진행 상태' });
await user.type(search, 'react');
await user.selectOptions(status, 'planning');
expect(screen.getByText('조건에 맞는 프로젝트가 없습니다.')).toBeInTheDocument();
expect(screen.getByRole('status')).toHaveTextContent('검색 결과 0개');
await user.clear(search);
expect(status).toHaveValue('planning');
expect(screen.getByRole('cell', { name: '사진 모음' })).toBeInTheDocument();
expect(
screen.queryByText('조건에 맞는 프로젝트가 없습니다.'),
).not.toBeInTheDocument();
});
it('설명과 기술도 검색하며 원본 데이터를 변경하지 않는다', () => {
const before = structuredClone(projects);
expect(filterProjects(projects, '업무 시간', 'all').map((p) => p.id)).toEqual([2]);
expect(filterProjects(projects, 'typescript', 'all').map((p) => p.id)).toEqual([1]);
expect(filterProjects(projects, '', 'all')).toHaveLength(3);
expect(projects).toEqual(before);
});
});
src/App.tsx
import { useState } from 'react';
import { filterProjects } from './filterProjects';
import type { Project, StatusFilter } from './filterProjects';
import './App.css';
export const projects: Project[] = [
{
id: 1,
name: '초대장',
description: '행사 안내',
status: 'done',
stack: ['React', 'TypeScript'],
},
{
id: 2,
name: '작업 기록',
description: '업무 시간 기록',
status: 'in-progress',
stack: ['React'],
},
{
id: 3,
name: '사진 모음',
description: '여행 사진 정리',
status: 'planning',
stack: ['JavaScript'],
},
];
const statusLabels = {
planning: '기획',
'in-progress': '진행 중',
done: '완료',
};
function isStatusFilter(value: string): value is StatusFilter {
return (
value === 'all' ||
value === 'planning' ||
value === 'in-progress' ||
value === 'done'
);
}
export default function App() {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const filteredProjects = filterProjects(projects, searchTerm, statusFilter);
return (
<main className="project-page">
<h1>프로젝트 목록</h1>
<div className="filters">
<label htmlFor="project-search">검색어</label>
<input
id="project-search"
type="search"
value={searchTerm}
placeholder="이름, 설명, 기술 검색"
onChange={(event) => setSearchTerm(event.target.value)}
/>
<label htmlFor="project-status">진행 상태</label>
<select
id="project-status"
value={statusFilter}
onChange={(event) => {
const value = event.target.value;
if (isStatusFilter(value)) setStatusFilter(value);
}}
>
<option value="all">전체</option>
<option value="planning">기획</option>
<option value="in-progress">진행 중</option>
<option value="done">완료</option>
</select>
</div>
<p role="status" aria-live="polite">
검색 결과 {filteredProjects.length}개
</p>
<div
className="table-scroll"
role="region"
aria-label="프로젝트 결과 표"
tabIndex={0}
>
<table>
<caption>검색 조건에 맞는 프로젝트</caption>
<thead>
<tr>
<th scope="col">이름</th>
<th scope="col">설명</th>
<th scope="col">상태</th>
<th scope="col">기술</th>
</tr>
</thead>
<tbody>
{filteredProjects.length === 0 ? (
<tr>
<td colSpan={4}>조건에 맞는 프로젝트가 없습니다.</td>
</tr>
) : (
filteredProjects.map((project) => (
<tr key={project.id}>
<td>{project.name}</td>
<td>{project.description}</td>
<td>{statusLabels[project.status]}</td>
<td>{project.stack.join(', ')}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</main>
);
}
src/filterProjects.ts
export type ProjectStatus = 'planning' | 'in-progress' | 'done';
export type StatusFilter = 'all' | ProjectStatus;
export type Project = {
id: number;
name: string;
description: string;
status: ProjectStatus;
stack: string[];
};
export function filterProjects(
projects: readonly Project[],
searchTerm: string,
statusFilter: StatusFilter,
): Project[] {
const keyword = searchTerm.trim().toLowerCase();
return projects.filter((project) => {
const matchesKeyword = [project.name, project.description, ...project.stack].some(
(value) => value.toLowerCase().includes(keyword),
);
const matchesStatus = statusFilter === 'all' || project.status === statusFilter;
return matchesKeyword && matchesStatus;
});
}
src/index.css
body {
margin: 0;
font-family: sans-serif;
color: #222;
background: #fff;
}
main {
max-width: 36rem;
margin: 3rem auto;
padding: 1rem;
}
form {
display: grid;
gap: 0.75rem;
}
input,
button {
font: inherit;
padding: 0.6rem;
}
src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => cleanup());
src/vite-env.d.ts
/// <reference types="vite/client" />
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "vite.config.ts"]
}
vite.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
},
});
src/test/setup.ts입니다. cleanup을 이곳 한 군데에서 등록합니다.
React·TypeScript 프로젝트의 tsconfig에는 JSX 변환 설정이 있어야 합니다. 이 예제는 jsx: “react-jsx”를 사용합니다. ZIP에서는 원본 App.tsx, App.css, filterProjects.ts를 내용 변경 없이 src에 복사하고 App.test.tsx를 같은 폴더에 둡니다. 경로만 src로 옮겼으므로 React 구현 글의 데이터·label·역할·필터 계약이 같습니다.
화면 동작을 테스트 항목으로 바꾸기
| 사용자 동작 | 기대 결과 | 놓치기 쉬운 오류 |
|---|---|---|
| 첫 화면 열기 | 3개 항목 | 초기 필터가 잘못 적용됨 |
| 공백 포함 REACT 입력 | 2개 항목 | 공백·대소문자 처리 누락 |
| React 검색 후 완료 선택 | 초대장 1개 | AND 대신 OR 조건 |
| React 검색 후 기획 선택 | 0개 안내 | 이전 결과가 화면에 남음 |
| 검색어 지우기 | 기획 상태 유지, 사진 모음 표시 | 필터까지 초기화됨 |
| 필터 함수 호출 | 원본 배열 유지 | sort·splice 등 원본 변경 |
테스트 이름은 컴포넌트 내부 함수보다 사용자가 기대하는 결과를 설명하도록 작성합니다. 검색창은 연결된 label로 찾으므로 접근 가능한 이름도 함께 확인됩니다.
App.test.tsx 전체 코드
userEvent.setup으로 만든 user의 입력·선택 작업에는 await를 붙입니다. 존재해야 하는 요소는 getBy, 없어야 하는 요소는 queryBy로 확인합니다. 이 예제는 API 요청이 없으므로 임의의 setTimeout, fetch mock, 라우터 mock을 추가하지 않습니다.
within(table)은 검증 범위를 결과 표로 좁힙니다. 같은 프로젝트명이 다른 곳에 추가되어도 표 안의 결과를 확인할 수 있습니다. afterEach(cleanup)은 앞 테스트의 화면이 다음 테스트에 남지 않게 합니다.
초기 행 4개에는 헤더 1개와 데이터 3개가 포함됩니다. 빈 결과도 안내를 담은 행 하나가 있으므로 row 수만으로 빈 결과를 판정하지 않습니다. 결과 개수와 실제 cell의 존재를 함께 확인해야 “숫자만 바뀌고 이전 행이 남는” 버그를 구별할 수 있습니다. 원본 보존 검사는 반환 배열과 원본이 같은 참조여야 한다는 뜻이 아니라, 호출 전후 원본 데이터 내용이 같아야 한다는 뜻입니다.
실행 결과를 읽는 방법
npx vitest run src/App.test.tsx
파일을 src 아래에 둔 일반적인 프로젝트의 실행 명령입니다. 테스트 파일 위치가 다르면 경로만 맞춥니다. 하나의 테스트가 실패하면 메시지의 expected와 실제 DOM을 보고 조건 계산·label·렌더링 중 어느 부분이 달라졌는지 먼저 찾습니다.
예를 들어 필터 함수의 matchesKeyword && matchesStatus를 OR 조건으로 바꾸면 교집합과 빈 결과 테스트가 실패해야 합니다. 이처럼 실제 버그를 일부러 넣었을 때 실패하는지 확인하면 테스트가 단순히 실행만 되는지 구분할 수 있습니다.
이 테스트로 보장하지 않는 범위
jsdom에서 확인하는 것은 DOM과 사용자 상호작용입니다. 실제 모바일 화면의 너비, 가로 스크롤의 사용성, 브라우저별 렌더링, 화면 읽기 프로그램의 음성 출력까지 자동으로 보장하지 않습니다. 좁은 화면의 레이아웃은 별도 브라우저 확인이 필요합니다.
서버 검색을 추가한다면 요청 중·실패·응답 순서 경쟁도 검증 대상으로 늘어납니다. 현재 없는 기능을 테스트에 먼저 가정하지 않고 실제 구현 범위에 맞춰 확장하세요.
공식 문서와 다음 학습
독립 실습 파일과 실행 순서
아래 ZIP은 이 글만 읽어도 실행할 수 있는 완성 프로젝트입니다. 압축을 풀고 package.json이 있는 폴더에서 Node 22.12 이상을 사용합니다. 처음은 npm install, 이후 생성한 잠금 파일을 보관한 환경은 npm ci를 사용하세요. test는 감시 모드이며 test:run은 한 번 실행하고 종료합니다.
터미널
npm install
npm run test:run
npm run build
npm run dev
검증 버전은 package.json에 고정되어 있습니다. npm run dev는 독자가 화면을 직접 확인하는 명령입니다. 작성 검증은 Node/jsdom 테스트와 TypeScript·Vite 빌드까지 수행했으며 실제 브라우저의 화면·키보드·레이아웃 검증은 수행하지 않았습니다.
src/vite-env.d.ts의 /// <reference types="vite/client" />는 CSS import 타입을 연결합니다. 이 파일 없이 TypeScript 7에서 CSS 부수 효과 import 오류가 나면 테스트 환경을 바꾸는 대신 타입 선언부터 복구하세요.
검증 결과: 테스트 5개 통과, TypeScript 검사 및 Vite 프로덕션 빌드 통과. README의 오류 재현 연습은 일부러 코드를 바꾸고 실패를 확인한 뒤 복원하도록 구성했습니다.
의도적인 오류 검증도 실행했습니다. AND 조건을 OR로 바꾸자 교집합·빈 결과 검증이 실패했습니다. 변경을 복원한 완성 코드를 ZIP에 제공합니다.
설정 기준은 Vitest 환경 문서와 설정 문서에서 확인할 수 있습니다. DOM 실습의 이벤트와 matcher는 user-event 소개, jest-dom의 Vitest 연결을 따릅니다.
이 글이 도움이 되었나요?
테스트 학습 순서
필수 7개 · 전체 9개
읽음 기록 관리
전체 과정 목차 (9개)
- 필수 학습 · Vite 프로젝트에서 Vitest로 테스트 환경 시작하기
- 필수 학습 · React Testing Library로 클릭·입력 테스트 작성하기
- 선택 참고 · Vitest document is not defined 오류 해결: jsdom 설정 기준
- 필수 선수 · Jest console.error 테스트: try catch 에러 검증
- 필수 학습 · React 검색 필터 테스트: 교집합·빈 결과·입력 초기화 검증 현재 글
- 필수 학습 · TanStack Query 컴포넌트 테스트: QueryClient 격리와 실패·재시도·목록 갱신 검증
- 필수 학습 · 비동기 테스트 고급: 실패·재시도·경쟁 상태를 결정적으로 검증하기
- 필수 학습 · Playwright 첫 E2E 테스트: 로컬 앱 실행부터 결과 확인까지
- 선택 참고 · Playwright 테스트 실패 해결: locator가 요소를 찾지 못할 때 체크리스트
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.