본문 바로가기

react-testing-library의 “not wrapped in act” Errors 원인과 해결법

최근 추후에 있을 기능 개선과 리팩토링을 위해 중요 로직이 포함된 컴포넌트에 react-testing-library를 이용하여 단위 테스트 코드를 작성하고 있다.

 

그러던 중에 다음 Warning을 발견했다.

Warning: An update to Component inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):

 

해당 경고를 받는 3가지 원인과 해결책을 잘 정리해둔 글이 있어 기억할 겸 정리해보았다.

 

출처 : React Testing Library and the “not wrapped in act” Errors

 

들어가기 앞서

react-testing-library는 실제 사용자 환경과 동일하게 맞추어 테스트하기 위해서 리액트의 콜스택을 사용한다.

 

리액트 공식문서에서는 예시코드와 함께 다음처럼 설명한다.

 

컴포넌트의 진단을 준비하기 위해서는 컴포넌트를 렌더링하고 갱신해주는 코드를 act()를 호출한 것의 안에 넣어줘야 합니다. 이를 통해 React를 브라우저 내에서 동작하는 것과 비슷한 환경에서 테스트할 수 있습니다.
it('can render and update a counter', () => {
  // 첫 render와 componentDidMount를 테스트
  act(() => {
    ReactDOM.render(<Counter />, container);
  });
  
  const button = container.querySelector('button');
  
  // 두 번째 render와 componentDidUpdate를 테스트
  act(() => {
    button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
  });
});

react-testing-library는 내부 API에 act를 내포하고 있어 우리는 굳이 act로 감싸서 호출하지 않고 렌더링과 업데이트를 할 수 있다.

 

act()를 직접 사용하다 보면, 코드가 길어질 때가 있습니다. 이를 간결하게 하고 싶을 때는 act()를 감싼 여러 도우미 함수를 제공하는 [React Testing Library] (https://testing-library.com/react)를 사용할 수 있습니다.
출처 : https://ko.reactjs.org/docs/testing-recipes.html#act

 

 

it("should render and update a counter", () => {
  const { getByText } = render(<Counter />;
  
  const button = container.querySelector('button');

  fireEvent.click(button);
});

이제 위 에러를 직역하면 테스트할 컴포넌트를 act로 감싸라는 뜻인데 왜 이런 경고메세지를 받는지 원인을 안아본다.

 

원인과 해결

원인은 크게 세 가지로 분류된다. 각 원인에 따른 해결법도 함께 기록했다.

 

1. 비동기 업데이트

컴포넌트가 비동기 API를 호출하는 경우가 있다.

const User = ({fetchUser}) => {
  const [name, setName] =  useState('')
  
  const handleFetchUser = useCallback(async () => {
    const { data } = await fetchUser()
    setName(data.name) // <- 비동기 업데이트
  }, [])

  return (
    <>
      <button className='btn' onClick={handleFetchUser}>불러오기</button>
      {name}
    </>
  )
}

위 컴포넌트를 테스트하는 코드를 아래처럼 작성하면 위의 경고를 보게 된다.

const mockFetchUser = jest.fn()

it("이름 조회", () => {
  mockFetchUser.mockReturnValue({
    code: 200, 
    data: {
      name: 'newjeong'
    }
  })
  
  const { container } = render(<User fetchUser={mockFetchUser} />)
  const button = container.querySelector('button.btn')
  
  fireEvent.click(button)

  expect(getByText("newjeong")).toBeInTheDocument()
})

fireEvent.click이 fetchUser(비동기)를 호출하고 응답을 state에 저장한다. 중요한건 이 순간의 컴포넌트 업데이트는 리액트 콜스택 바깥에서 이루어진다.

fireEvent.click triggers fetchData to be called, which is an asynchronous call. When its response comes back, setPerson will be invoked, but at this moment, the update will happen outside of React’s call stack. (출처)

 

해결

react-testing-library에서 waitFor라는 비동기 유틸리티를 제공한다. waitFor를 통해 컴포넌트 업데이트가 완료될때까지 기다릴 수 있다.

it("이름 조회", async () => {
  ...
  fireEvent.click(button)

  await waitFor(() => expect(getByText("newjeong")).toBeInTheDocument())
})

 

2. Fake Timer

컴포넌트 내부에 setTimeout 혹은 setInterval을 사용하는 경우가 있다.

const Toast = () => {
  const [isVisible, setIsVisible] = useState(true)
  
  useEffect(() => {
    setTimeout(() => { setIsVisible(false)}, 1000)
  }, [])
  
  return isVisible ? <div>Toast!</div> : null
}

테스트 코드에서 타이머 함수를 jest.useFakeTimers로 mocking하고 jest.advanceTimersByTime(msToRun)로 실행할 수 있다.

it("1초 후에 토스트가 사라진다.", () => {
  jest.useFakeTimers()
  
  const { queryByText } = render(<MyComponent />)
  
  jest.advanceTimersByTime(1000)
  
  expect(queryByText("Toast!")).not.toBeInTheDocument()
})

 

해결

1번의 비동기 이벤트와 마찬가지로 컴포넌트를 업데이트하고 있기 떄문에 act()로 감싸주어야 에러가 나지 않는다.

it("1초 후에 토스트가 사라진다.", () => {
  jest.useFakeTimers()
  
  const { queryByText } = render(<MyComponent />)
  
  act(() => {
    jest.advanceTimersByTime(1000)
  })
  
  expect(queryByText("Toast!")).not.toBeInTheDocument()
})

 

3. 렌더링/업데이트 전에 테스트가 종료됨

 

주로 로딩 상태에서 아래처럼 조건문을 통해 렌더링하는 코드를 많이 볼 수 있다.

const MyComponent = ({status = STATUS.PROGRESS}) => {
    const getComponent = useCallback(() => {
        switch (status) {
            case STATUS.SUCCESS:
                return <Success />
            case STATUS.FAILURE:
                return <Failure />
            default:
                return <Loading />
        }
    }, [status])

    useEffect(() => {
        const status = fetchUserStatus()
        
        setStatus(status)
    }, [])

    return getComponent()
}
let mockStatus = STATUS.PROGRESS

it("성공", () => {
  mockStatus = STATUS.SUCCESS
  
  const { container } = render(<MyComponent status={mockStatus} />);
  
  const msg = container.querySelector('div.msg')
  
  expect(msg).toBeInTheDocument();
});

그러나 getUserStatus에서 받은 응답으로 컴포넌트가 설정되기 전에 테스트가 종료되어버려 위의 경고를 받게 된다.

따라서 이런 경우도 1번과 마찬가지로 waitFor로 기다려야 한다.

 

해결

let mockStatus = STATUS.PROGRESS

it("성공", async () => {
  mockStatus = STATUS.SUCCESS
  
  const { container } = render(<MyComponent status={mockStatus} />)
  
  const msg = container.querySelector('div.msg')
  
  await waitFor(() => expect(msg).toBeInTheDocument())
})

 

첨언

나의 경우, 3번과 유사한 원인으로 인해 해당 Warning을 보게 되었다. (비동기 함수 호출 시 waitFor로 기다리는것만 알고 있었다..)

mobX에서 설정된 observable에 따른 컴포넌트 렌더링또한 위처럼 waitFor로 감싸주어야 함을 알게 되었다.