RainyLab
Dev · React

React 입력값 테스트가 안 먹히는 이유 — value 를 넣었는데 왜 안 바뀌나

테스트에서 input 의 value 를 바꾸고 change 이벤트를 쏘면 화면이 따라올 것 같습니다. React 에서는 안 됩니다. 오타도 타이밍 문제도 아니고, React 가 입력값을 추적하는 방식과 이벤트를 듣는 방식 두 가지가 동시에 막고 있기 때문입니다. 그 구조를 알고 나면 Testing Library 가 왜 그렇게 생겼는지도 같이 풀립니다.

Archive · 2019

원문은 2019년 3월, react-testing-library 와 React 16 을 쓰던 시절의 노트입니다. 패키지 이름부터 지금과 다릅니다 — 그해 하반기에 @testing-library/react 로 바뀌었고, 원문이 대안으로 든 react-dom/test-utilsSimulateReact 19 에서 제거됐습니다. 코드는 전부 지금 쓰는 API 로 고쳐 실었고, 6절에 무엇이 어떻게 바뀌었는지 표로 모았습니다.

반대로 원문이 관찰한 현상 자체는 지금도 그대로 재현됩니다. 원인을 설명하지 않고 "이렇게 하면 된다"로 끝났던 부분을 이 글에서 채웠습니다.

1. 안 되는 코드부터

이름을 입력하면 인사말이 바뀌는 컴포넌트를 테스트한다고 합시다. 직관적으로는 이렇게 씁니다.

const input = screen.getByLabelText('Name')

input.value = 'jongmin'
input.dispatchEvent(new Event('change'))

expect(screen.getByText('hello jongmin')).toBeInTheDocument()
// → 실패. 화면은 여전히 'hello world' 다

DOM 만 놓고 보면 값도 바꿨고 이벤트도 쐈습니다. 그런데 React 는 아무 반응이 없습니다. 막는 것이 두 개인데, 하나만 고쳐서는 여전히 안 됩니다.

2. 첫 번째 벽 — React 는 change 를 듣지 않는다

JSX 에 onChange 라고 쓰지만, React 가 텍스트 입력에 대해 실제로 듣는 브라우저 이벤트는 input 입니다.

이름이 이렇게 어긋난 건 의도적입니다. DOM 의 네이티브 change포커스를 잃어야 발생합니다. 글자를 칠 때마다 오는 게 아닙니다. React 는 "값이 바뀌면 그때그때 알려 주는" 쪽이 쓰기 좋다고 보고, onChange 라는 이름에 네이티브 input 의 동작을 붙였습니다.

// JSX 에 쓰는 것
<input onChange={handle} />

// 실제로 걸리는 브라우저 이벤트
'input'   ← 'change' 가 아니다

그러니 new Event('change') 는 React 가 듣지 않는 채널로 소리친 셈입니다. 여기까지가 절반입니다. 'input' 으로 바꿔도 아직 안 됩니다.

3. 두 번째 벽 — 값 추적기

React 는 각 입력 노드에 마지막으로 알고 있던 값을 따로 붙여 둡니다. 이벤트가 올 때마다 지금 값과 그 기록을 비교해서, 다를 때만 onChange 를 부릅니다. 같은 값으로 이벤트가 여러 번 오는 상황에서 불필요한 리렌더를 막으려는 장치입니다.

문제는 그 기록을 value 프로퍼티에 끼어들어 유지한다는 점입니다. React 는 노드의 value 접근자를 자기 것으로 덮어 둡니다. 그래서 input.value = 'jongmin' 이라고 쓰는 순간, 값이 바뀌는 동시에 기록도 같이 갱신됩니다.

input.value = 'jongmin'
// 실제 값     : 'jongmin'
// React 의 기록: 'jongmin'  ← 같이 따라 올라갔다

// 이제 이벤트가 도착하면 React 는 이렇게 판단한다
// "기록과 현재 값이 같다 → 바뀐 게 없다 → onChange 안 부른다"

값을 바꾸는 행위 자체가 "바뀌었다"는 증거를 지워 버립니다. 그래서 이벤트 이름을 고쳐도 소용이 없었던 것입니다.

빠져나가는 방법은 React 가 덮어쓰기 전의 원래 설정자를 직접 부르는 것입니다. 그러면 값만 바뀌고 기록은 옛날 값에 남아, 이벤트가 도착했을 때 차이가 생깁니다.

const setter = Object.getOwnPropertyDescriptor(
  window.HTMLInputElement.prototype, 'value',
).set

setter.call(input, 'jongmin')            // 기록을 건드리지 않고 값만
input.dispatchEvent(new Event('input', { bubbles: true }))
// → 이제 onChange 가 불린다

이 코드를 직접 쓸 일은 없습니다. Testing Library 의 fireEvent.change 가 내부에서 정확히 이 일을 합니다. 원문이 "이렇게 하면 된다"고만 적었던 그 한 줄의 정체가 이것입니다.

4. Testing Library 로 쓰면

import { render, screen } from '@testing-library/react'
import { fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom'
import App from './App'

it('이름을 바꾸면 인사말이 따라 바뀐다', () => {
  render(<App />)

  const input = screen.getByLabelText('Name')
  expect(input).toHaveValue('world')

  fireEvent.change(input, { target: { value: 'jongmin' } })

  expect(screen.getByText('hello jongmin')).toBeInTheDocument()
})

원문 코드에서 세 가지가 달라졌습니다.

쿼리는 getByRole 부터

Testing Library 가 권하는 순서는 사용자가 화면에서 그것을 찾는 방법에 가까운 쪽입니다. getByRolegetByLabelTextgetByText 순이고, container.querySelectordata-testid 는 마지막 수단입니다.

screen.getByRole('textbox', { name: 'Name' })   // 가장 권장
screen.getByLabelText('Name')                   // 폼 입력에는 이것도 좋다
container.querySelector('#name')                // 원문 방식 — 구조에 묶인다

#name 으로 찾으면 id 를 바꾸는 순간 테스트가 깨집니다. 화면은 멀쩡한데 말입니다. 역할과 라벨로 찾으면 마크업을 바꿔도 사용자에게 보이는 것이 그대로면 테스트도 그대로입니다. 접근성이 깨지면 테스트가 먼저 알려 주는 것은 덤입니다.

5. 지금은 fireEvent 보다 userEvent

fireEvent.change이벤트 하나를 쏩니다. 실제 사용자가 글자를 치면 그것보다 훨씬 많은 일이 일어납니다 — 클릭해서 포커스가 가고, 키를 누르고 떼고, 글자마다 값이 갱신됩니다.

import userEvent from '@testing-library/user-event'

it('이름을 바꾸면 인사말이 따라 바뀐다', async () => {
  const user = userEvent.setup()   // v14 부터 필요하다
  render(<App />)

  const input = screen.getByRole('textbox', { name: 'Name' })
  await user.clear(input)
  await user.type(input, 'jongmin')   // 전부 await 한다

  expect(screen.getByText('hello jongmin')).toBeInTheDocument()
})

차이가 드러나는 곳이 있습니다. maxLength 로 길이를 제한했거나, 키 입력마다 뭔가를 하거나, disabled 인 요소를 다룰 때 fireEvent 는 통과하는데 실제로는 안 되는 경우가 생깁니다. userEvent 는 브라우저처럼 거절합니다.

다만 둘 중 하나만 쓰라는 뜻은 아닙니다. 사용자의 행동을 흉내 내는 자리에는 userEvent, 사용자가 직접 만들 수 없는 이벤트(paste 이외의 합성 이벤트, scroll 같은 것)를 억지로 만들어야 하는 자리에는 fireEvent 가 여전히 맞습니다.

6. 2019년 이후 바뀐 것

원문의 코드를 그대로 복사하면 지금은 설치조차 되지 않습니다. 이름이 바뀐 것과 없어진 것을 모았습니다.

원문 (2019)지금비고
react-testing-library @testing-library/react 2019년 v9 에서 이름이 바뀌었다. 옛 패키지는 더 이상 갱신되지 않는다
jest-dom/extend-expect @testing-library/jest-dom 같이 이름이 바뀌었다
cleanup() 직접 호출 불필요 v9 부터 afterEach 에 자동 등록된다
ReactDOM.render(…, container) createRoot(container).render(…) React 18. 테스트에서는 render() 가 알아서 한다
import {act} from 'react-dom/test-utils' import {act} from 'react' React 19 에서 옮겨졌다
ReactTestUtils.Simulate.change(…) 없어졌다 React 19 에서 react-dom/test-utils 의 나머지 함수는 제거됐다. 부르면 오류가 난다
fireEvent 위주 @testing-library/user-event v14 부터 setup() 이 필요하고 전부 await 한다

Simulate 가 없어진 이유가 이 글의 주제와 이어집니다. 그건 DOM 을 거치지 않고 React 내부의 이벤트 시스템을 직접 찔러 동작했습니다. 그래서 값 추적기도 이벤트 이름 문제도 우회할 수 있었지만, 동시에 실제로는 일어날 수 없는 일도 테스트에서 통과시켰습니다. React 팀이 정리한 이유가 그것입니다 — 내부 구현에 기대는 테스트였기 때문입니다.

7. 한 줄로 줄이면

value 를 직접 넣으면 값과 함께 "바뀌었다는 증거"까지 갱신되고, React 는 change 가 아니라 input 을 듣는다. 이 두 가지가 겹쳐서 손으로 만든 이벤트가 통하지 않습니다.

실무에서는 userEvent 를 쓰면 그만이지만, 그 안에서 무슨 일이 일어나는지를 알면 테스트가 실패했을 때 어디를 볼지가 달라집니다. 값이 안 바뀌면 추적기를, 핸들러가 안 불리면 이벤트 이름을 의심하면 됩니다.

자바스크립트에서 이벤트 핸들러가 도대체 어떤 문맥으로 불리는가 하는 더 앞단의 이야기는 이벤트 핸들러 안의 this 에 정리해 두었습니다.