React Testing Library로 클릭·입력 테스트 작성하기

2026.09.05·수정 2026.09.12·약 19분·작성: 해비·블로그 소개

이 글에서 배우는 내용

닉네임을 입력하고 저장하면 안내 문장이 바뀌는 React 화면을 만듭니다. React Testing Library로 실제 DOM을 찾고 user-event로 입력·클릭한 뒤, 저장 결과와 빈 입력의 제한을 Vitest에서 검증합니다.

목차

컴포넌트 내부 대신 사용자의 행동 정하기

먼저 Vite 프로젝트에서 Vitest 시작하기의 테스트 실행 흐름을 익히세요. 함수 테스트가 입력과 반환값을 확인한다면 이번 실습은 사람이 화면에서 할 수 있는 행동과 그 결과를 확인합니다. React state의 변수 이름이나 setState 호출 횟수를 검사하지 않습니다.

시작 상태 행동 기대 결과
빈 닉네임 화면 열기 저장 버튼 비활성화
닉네임 입력 가능 앞뒤 공백을 포함한 이름 입력 후 저장 공백을 제거한 이름으로 저장 안내
미저장 상태에서 입력 가능 공백만 입력하거나 모두 지우기 저장 불가, 기존 미저장 안내 유지

이 표가 테스트의 요구사항입니다. 테스트 코드가 화면 구현을 그대로 복사하지 않도록 먼저 사용자 입장에서 문장을 정합니다. CSS class 이름을 바꾸어도 같은 label과 button이 동작한다면 테스트는 통과하는 편이 좋습니다.

사용자의 입력과 클릭을 테스트 동작으로 표현합니다.
사용자의 입력과 클릭을 테스트 동작으로 표현합니다.

Vite·Vitest와 DOM 환경 준비하기

이미 Vite React TypeScript 프로젝트가 있다면 그 프로젝트에서 개발 의존성 설치부터 진행하세요. 새 실습은 다음 명령으로 시작합니다. 생성기가 만든 TypeScript 설정과 package.json은 유지하고, 뒤에 명시한 파일만 교체하거나 추가합니다.

터미널

npm create vite@latest ui-behavior -- --template react-ts
cd ui-behavior
npm install
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event
npm pkg set "scripts.test:run=vitest run"

Vite 공식 시작 문서에서 지원하는 Node.js를 사용하세요. 이번 검증은 Node 24.19.0, Vite 8.3.0, React 19.3.0, Vitest 4.1.11, React Testing Library 16.3.3, user-event 14.6.7, jsdom 30.0.1로 수행했습니다. ZIP은 package.json에 검증 버전을 고정합니다. 첫 npm install이 만드는 package-lock.json을 보관하며 기존 프로젝트에서는 호환성을 확인한 뒤 설치합니다.

vite.config.ts

실습 파일

파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 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-8870",
  "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.test.tsx

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test } from 'vitest';
import App from './App';

test('입력 후 저장하면 공백을 제거한 닉네임이 표시된다', async () => {
  const user = userEvent.setup();
  render(<App />);
  const input = screen.getByRole('textbox', { name: '닉네임' });
  const save = screen.getByRole('button', { name: '저장' });

  expect(save).toBeDisabled();
  expect(screen.getByRole('status')).toHaveTextContent('아직 저장하지 않았습니다.');
  await user.type(input, '  해비  ');
  expect(input).toHaveValue('  해비  ');
  expect(save).toBeEnabled();
  await user.click(save);
  expect(screen.getByRole('status')).toHaveTextContent('해비님, 저장했습니다.');
});

test('공백만 입력하거나 입력을 지우면 저장할 수 없다', async () => {
  const user = userEvent.setup();
  render(<App />);
  const input = screen.getByRole('textbox', { name: '닉네임' });
  const save = screen.getByRole('button', { name: '저장' });

  await user.type(input, '   ');
  expect(save).toBeDisabled();
  await user.click(save);
  expect(screen.getByRole('status')).toHaveTextContent('아직 저장하지 않았습니다.');
  await user.clear(input);
  await user.type(input, '퍼블리셔');
  expect(save).toBeEnabled();
  await user.clear(input);
  expect(input).toHaveValue('');
  expect(save).toBeDisabled();
});

src/App.tsx

import { useState } from 'react';
import type { FormEvent } from 'react';

export default function App() {
  const [nickname, setNickname] = useState('');
  const [savedName, setSavedName] = useState('');
  const trimmedName = nickname.trim();

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (!trimmedName) return;
    setSavedName(trimmedName);
  }

  return (
    <main>
      <h1>학습 프로필</h1>
      <form onSubmit={handleSubmit}>
        <label htmlFor="nickname">닉네임</label>
        <input
          id="nickname"
          value={nickname}
          onChange={(event) => setNickname(event.target.value)}
        />
        <button type="submit" disabled={!trimmedName}>
          저장
        </button>
      </form>
      <p role="status">
        {savedName ? `${savedName}님, 저장했습니다.` : '아직 저장하지 않았습니다.'}
      </p>
    </main>
  );
}

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

vite.config.ts 전체 코드 보기

test.environment가 jsdom이면 Node 안에 DOM 환경을 준비합니다. include는 src의 컴포넌트 테스트만 수집하므로 나중에 e2e 폴더를 추가해도 Playwright 테스트를 Vitest가 실행하지 않습니다. ZIP은 tsconfig의 jsx: react-jsx로 JSX를 변환합니다. 기존 React 플러그인이 있는 프로젝트라면 플러그인을 유지하고 test 설정만 합치세요.

src/test/setup.ts

src/test/setup.ts 전체 코드 보기

jest-dom/vitest import는 toHaveValue와 toBeDisabled 같은 DOM matcher를 Vitest에 연결합니다. 여기서는 test·expect·afterEach를 명시적으로 import하므로 globals 설정에 기대지 않습니다. Testing Library cleanup 안내처럼 afterEach에서 cleanup을 호출해 이전 테스트의 화면이 다음 테스트에 남지 않게 합니다.

닉네임 입력·저장 화면 만들기

src/App.tsx는 입력 중인 nickname과 저장한 savedName을 분리합니다. 제출 시 기본 페이지 이동을 막고, trim 결과가 비어 있으면 저장하지 않습니다. 서버나 저장소를 사용하지 않으므로 새로고침하면 초기 상태로 돌아오는 메모리 실습입니다.

src/App.tsx

src/App.tsx 전체 코드 보기

label의 htmlFor와 input의 id가 같으므로 입력 필드의 접근성 이름은 “닉네임”입니다. button은 텍스트 “저장”으로 찾고 안내 문장은 role=”status”로 찾습니다. 이러한 연결은 테스트뿐 아니라 화면을 이해하고 조작하는 데도 쓰입니다. disabled 조건과 제출 함수의 빈 값 검사 둘 다 있어 버튼을 통한 입력과 제출 로직을 일관되게 제한합니다.

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

main.tsx는 App을 실제 페이지에 연결합니다. 테스트는 이 진입점 대신 App을 직접 render하지만, 브라우저에서 확인할 때는 같은 App이 실행됩니다.

src/index.css

src/index.css 전체 코드 보기

CSS는 입력과 버튼을 읽기 쉽게 배치하는 용도입니다. 테스트의 선택 조건에 스타일 class나 색상을 넣지 않습니다. npm run dev로 열면 입력 전 저장 버튼이 비활성화되고, 이름을 입력해 저장했을 때 안내가 바뀌어야 합니다.

입력과 클릭을 두 테스트로 검증하기

다음 두 테스트는 같은 App을 새로 렌더링합니다. user-event 공식 안내처럼 각 테스트 안에서 setup을 만들고 입력·클릭 메서드를 await합니다. 행동이 끝나기 전에 결과를 검사하면 실제 문제가 아닌 실행 순서 때문에 실패할 수 있습니다.

src/App.test.tsx

src/App.test.tsx 전체 코드 보기

첫 테스트는 입력값에는 공백이 남아도 저장 결과에서는 제거되는 요구사항을 검증합니다. 두 번째는 비활성 버튼 클릭이 저장을 일으키지 않는지 확인하고 clear로 입력을 지운 뒤 다시 비활성화되는지 검사합니다. 모든 테스트가 초기 DOM에서 시작하므로 실행 순서에 의존하지 않습니다.

화면에 나타나는 결과를 기준으로 검증합니다.
화면에 나타나는 결과를 기준으로 검증합니다.

쿼리 공식 문서에서 getBy는 현재 존재하는 요소를 찾고, queryBy는 없는 상태를 검사할 때, findBy는 나중에 나타나는 요소를 기다릴 때 사용합니다. 이 화면은 await user.click 뒤 즉시 반영되는 로컬 state이므로 getByRole로 결과를 읽습니다. API 응답 후 새 안내가 나타나는 화면으로 바뀌면 findByRole 또는 적절한 비동기 검증을 사용해야 합니다.

실행 결과와 테스트 경계 읽기

터미널

npm run test:run
npm run build

작성 검증에서는 테스트 파일 1개, 테스트 2개가 모두 통과했고 TypeScript 검사와 Vite 프로덕션 빌드도 통과했습니다. 기대 결과는 Test Files 1 passed와 Tests 2 passed입니다. 단순히 테스트를 발견했다는 목록 출력은 실제 통과 결과가 아닙니다.

연습으로 저장 안내의 이름을 고정된 다른 문자열로 바꾸고 첫 테스트가 실패하는지 확인한 뒤 원래 코드로 복원해 보세요. 테스트가 계속 통과한다면 행동의 결과를 충분히 검사하고 있는지 확인해야 합니다. 실패 메시지에서 기대 문자열과 실제 DOM을 먼저 비교하고 무작정 matcher를 약하게 만들지 마세요.

document is not defined는 jsdom 설정 오류 해결을, matcher를 찾지 못하는 오류는 jest-dom의 Vitest 연동 안내를 확인하세요. jsdom 테스트 통과는 실제 브라우저의 레이아웃·모바일 키보드·네트워크·화면 읽기 도구의 동작까지 보장하지 않습니다. 컴포넌트 행동을 먼저 좁게 확인한 뒤 Playwright로 페이지 실행 경로를 더합니다.

독립 실습 파일과 실행 순서

아래 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 오류가 나면 테스트 환경을 바꾸는 대신 타입 선언부터 복구하세요.

검증 결과: 테스트 2개 통과, TypeScript 검사 및 Vite 프로덕션 빌드 통과. README의 오류 재현 연습은 일부러 코드를 바꾸고 실패를 확인한 뒤 복원하도록 구성했습니다.

의도적인 오류 검증도 실행했습니다. trim을 제거하자 공백 입력과 저장 결과 테스트가 실패했습니다. 변경을 복원한 완성 코드를 ZIP에 제공합니다.

설정 기준은 Vitest 환경 문서설정 문서에서 확인할 수 있습니다. DOM 실습의 이벤트와 matcher는 user-event 소개, jest-dom의 Vitest 연결을 따릅니다.

실습 프로젝트 ZIP 내려받기

이 글이 도움이 되었나요?

조회 중

테스트 학습 순서

필수 7개 · 전체 9개

읽음 기록 관리

전체 과정 목차 (9개)
  1. 필수 학습 · Vite 프로젝트에서 Vitest로 테스트 환경 시작하기
  2. 필수 학습 · React Testing Library로 클릭·입력 테스트 작성하기 현재 글
  3. 선택 참고 · Vitest document is not defined 오류 해결: jsdom 설정 기준
  4. 필수 선수 · Jest console.error 테스트: try catch 에러 검증
  5. 필수 학습 · React 검색 필터 테스트: 교집합·빈 결과·입력 초기화 검증
  6. 필수 학습 · TanStack Query 컴포넌트 테스트: QueryClient 격리와 실패·재시도·목록 갱신 검증
  7. 필수 학습 · 비동기 테스트 고급: 실패·재시도·경쟁 상태를 결정적으로 검증하기
  8. 필수 학습 · Playwright 첫 E2E 테스트: 로컬 앱 실행부터 결과 확인까지
  9. 선택 참고 · Playwright 테스트 실패 해결: locator가 요소를 찾지 못할 때 체크리스트

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기