act를 감싸라는 에러의 원인과 해결법

테스트 코드를 작성하다가 act를 감싸라는 에러를 많이 본적이 있는데 매번 어찌저찌 act를 감싸는식으로 에러를 해결했던 것 같다.. 정확한 원인과 해결방법에 대해서 알아보자.

원문: https://kentcdodds.com/blog/fix-the-not-wrapped-in-act-warning

컴포넌트의 happy-path를 완전히 테스트하지 않았을 때

이름을 제출하는 간단한 컴포넌트를 클래스형 컴포넌트와 함수형 컴포넌트로 구현하였다.

class UsernameFormClass extends React.Component {
	state = { status: 'idle', error: null }
	handleSubmit = async (event) => {
		event.preventDefault()
		const newUsername = event.target.elements.username.value
		this.setState({ status: 'pending' })
		try {
			await this.props.updateUsername(newUsername)
			this.setState({ status: 'fulfilled' })
		} catch (e) {
			this.setState({ status: 'rejected', error: e })
		}
	}
	render() {
		const { error, status } = this.state

		return (
			<form onSubmit={this.handleSubmit}>
				<label htmlFor="username">Username</label>
				<input id="username" />
				<button type="submit">Submit</button>
				<span>{status === 'pending' ? 'Saving...' : null}</span>
				<span>{status === 'rejected' ? error.message : null}</span>
			</form>
		)
	}
}

아래와 같이 테스트 코드를 작성할 수 있다.

import * as React from 'react'
import user from '@testing-library/user-event'
import { render, screen } from '@testing-library/react'

test('calls updateUsername with the new username', async () => {
	const handleUpdateUsername = jest.fn()
	const fakeUsername = 'sonicthehedgehog'

	render(<UsernameForm updateUsername={handleUpdateUsername} />)

	const usernameInput = screen.getByLabelText(/username/i)
	user.type(usernameInput, fakeUsername)
	user.click(screen.getByText(/submit/i))

	expect(handleUpdateUsername).toHaveBeenCalledWith(fakeUsername)
})

만약 우리가 어떤 종류의 오타를 냈고 updateUsername 함수를 호출하지 않았거나 새로운 사용자 이름으로 호출하는 것을 잊었다면, 우리의 테스트는 실패하고 그것은 우리에게 가치를 제공할 것이다.

하지만, 이것을 후크가 있는 함수형 컴포넌트로 다시 쓰면 어떨까? 시도해보자.

function UsernameForm({ updateUsername }) {
	const [{ status, error }, setState] = React.useState({
		status: 'idle',
		error: null,
	})

	async function handleSubmit(event) {
		event.preventDefault()
		const newUsername = event.target.elements.username.value
		setState({ status: 'pending' })
		try {
			await updateUsername(newUsername)
			setState({ status: 'fulfilled' })
		} catch (e) {
			setState({ status: 'rejected', error: e })
		}
	}

	return (
		<form onSubmit={handleSubmit}>
			<label htmlFor="username">Username</label>
			<input id="username" />
			<button type="submit">Submit</button>
			<span>{status === 'pending' ? 'Saving...' : null}</span>
			<span>{status === 'rejected' ? error.message : null}</span>
		</form>
	)
}

React Testing Library의 멋진 점은 구현 세부 사항이 없기 때문에 컴포넌트의 리팩토링된 버전에서 정확히 동일한 테스트를 실행할 수 있다는 것이다. 함수형 컴포넌트에 대해 테스트를 실행하면 아래와 같은 에러가 발생한다.

console.error node_modules/react-dom/cjs/react-dom.development.js:530
  Warning: An update to UsernameForm inside a test was not wrapped in act(...).

  When testing, code that causes React state updates should be wrapped into act(...):

  act(() => {
    /* fire events that update state */
  });
  /* assert on the output */

  This ensures that you're testing the behavior the user would see in the browser. Learn more at https://fb.me/react-wrap-tests-with-act
      in UsernameForm

사실 이는, 컴포넌트의 happy-path를 완전히 테스트 하지 않았고 중요한 측면 하나를 회귀에 취약하게 만들어서 생긴 에러이다. 클래스 버전으로 돌아가서 아래 코드를 주석처리 해보겠다.

// ...생략
	handleSubmit = async (event) => {
		event.preventDefault()
		const newUsername = event.target.elements.username.value
		this.setState({ status: 'pending' })
		try {
			await this.props.updateUsername(newUsername)
			// this.setState({status: 'fulfilled'})
		} catch (e) {
			this.setState({ status: 'rejected', error: e })
		}
	}
// ...생략

컴포넌트에서 중요한 역할을 하는 구문이 주석 처리되었지만 우리의 테스트는 여전히 통과한다. 이는 우리가 컴포넌트가 하는 모든것을 테스트 하지 않았다는 말이다. 따라서 act에 대한 경고는 우리가 예상하지 못한 일이 컴포넌트에 발생했다는 것을 알려주기 위해 존재한다. 그렇게 하지 않고 업데이트가 있는 경우 React는 예상치 못한 업데이트가 있었다고 경고한다.

act(...)경고를 수정하는 방법 (대안1)

test('calls updateUsername with the new username', async () => {
	const handleUpdateUsername = jest.fn()
	const fakeUsername = 'sonicthehedgehog'

	render(<UsernameForm updateUsername={handleUpdateUsername} />)

	const usernameInput = screen.getByLabelText(/username/i)
	user.type(usernameInput, fakeUsername)
	user.click(screen.getByText(/submit/i))

	expect(handleUpdateUsername).toHaveBeenCalledWith(fakeUsername)
	// ✅
	await waitForElementToBeRemoved(() => screen.queryByText(/saving/i))
})

위와 같이 코드를 추가하면 클래스형 컴포넌트나 함수형 컴포넌트에 상관없이 모두 통과하는 것을 확인할 수 있습니다. 그러나 추가줄이 없으면 클래스 컴포넌트에서는 문제가 없으나 함수형 컴포넌트에 대해서만 경고가 발생합니다. 그 이유는 React 팀이 사람들과 기존 테스트에 대한 새로운 경고를 엄청나게 많이 만들지 않고는 클래스 구성 요소에 대한 이 경고를 합리적으로 추가할 수 없었기 때문이다. 따라서 이 경고가 사람들이 이와 같은 버그를 찾아 수정하는 데 도움이 될 수 있지만, 사람들이 새로운 구성 요소를 개발하고 테스트할 때 경고가 발생하도록 후크에만 적용하기로 결정했습니다.

결론: 여전히 경고가 표시된다면 act가장 가능성 있는 이유는 테스트가 완료된 후 예상대로 어떤 일이 발생하고 있기 때문입니다(이전 예와 같이).

mocking된 promise를 기다리는 방법 (대안2)

이 사용 사례에는 그다지 좋지 않지만, 비동기 작업 완료에 대한 시각적 표시가 없는 경우 특히 유용할 수 있는 대안을 하나 보여드리고 싶습니다.

우리 코드는 계속하기 전에 약속이 해결될 때까지 기다리므로 updateUsername가짜 버전에서 약속을 반환하고 비동기 작업을 사용하여 해당 약속이 해결될 때까지 기다릴 수 있습니다.

test('calls updateUsername with the new username', async () => {
	const promise = Promise.resolve() // You can also resolve with a mocked return value if necessary
	const handleUpdateUsername = jest.fn(() => promise)
	const fakeUsername = 'sonicthehedgehog'

	render(<UsernameForm updateUsername={handleUpdateUsername} />)

	const usernameInput = screen.getByLabelText(/username/i)
	user.type(usernameInput, fakeUsername)
	user.click(screen.getByText(/submit/i))

	expect(handleUpdateUsername).toHaveBeenCalledWith(fakeUsername)
	// we await the promise instead of returning directly, because act expects a "void" result
	await act(async () => {
		await promise
	})
})

jest.useFakeTimers를 사용할 때

import React from 'react'
import {checkStatus} from './api'

function OrderStatus({orderId}) {
	const [{status,data,error}, setState] = React.useReducer(
		(s, a) => ({...s, ...a}),
		{status: 'idle', data: null, error: null}
	)
	React.useEffect(() => {
		let current = true
		function tick() {
			setState({status: 'pending'})
			checkStatus(orderId).then(
				d => {
					if (current) setState({status: 'fulfilled', data: d})
				},
				e => {
					if (current) setState({status: 'rejected', error: e})
				}
			)
		}
		const id = setInterval(tick, 1000)
		return () => {
			current = false
			clearInterval(id)
		}
	}, [orderId])

	return (
		<div>
			Order Status:{' '}
			<span>
				{status === 'idle' || status === 'pending'
					? '...'
					: status === 'error'
					? error.message
					: status === 'fulfilled'
					? data.orderStatus
					: null
				}
			</span>
		</div>
	)
}

export default OrderStatus

위의 코드는 1초뒤에 tick 함수를 수행하는 간단한 컴포넌트이고, 아래는 이를 테스트하는 코드이다.

import React from 'react'
import {render, screen} from '@testing-library/react'
import OrderStatus from '../order-status'
import {checkStatus} from '../api'

jest.mock('../api')

beforeAll(() => {
	jest.useFakeTimers()
})

afterAll(() => {
	jest.useRealTimers()
})

test('polling backend on an interval', async () => {
	const orderId = 'abc123'
	const orderStatus = 'Order Received'
	checkStatus.mockResolvedValue({ orderStatus })

	render(<OrderStatus orderId={orderId} />)

	expect(screen.getByText(/\.\.\./i)).toBeInTheDocument()
	expect(checkStatus).toHaveBeenCalledTimes(0)

	// advance the timers by a second to kick off the first request
	jest.advanceTimersByTime(1000)

	expect(await screen.findByText(orderStatus)).toBeInTheDocument()

	expect(checkStatus).toHaveBeenCalledWith(orderId)
	expect(checkStatus).toHaveBeenCalledTimes(1)
})

이 테스트를 수행하면 act를 감싸라는 에러가 나오는데 이는 tick 함수에서 setState를 해준 코드때문에 발생한 에러이다. tick 함수는 React의 callStack 외부에서 실행되므로 이 컴포넌트가 상호작용이 적절하게 테스트되었는지 확실하지 않습니다. RTL에는 fakeTimer에 대한 유틸리티가 없으므로 타이머 진행상황을 다음과 같이 act로 직접 래핑해야합니다.

React의 콜스택은 Javascript 엔진의 콜스택과 동일하게 동작한다. 그러나 useEffect는 렌더링 후 비동기로 실행되므로 콜스택이 비워질 때까지 대기합니다.

// ❌
jest.advanceTimersByTime(1000)
// ✅
act(() => jest.advanceTimersByTime(1000))

커스텀훅을 테스트할 때

커스텀훅에서 반환된 함수를 호출하여 상태 업데이트를 초래하는 경우에 act에 대한 경고가 발생한다. 다음은 이에 대한 간단한 예이다.

import * as React from 'react'

function useCount() {
	const [count, setCount] = React.useState(0)
	const increment = () => setCount((c) => c + 1)
	const decrement = () => setCount((c) => c - 1)
	return { count, increment, decrement }
}

export default useCount
import * as React from 'react'
import { renderHook } from '@testing-library/react'
import useCount from '../use-count'

test('increment and decrement updates the count', () => {
	const { result } = renderHook(() => useCount())
	expect(result.current.count).toBe(0)

	// act로 래핑하라고 에러 발생
	result.current.increment()
	expect(result.current.count).toBe(1)

	// act로 래핑하라고 에러 발생
	result.current.decrement()
	expect(result.current.count).toBe(0)
})

우리가 후크에서 증가 및 감소 함수를 호출하면 상태 업데이트가 트리거되고 React 콜스택에 없기 때문에 act(...) 경고가 발생한다. 그러니 act(...)로 래핑해 보자!

import * as React from 'react'
import { renderHook, act } from '@testing-library/react'
import useCount from '../use-count'

test('increment and decrement updates the count', () => {
	const { result } = renderHook(() => useCount())
	expect(result.current.count).toBe(0)

	act(() => result.current.increment())
	expect(result.current.count).toBe(1)

	act(() => result.current.decrement())
	expect(result.current.count).toBe(0)
})

useImperativeHandle을 사용할 때

다음 예시를 확인해보자

function ImperativeCounter(props, ref) {
	const [count, setCount] = React.useState(0)
	React.useImperativeHandle(ref, () => ({
		increment: () => setCount((c) => c + 1),
		decrement: () => setCount((c) => c - 1),
	}))
	return <div>The count is: {count}</div>
}
ImperativeCounter = React.forwardRef(ImperativeCounter)

아래와 같이 테스트 코드를 작성할 수 있다.

import * as React from 'react'
import { render, screen, act } from '@testing-library/react'
import ImperativeCounter from '../imperative-counter'

test('can call imperative methods on counter component', () => {
	const counterRef = React.createRef()
	render(<ImperativeCounter ref={counterRef} />)
	expect(screen.getByText('The count is: 0')).toBeInTheDocument()

	counterRef.current.increment()
	expect(screen.getByText('The count is: 1')).toBeInTheDocument()

	counterRef.current.decrement()
	expect(screen.getByText('The count is: 0')).toBeInTheDocument()
})

counterRef의 increment와 decrement 모두 React의 콜스택 외부에서 발생하기 때문에 act로 래핑해야합니다.

import * as React from 'react'
import { render, screen, act } from '@testing-library/react'
import ImperativeCounter from '../imperative-counter'

test('can call imperative methods on counter component', () => {
	const counterRef = React.createRef()
	render(<ImperativeCounter ref={counterRef} />)
	expect(screen.getByText('The count is: 0')).toBeInTheDocument()

	act(() => counterRef.current.increment())
	expect(screen.getByText('The count is: 1')).toBeInTheDocument()

	act(() => counterRef.current.decrement())
	expect(screen.getByText('The count is: 0')).toBeInTheDocument()
})