[번역] 더 적고, 더 긴 테스트를 작성하세요

I
Inkyu Oh

Front-End2026.01.07

Kent C. Dodds - 2019년 10월 21일


데이터가 로드될 때까지 로딩 스피너를 렌더링하는 다음과 같은 UI가 있다고 가정해 봅시다.
import * as React from 'react'
import * as api from './api'

function Course({ courseId }) {
const [state, setState] = React.useState({
loading: false,
course: null,
error: null,
})

const { loading, course, error } = state

React.useEffect(() => {
setState({ loading: true, course: null, error: null })
api.getCourseInfo(courseId).then(
(data) => setState({ loading: false, course: data, error: null }),
(e) => setState({ loading: false, course: null, error: e }),
)
}, [courseId])

return (
<>
<div role="alert" aria-live="polite">
{loading ? 'Loading...' : error ? error.message : null}
</div>
{course ? <CourseInfo course={course} /> : null}
</>
)
}

function CourseInfo({ course }) {
const { title, subtitle, topics } = course
return (
<div>
<h1>{title}</h1>
<strong>{subtitle}</strong>
<ul>
{topics.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
</div>
)
}

export default Course
이 컴포넌트를 테스트하는 것에 대해 이야기해 봅시다. 실제 네트워크 요청을 보내지 않도록 api.getCourseInfo(courseId) 호출을 모킹(Mocking)할 것입니다. 이 컴포넌트가 수행하는 작업에 대해 검증(Assert)해야 할 몇 가지 사항은 다음과 같습니다.
  1. 로딩 스피너를 보여주어야 함
  1. getCourseInfo 함수를 적절하게 호출해야 함
  1. 제목(title)을 렌더링해야 함
  1. 부제목(subtitle)을 렌더링해야 함
  1. 주제(topics) 목록을 렌더링해야 함

그리고 에러 케이스(요청이 실패하는 경우 등)도 있습니다.
  1. 로딩 스피너를 보여주어야 함
  1. getCourseInfo 함수를 적절하게 호출해야 함
  1. 에러 메시지를 렌더링해야 함

많은 사람이 컴포넌트에 대한 이러한 요구 사항 목록을 읽고 이를 개별 테스트 케이스로 만듭니다. 아마도 "테스트당 하나의 단언(Assertion)만 사용하라"는 소위 베스트 프랙티스에 대해 들어보셨을 것입니다. 그렇게 한번 해보겠습니다.
// 🛑 이렇게 하지 마세요...
import * as React from 'react'
import { render, wait, cleanup } from '@testing-library/react/pure'
import { getCourseInfo } from '../api'
import Course from '../course'

jest.mock('../api')

function buildCourse(overrides) {
return {
title: 'TEST_COURSE_TITLE',
subtitle: 'TEST_COURSE_SUBTITLE',
topics: ['TEST_COURSE_TOPIC'],
...overrides,
}
}

describe('Course success', () => {
const courseId = '123'
const title = 'My Awesome Course'
const subtitle = 'Learn super cool things'
const topics = ['topic 1', 'topic 2']

let utils
beforeAll(() => {
getCourseInfo.mockResolvedValueOnce(
buildCourse({ title, subtitle, topics }),
)
})

afterAll(() => {
cleanup()
jest.resetAllMocks()
})

it('should show a loading spinner', () => {
utils = render(<Course courseId={courseId} />)
expect(utils.getByRole('alert')).toHaveTextContent(/loading/i)
})

it('should call the getCourseInfo function properly', () => {
expect(getCourseInfo).toHaveBeenCalledWith(courseId)
})

it('should render the title', async () => {
expect(await utils.findByRole('heading')).toHaveTextContent(title)
})

it('should render the subtitle', () => {
expect(utils.getByText(subtitle)).toBeInTheDocument()
})

it('should render the list of topics', () => {
const topicElsText = utils
.getAllByRole('listitem')
.map((el) => el.textContent)
expect(topicElsText).toEqual(topics)
})
})

describe('Course failure', () => {
const courseId = '321'
const message = 'TEST_ERROR_MESSAGE'

let utils, alert
beforeAll(() => {
getCourseInfo.mockRejectedValueOnce({ message })
})

afterAll(() => {
cleanup()
jest.resetAllMocks()
})

it('should show a loading spinner', () => {
utils = render(<Course courseId={courseId} />)
alert = utils.getByRole('alert')
expect(alert).toHaveTextContent(/loading/i)
})

it('should call the getCourseInfo function properly', () => {
expect(getCourseInfo).toHaveBeenCalledWith(courseId)
})

it('should render the error message', async () => {
await wait(() => expect(alert).toHaveTextContent(message))
})
})
우리는 이러한 테스트 방식에 반대할 것을 강력히 권장합니다. 여기에는 몇 가지 문제가 있습니다.
  1. 테스트가 전혀 격리(Isolation)되지 않았습니다. (Test Isolation with React를 읽어보세요)
  1. 가변 변수(Mutable variables)가 테스트 간에 공유됩니다. (Avoid Nesting when you're Testing을 읽어보세요)
  1. 테스트 사이에 비동기적인 일이 발생하여 act 경고가 발생할 수 있습니다. (이 특정 예시의 경우)
처음 두 가지 포인트는 무엇을 테스트하든 상관없이 적용 가능하다는 점에 주목할 필요가 있습니다. 세 번째는 Jest와 act 사이의 구현 세부 사항(Implementation detail)에 가깝습니다.
대신, 다음과 같이 테스트를 결합하는 것을 제안합니다.
// ✅ 이렇게 하세요
import { render, screen, wait } from '@testing-library/react'
import * as React from 'react'

import { getCourseInfo } from '../api'
import Course from '../course'

jest.mock('../api')

afterEach(() => {
jest.resetAllMocks()
})

function buildCourse(overrides) {
return {
title: 'TEST_COURSE_TITLE',
subtitle: 'TEST_COURSE_SUBTITLE',
topics: ['TEST_COURSE_TOPIC'],
...overrides,
}
}

test('course loads and renders the course information', async () => {
const courseId = '123'
const title = 'My Awesome Course'
const subtitle = 'Learn super cool things'
const topics = ['topic 1', 'topic 2']

getCourseInfo.mockResolvedValueOnce(buildCourse({ title, subtitle, topics }))

render(<Course courseId={courseId} />)

expect(getCourseInfo).toHaveBeenCalledWith(courseId)
expect(getCourseInfo).toHaveBeenCalledTimes(1)

const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent(/loading/i)

const titleEl = await screen.findByRole('heading')
expect(titleEl).toHaveTextContent(title)

expect(screen.getByText(subtitle)).toBeInTheDocument()

const topicElsText = screen
.getAllByRole('listitem')
.map((el) => el.textContent)
expect(topicElsText).toEqual(topics)
})

test('an error is rendered if there is a problem getting course info', async () => {
const message = 'TEST_ERROR_MESSAGE'
const courseId = '321'

getCourseInfo.mockRejectedValueOnce({ message })

render(<Course courseId={courseId} />)

expect(getCourseInfo).toHaveBeenCalledWith(courseId)
expect(getCourseInfo).toHaveBeenCalledTimes(1)

const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent(/loading/i)

await wait(() => expect(alert).toHaveTextContent(message))
})
이제 우리의 테스트는 완전히 격리되었고, 더 이상 공유되는 가변 변수 참조가 없으며, 중첩이 줄어들어 테스트를 따라가기가 더 쉬워졌고, React로부터 act 경고를 더 이상 받지 않게 될 것입니다.
네, 우리는 "테스트당 하나의 단언" 규칙을 위반했습니다. 하지만 그 규칙은 원래 테스트 프레임워크가 테스트 실패의 원인을 파악하는 데 필요한 맥락 정보를 제대로 제공하지 못하던 시절에 만들어진 것입니다. 이제 이러한 Jest 테스트에서 테스트 실패는 다음과 같이 보일 것입니다.
FAIL src/__tests__/course-better.js
course loads and renders the course information

Unable to find an element with the text: Learn super cool things. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

<body>
<div>
<div
aria-live="polite"
role="alert"
/>
<div>
<h1>
My Awesome Course
</h1>
<ul>
<li>
topic 1
</li>
<li>
topic 2
</li>
</ul>
</div>
</div>
</body>

40 | expect(titleEl).toHaveTextContent(title)
41 |
> 42 | expect(getByText(subtitle)).toBeInTheDocument()
| ^
43 |
44 | const topicElsText = getAllByRole('listitem').map(el => el.textContent)
45 | expect(topicElsText).toEqual(topics)

at getElementError (node_modules/@testing-library/dom/dist/query-helpers.js:22:10)
at node_modules/@testing-library/dom/dist/query-helpers.js:76:13
at node_modules/@testing-library/dom/dist/query-helpers.js:59:17
at Object.getByText (src/__tests__/course-better.js:42:10)
우리의 훌륭한 도구들 덕분에 어떤 단언이 실패로 이어졌는지 식별하는 것은 매우 쉽습니다. 그리고 위에서 설명한 문제들을 피할 수 있습니다. 상황을 더 명확하게 만들고 싶다면, 단언문 위에 코드 주석을 추가하여 해당 단언이 왜 중요한지 설명할 수 있습니다.

결론

긴 테스트를 작성하는 것을 걱정하지 마세요. 두 명의 사용자에 대해 생각하고 테스트 사용자(test user)를 피한다면, 테스트는 종종 여러 개의 단언을 포함하게 될 것이며 이는 좋은 일입니다. 단언문을 개별 테스트 블록으로 임의로 분리하지 마세요. 그렇게 해야 할 타당한 이유가 없습니다.
단, 단일 테스트 블록 내에서 동일한 컴포넌트를 여러 번 렌더링하는 것은 권장하지 않는다는 점을 말씀드리고 싶습니다. (예를 들어 prop 업데이트 시 발생하는 상황을 테스트하는 경우라면 리렌더링은 괜찮습니다.)
원칙:
수동 테스터를 위한 테스트 케이스 워크플로우를 생각하고, 각 테스트 케이스가 해당 워크플로우의 모든 부분을 포함하도록 노력하세요. 이는 종종 여러 작업과 단언으로 이어지며, 이는 괜찮습니다.
테스트 구조를 잡기 위한 오래된 "Arrange(준비)", "Act(실행)", "Assert(단언)" 모델이 있습니다. 우리는 일반적으로 테스트당 하나의 "Arrange"를 두고, 신뢰를 얻고자 하는 워크플로우에 필요한 만큼의 "Act"와 "Assert"를 가질 것을 제안합니다.
이 예제들에 대한 실행 가능한 코드는 여기에서 찾을 수 있습니다.

부록

React Testing Library의 유틸리티를 사용하고 있는데도 여전히 act 경고가 발생합니다!

React의 act 유틸리티는 React Testing Library에 내장되어 있습니다. React Testing Library의 비동기 유틸리티를 사용하고 있다면 직접 act를 사용해야 하는 경우는 거의 없습니다.
  1. jest.useFakeTimers()를 사용할 때
  1. useImperativeHandle을 사용하고 상태 업데이트 함수를 호출하는 함수를 직접 호출할 때
  1. 커스텀 훅을 테스트하고 상태 업데이트 함수를 호출하는 함수를 직접 호출할 때

그 외의 경우에는 React Testing Library가 알아서 처리해 줄 것입니다. 여전히 act 경고가 발생한다면, 가장 가능성 높은 이유는 테스트가 완료된 후에 무언가가 발생하고 있고, 여러분이 그것을 기다려야(wait) 하기 때문입니다.
이 문제로 고통받는 테스트 예시(위와 동일한 예시 사용)는 다음과 같습니다.
// 🛑 이렇게 하지 마세요...
test('course shows loading screen', () => {
getCourseInfo.mockResolvedValueOnce(buildCourse())
render(<Course courseId="123" />)
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent(/loading/i)
})
여기서 우리는 Course를 렌더링하고 로딩 메시지가 제대로 표시되는지 확인하려고 합니다. 문제는 <Course /> 컴포넌트를 render할 때 즉시 비동기 요청이 발생한다는 것입니다. 우리는 이를 올바르게 모킹하고 있습니다(그렇게 하지 않으면 테스트가 실제로 요청을 보낼 것이기 때문에 반드시 해야 합니다). 하지만 모킹된 요청이 해결(resolve)될 기회를 갖기도 전에 테스트가 동기적으로 완료됩니다. 마침내 요청이 해결되면 성공 핸들러가 호출되어 상태 업데이트 함수를 호출하고, 우리는 act 경고를 받게 됩니다.
이를 해결하는 세 가지 방법이 있습니다.
  1. 프로미스(Promise)가 해결될 때까지 기다리기
  1. React Testing Library의 wait 유틸리티 사용하기
  1. 이 단언을 다른 테스트에 포함하기 (이 글의 전제)
// 1. 프로미스가 해결될 때까지 기다리기
// ⚠️ 이 방법도 문제를 해결하는 괜찮은 방법이지만, 더 좋은 방법이 있으니 계속 읽어보세요.
test('course shows loading screen', async () => {
const promise = Promise.resolve(buildCourse())
getCourseInfo.mockImplementationOnce(() => promise)
render(<Course courseId="123" />)
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent(/loading/i)
await act(() => promise)
})
이것은 사실 그렇게 나쁘지 않습니다. DOM에 관찰 가능한 변화가 없는 경우라면 이 방법을 권장합니다. 우리는 낙관적 업데이트(Optimistic update, 즉 요청이 끝나기 전에 DOM 업데이트가 발생하는 것)를 구현하여 DOM의 변화를 기다리거나 단언할 방법이 없었던 UI를 구축할 때 이런 상황을 겪은 적이 있습니다.
// 2. React Testing Library의 `wait` 유틸리티 사용하기
test('course shows loading screen', async () => {
getCourseInfo.mockResolvedValueOnce(buildCourse())
render(<Course courseId="123" />)
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent(/loading/i)
await wait()
})
이 방법은 생성한 mock이 즉시 해결되는 경우에만 제대로 작동하며, 대부분의 경우(특히 mockResolvedValueOnce를 사용하는 경우) 그러할 것입니다. 여기서는 act를 직접 사용할 필요는 없지만, 이 테스트는 기본적으로 기다리는 동안 발생한 모든 일을 무시하고 있기 때문에 별로 권장하지 않습니다.
우리가 제안하는 마지막(그리고 가장 좋은) 권장 사항은 이 단언을 컴포넌트의 다른 테스트에 포함하는 것입니다. 이 단언을 단독으로 유지하는 것에는 큰 가치가 없습니다.
0
26

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글