컴포넌트 테스트: 실제 브라우저 없이 사용자 행동을 어디까지 검증할 수 있는가
저장 함수만 직접 호출하는 테스트가 놓치는 버튼 연결 실패를 재현하고, jsdom에서 실제 React 컴포넌트의 입력·제출·비동기 상태 전이를 사용자 관점으로 검증한다.
목차
표준·구현·측정·해석 표시는 무엇인가요?
- 표준웹 표준이나 언어 명세가 정한 동작
- 구현특정 기술이나 브라우저가 실제로 구현한 동작
- 측정명시한 환경에서 직접 실행해 관찰한 결과
- 해석앞선 근거에서 도출한 설계 판단
- 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용
30초 요약
컴포넌트 테스트는 저장 함수 하나를 따로 호출하는 대신 실제 React 컴포넌트를 렌더링하고, 사용자가 찾는 입력과 버튼을 조작해 화면 상태와 컴포넌트가 props로 전달받은 저장 함수의 호출 결과를 확인한다. jsdom에서는 React 상태, DOM(Document Object Model, 브라우저가 문서를 객체 구조로 표현한 것) 요소, 폼 제출과 비동기 완료를 빠르게 검증할 수 있다. 실제 시각 배치, 브라우저 이동과 운영 앱 전체 흐름은 이 테스트가 증명하지 않는다.
이 글을 관통하는 상황: 저장 함수 테스트는 통과했는데 버튼이 작동하지 않는다
프로필 이름을 바꾸는 폼이 있다. 사용자는 이름을 입력하고 저장을 누른다. 저장 중에는 입력과 버튼이
비활성화되어 중복 요청을 막고, 성공하면 저장 완료, 실패하면 초안을 유지한 채 저장 실패를 보여야
한다.
다음 컴포넌트는 제출 처리 함수를 만들었지만 버튼을 제출 동작에 연결하지 않았다.
import { useState, type FormEvent } from 'react'
export type SaveName = (name: string) => Promise<void>
type Props = {
initialName: string
saveName: SaveName
}
export default function ProfileNameFormBad({ initialName, saveName }: Props) {
const [name, setName] = useState(initialName)
const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle')
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setStatus('saving')
await saveName(name.trim())
setStatus('saved')
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="profile-name">이름</label>
<input
id="profile-name"
value={name}
onChange={(event) => setName(event.target.value)}
/>
<button type="button">저장</button>
{status === 'saving' && <p>저장 중</p>}
{status === 'saved' && <p>저장 완료</p>}
</form>
)
}함수만 직접 호출하면 끊어진 UI를 보지 못한다
다음 검사는 통과하지만 컴포넌트를 한 번도 렌더링하지 않는다. saveName은 운영 서버 대신 호출과 결과를
통제하는 테스트 대역이다. vi.fn은 Vitest가 제공하는 기록 가능한 대체 함수다.
import { expect, test, vi } from 'vitest'
import type { SaveName } from './profile-name-form-bad'
test('새 이름을 저장할 수 있다', async () => {
const saveName = vi.fn<SaveName>().mockResolvedValue(undefined)
await saveName('새 이름')
expect(saveName).toHaveBeenCalledWith('새 이름')
})jsdom 테스트 환경을 먼저 고정한다
20편의 TypeScript Vite 실험에 프로젝트가 검증한 버전의 테스트 도구를 추가한다. React와 Vitest는 앞 편에서 이미 설치했다고 가정한다.
pnpm add -D @testing-library/[email protected] @testing-library/[email protected]
pnpm add -D @testing-library/[email protected] @testing-library/[email protected]
pnpm add -D [email protected] [email protected]Vitest의 기본 실행 환경은 Node.js라서 document가 없다. 다음 설정은 브라우저 API 일부를 흉내 내는
jsdom을 각 테스트에 제공한다.
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
})테스트가 끝날 때 렌더한 DOM을 지우고, toBeDisabled, toBeVisible처럼 DOM 상태를 읽기 좋은 검사를
등록한다.
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})이제 버튼을 실제로 눌러 보는 최소 검사를 작성한다.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import ProfileNameFormBad, { type SaveName } from './profile-name-form-bad'
test('사용자가 저장 버튼을 누르면 현재 이름을 저장한다', async () => {
const user = userEvent.setup()
const saveName = vi.fn<SaveName>().mockResolvedValue(undefined)
render(<ProfileNameFormBad initialName="이전 이름" saveName={saveName} />)
const nameInput = screen.getByRole('textbox', { name: '이름' })
await user.clear(nameInput)
await user.type(nameInput, '새 이름')
await user.click(screen.getByRole('button', { name: '저장' }))
expect(saveName).toHaveBeenCalledWith('새 이름')
})pnpm exec vitest run src/profile-name-form.test.tsx이 검사는 saveName이 새 이름 인자로 호출되기를 기대했지만 실제 호출은 0회라고 실패한다. 화면의
저장 버튼에서 saveName까지 이어지는 경로가 실제로 끊겼다는 재현 증거다.
렌더된 DOM을 사용자처럼 찾고 조작한다
이 검사는 .form, 내부 Hook 이름이나 setStatus 호출을 모른다. 다음 공개 결과만 안다.
이름입력이 보인다.저장버튼을 누를 수 있다.- 저장 함수가 현재 입력값을 받는다.
- 저장 중에는 중복 행동이 막힌다.
- 성공·실패 결과가 화면에 보인다.
제출 계약을 고치고 상태를 화면으로 드러낸다
버튼을 type="submit"으로 연결해 클릭이 폼 제출로 이어지게 하고, 저장 중 입력·버튼 비활성화와 실패
상태를 구현한다.
import { useState, type FormEvent } from 'react'
export type SaveName = (name: string) => Promise<void>
type Props = {
initialName: string
saveName: SaveName
}
type Status = 'idle' | 'saving' | 'saved' | 'error'
export default function ProfileNameForm({ initialName, saveName }: Props) {
const [name, setName] = useState(initialName)
const [status, setStatus] = useState<Status>('idle')
const canSubmit = name.trim().length > 0 && status !== 'saving'
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!canSubmit) return
setStatus('saving')
try {
await saveName(name.trim())
setStatus('saved')
} catch {
setStatus('error')
}
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="profile-name">이름</label>
<input
id="profile-name"
value={name}
disabled={status === 'saving'}
onChange={(event) => {
setName(event.target.value)
setStatus('idle')
}}
/>
<button type="submit" disabled={!canSubmit}>
{status === 'saving' ? '저장 중' : '저장'}
</button>
{status === 'saved' && <p>저장 완료</p>}
{status === 'error' && <p>저장 실패: 다시 시도해 주세요.</p>}
</form>
)
}이 입력은 React state를 현재 값의 기준으로 두는 제어 입력이다. 테스트는 state를 직접 읽지 않고 입력의 현재 값과 그 값으로 생긴 버튼·문구 변화를 관찰한다.
비동기 상태는 중간과 끝을 나눠 검증한다
즉시 성공하는 저장 대역만 쓰면 saving 상태가 빠르게 지나간다. 원하는 시점에 완료할 수 있는 Promise를
만들어 저장 중과 완료 뒤를 나눠 본다.
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import ProfileNameForm, { type SaveName } from './profile-name-form'
function createDeferred() {
const controls: { resolve?: () => void } = {}
const promise = new Promise<void>((resolve) => {
controls.resolve = resolve
})
return {
promise,
resolve() {
if (!controls.resolve) throw new Error('resolve가 준비되지 않았습니다.')
controls.resolve()
},
}
}
test('저장 중 중복 제출을 막고 완료 상태로 전환한다', async () => {
const user = userEvent.setup()
const deferredSave = createDeferred()
const saveName = vi.fn<SaveName>(() => deferredSave.promise)
render(<ProfileNameForm initialName="이전 이름" saveName={saveName} />)
const nameInput = screen.getByRole('textbox', { name: '이름' })
await user.clear(nameInput)
await user.type(nameInput, '새 이름')
const saveButton = screen.getByRole('button', { name: '저장' })
await user.click(saveButton)
expect(saveName).toHaveBeenCalledOnce()
expect(saveName).toHaveBeenCalledWith('새 이름')
expect(nameInput).toBeDisabled()
expect(saveButton).toBeDisabled()
expect(saveButton).toHaveTextContent('저장 중')
await user.click(saveButton)
expect(saveName).toHaveBeenCalledOnce()
deferredSave.resolve()
expect(await screen.findByText('저장 완료')).toBeVisible()
expect(nameInput).toBeEnabled()
expect(screen.getByRole('button', { name: '저장' })).toBeEnabled()
})
test('저장 실패 뒤 입력값과 재시도 가능한 상태를 유지한다', async () => {
const user = userEvent.setup()
const saveName = vi.fn<SaveName>().mockRejectedValue(new Error('network'))
render(<ProfileNameForm initialName="이전 이름" saveName={saveName} />)
const nameInput = screen.getByRole('textbox', { name: '이름' })
await user.clear(nameInput)
await user.type(nameInput, '새 이름')
await user.click(screen.getByRole('button', { name: '저장' }))
expect(await screen.findByText('저장 실패: 다시 시도해 주세요.')).toBeVisible()
expect(nameInput).toHaveValue('새 이름')
expect(screen.getByRole('button', { name: '저장' })).toBeEnabled()
})act는 React 갱신을 적용한 뒤 검증하도록 묶는 도구다. React Testing Library의 render, user-event와
비동기 요소 찾기 함수를 쓰는 일반 흐름에서는 이 도구들이 제공하는 경계를 먼저 사용한다. 직접 타이머나
React 밖에서 다른 코드가 호출하도록 전달한 함수를 수동으로 실행해 act 경고가 난다면, 경고를
숨기지 말고 어떤 갱신을 기다리지
않았는지 찾는다.
pnpm exec vitest run src/profile-name-form.test.tsx최종 출력에는 테스트 파일 1개와 테스트 2개가 passed로 표시되어야 한다. 처음의 잘못된 컴포넌트와 함수만 직접 호출한 검사는 실험 뒤 제거한다.
jsdom이 증명하지 못하는 경계를 남긴다
따라서 이 글의 테스트가 증명하는 것은 다음과 같다.
- React 컴포넌트가 label, input, button과 상태 문구를 DOM에 만든다.
- 사용자의 입력·클릭이 제출 처리와 저장 대역까지 이어진다.
- 저장 중 비활성화와 성공·실패 상태 전이가 DOM에 반영된다.
반대로 다음은 아직 증명하지 않았다.
- 스타일 규칙으로 버튼과 오류 문구가 실제 화면에 올바르게 배치되는가?
- 브라우저의 실제 페이지 이동과 네트워크 요청이 운영 서버까지 이어지는가?
- Chrome, Safari와 Firefox에서 같은가?
- 배포 빌드의 로그인부터 프로필 저장까지 전체 흐름이 이어지는가?
이 경계는 22편의 실제 브라우저 흐름 테스트에서 이어 간다.
React Native에서는 같은 질문을 다른 렌더 대상에 묻는다
Web의 DOM 역할과 label 대신 React Native Testing Library의 화면 텍스트와 접근성 이름, press와
changeText 같은 동작을 사용한다. 저장 함수를 통제해 saving → saved/error 상태를 확인하는
질문은 같다. 그러나 실제 키보드, 운영체제 권한, 네이티브 코드와 화면 배치는 시뮬레이터·에뮬레이터나
기기 경계에서 별도로 확인해야 한다.
문제 저장 함수 검사는 통과하지만 화면의 저장 버튼이 제출과 연결되지 않음
잘못된 판단 마지막 저장 함수를 직접 호출했으므로 사용자 경로도 검증됐다고 생각함
관찰 사용자 클릭 테스트에서 saveName 호출 기대가 실패함
원인 실제 React 컴포넌트, DOM과 이벤트 배선을 테스트 경계에서 제거함
행동 컴포넌트를 렌더하고 역할·이름으로 찾아 user-event로 조작함
검증 입력값, 호출 인자, 저장 중 비활성화, 완료·실패 문구를 각각 확인함PR에서 바로 확인할 항목
- 컴포넌트가 props로 전달받은 함수나 setter만 직접 호출하고 실제 사용자 조작 경로를 건너뛰지 않았는가?
- 컴포넌트 내부 state보다 역할·이름·현재 값·상태 문구를 검증하는가?
data-testid보다 역할, label과 보이는 텍스트로 먼저 찾을 수 있는가?- 클릭·입력은 user-event 인스턴스로 수행하고 비동기 결과를 기다리는가?
- 아직 완료되지 않은 Promise를 통제해 중복 제출 차단 같은 중간 상태도 확인하는가?
- 테스트 대역은 저장 경계 바깥만 바꾸고 실제 컴포넌트와 상태는 유지하는가?
- jsdom 테스트가 실제 배치·브라우저 이동·운영 네트워크까지 증명한다고 확대하지 않는가?
기억할 질문은 하나다.
이 테스트는 사용자가 조작하는 경로를 실제로 실행했는가, 마지막 함수만 따로 호출했는가?
Active recall
기억에서 꺼내 보기
답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.
01saveName 함수를 직접 호출해 성공한 테스트는 저장 버튼이 실제로 연결됐음을 증명하는가?
정답
아니다. 컴포넌트를 렌더링하고 사용자가 버튼을 찾고 눌렀을 때 같은 함수가 호출되는 경로를 실행해야 한다.
관련 설명 다시 읽기왜 그런가
버튼이 type="button"이고 제출 처리와 연결되지 않아도 saveName만 직접 호출한 테스트는 통과한다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
02컴포넌트 테스트에서 내부 state 대신 무엇을 관찰해야 하는가?
정답
사용자가 찾는 입력과 버튼, 조작 뒤 표시되는 문구·비활성 상태와 컴포넌트가 props로 전달받은 함수의 호출 결과를 관찰한다.
관련 설명 다시 읽기왜 그런가
내부 state 이름이나 setter 호출은 같은 동작을 유지한 리팩터링에서도 달라질 수 있다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
03비동기 저장 테스트에서 최종 완료 문구만 기다리면 충분한가?
정답
요구 계약에 저장 중 중복 차단이 있다면 아직 완료되지 않은 Promise를 통제해 저장 중 비활성 상태와 완료 뒤 복구를 각각 확인해야 한다.
관련 설명 다시 읽기왜 그런가
즉시 성공하는 대역만 쓰면 저장 중 상태가 너무 빨리 지나가 중복 제출 방지 회귀를 놓칠 수 있다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
04jsdom 컴포넌트 테스트가 통과하면 실제 브라우저의 배치·이동·네트워크 흐름도 증명되는가?
정답
아니다. jsdom은 Node.js에서 브라우저 API 일부를 흉내 내지만 실제 시각 배치와 렌더링을 수행하지 않는다.
관련 설명 다시 읽기왜 그런가
DOM 상태와 React 상호작용은 빠르게 확인할 수 있지만 실제 브라우저 엔진·페이지 이동·운영 앱 경계는 별도 테스트가 필요하다.
선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.
출처와 검증 범위
아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.
- Testing Library - Guiding PrinciplesTesting Library · 공식 문서 · 확인 2026-08-09
- React Testing LibraryTesting Library · 공식 문서 · 확인 2026-08-09
- Testing Library - About QueriesTesting Library · 공식 문서 · 확인 2026-08-09
- Testing Library user-event - IntroductionTesting Library · 공식 문서 · 확인 2026-08-09
- Vitest - Test EnvironmentVitest · 공식 문서 · 확인 2026-08-09
- jsdom READMEjsdom project · 소스 코드 · 확인 2026-08-09
- jsdom 29.1.1 package metadatajsdom project · 소스 코드 · 확인 2026-08-09
- HTML Standard - The button elementWHATWG · 표준 · 확인 2026-08-09
- React actReact · 공식 문서 · 확인 2026-08-09
- React Native - TestingReact Native · 공식 문서 · 확인 2026-08-09