React 테스트에서 act()에 대해 알아야 할 모든 것

I
Inkyu Oh

2025.11.13


HowToTestFrontend 블로그 포스트 번역
게시일: 2023년 10월 15일



React 테스트에서 act()에 대해 알아야 할 모든 것


React 테스트를 작성할 때, act() 함수에 익숙해질 것입니다. React 앱을 테스트하는 데 있어 기본적인 개념임에도 불구하고, 종종 가장 혼란스럽고 오해받는 부분 중 하나입니다.
과거에 React 앱을 테스트하는 방법을 배우는 엔지니어들에게 왜 act()가 필요한지 설명하기 어려웠습니다. (하지만 이 페이지에서 명확히 해드리겠습니다!)
테스트에서, 렌더링된 React 컴포넌트의 내부 상태를 업데이트하는 기능은 act()로 감싸야 합니다. 이렇게 하면 React가 모든 상태 변경과 부수 효과를 완전히 처리한 후에 나머지 테스트(즉, 어설션)가 계속 진행되도록 보장할 수 있습니다.
이렇게 하면 테스트가 현실적인 방식으로 테스트되도록 보장합니다. 그렇지 않으면 '오래된' 상태 값에 대해 어설션을 할 수 있으며, 이를 인식하지 못할 수 있습니다.
React Testing Library (RTL) 함수들을 사용할 때는 act()에 대해 생각할 필요가 없습니다. 이 함수들은 이미 act()로 기능을 감싸고 있기 때문입니다:
  • userEvent를 사용한 사용자 상호작용 (예: await userEvent.click(...))
  • await findBy... 함수들 (예: await screen.findByText("..."))
  • waitFor(...) 함수
하지만 - 주의하지 않으면 명령줄 출력에 act()로 감싸지지 않은 업데이트가 발생했다는 유명한 오류가 나타날 수 있습니다.
테스트 결과 출력에서 몇 번 본 적이 있을 것입니다:
An update to %s 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 */
이것은 테스트가 act()로 감싸지지 않았다는 경고입니다. 이는 테스트에서 '오래된' 상태에 대해 어설션을 하고 있을 수 있으며, 실제 버그를 인식하거나 잡지 못할 수 있음을 의미합니다.
(과거에 경고를 무시하고, act()를 올바르게 사용했다면 쉽게 잡을 수 있었던 큰 버그를 도입한 적이 있습니다!)
오류를 제거하는 몇 가지 방법을 보려면 아래 섹션을 참조하세요.
위의 내용이 React의 act()에 대해 알아야 할 모든 것입니다. 하지만 이 블로그를 읽고 있다면, 그것에 대해 모든 것을 정말로 이해하고 싶을 것입니다. 테스트를 작성하는 데 매우 중요한 부분이므로 완전히 이해하는 것이 합리적입니다.
먼저 몇 가지 혼란을 해소하겠습니다: React 자체에 act가 포함되어 있습니다. import React, from 'react'로 가져올 수 있습니다.
하지만 이렇게 하지 마세요. 항상 @testing-library/react에서 가져오세요:
import { act } from '@testing-library/react';
@testing-library/react에서 가져온 버전은 추가 기능이 거의 없는 매우 가벼운 래퍼입니다. 실제 reactact() 구현을 감싸지만, 다음과 같은 변경 사항이 있습니다:
  • 일부 환경 구성이 설정되어 있는지 확인합니다.
  • IS_REACT_ACT_ENVIRONMENT가 true로 설정되지 않은 경우, 테스트 환경 외부에서 act()를 호출하려고 하면 React가 경고합니다.
  • @testing-libraryact() 래퍼를 사용하면 올바르게 설정됩니다.
  • 오래된 버전의 React를 실행할 때 더 나은 호환성을 추가합니다. 2025년에는 이 기능이 필요하지 않을 가능성이 높습니다. 요즘에는 많은 사람들에게 영향을 미치지 않기 때문에 자세히 설명하지 않겠습니다.
관심이 있다면, RTL의 act()GitHub에서 어떻게 구현되어 있는지 확인할 수 있습니다. (그리고 레거시 이유로 세 번째 버전의 act()가 있다는 것을 알 수 있습니다.)
몇 년 전에는 더 큰 문제였고, 2025년에는 react 패키지에서 act()를 사용하는 것으로 충분할 수 있지만, 일관성과 매우 드문 엣지 케이스를 위해 항상 RTL에서 가져오는 것이 좋습니다!
다음 예제에서는 act()를 사용하지 않으면 테스트에서 현재 렌더링된 상태가 오래된 상태가 되는 경우를 보여드리겠습니다.
이 간단한 컴포넌트를 테스트해 보겠습니다. 10ms 후에 자동으로 count를 1씩 증가시킵니다:
const AutoCounter = () => {
const [count, setCount] = useState(0);

// 10ms 후에 자동으로 증가
useEffect(() => {
setTimeout(() => {
setCount(c => c + 1);
}, 10);
}, []);

return <h1>Count: {count}</h1>;
};
다음 테스트를 작성하면, 가짜 타이머를 사용하여 실패합니다. 렌더링된 컴포넌트가 여전히 Count: 0을 표시하고 있기 때문입니다:
vi.useFakeTimers();

test('auto-increments after 10ms', () => {
render(<AutoCounter />);
const heading = screen.getByRole('heading');

expect(heading).toHaveTextContent('Count: 0');

// 10ms로 가짜 타이머를 진행하면
// 컴포넌트의 setTimeout이 트리거됩니다.
// 이는 자체적으로 상태 변경을 트리거합니다.
vi.advanceTimersByTime(10);
// 참고: 대기 중인 타이머만 실행할 수도 있습니다: vi.runOnlyPendingTimers()

expect(heading).toHaveTextContent('Count: 1'); // ❌ 실패
});
하지만 vi.advanceTimersByTime(10)act(...)로 감싸면 이제 통과합니다:
vi.useFakeTimers();

test('auto-increments after 10ms', async () => {
render(<AutoCounter />);
const heading = screen.getByRole('heading');

expect(heading).toHaveTextContent('Count: 0');

// 타이머가 10ms로 진행될 때 상태 변경을 트리거합니다.
// setTimeout을 통해. 하지만 이번에는 act()로 감싸서
// 모든 상태 변경이 동기화된 후에 어설션을 수행합니다.
await act(async () => {
vi.advanceTimersByTime(10);
});

expect(heading).toHaveTextContent('Count: 1');
});
실제 테스트에서는 await screen.findByText('Count: 1')를 사용할 수도 있습니다!
await findBy... 함수는 내부적으로 act()를 사용하기 때문입니다.
다음 예제에서는 HTMLButton에서 직접 click 기능을 호출하는 예제입니다 (userEvent를 통해서가 아님).
const Counter = () => {
const [count, setCount] = useState(0);

const onClick = () => setCount(prev => prev + 1);
return (
<div>
<button onClick={onClick}>Increment</button>
<h1>Count: {count}</h1>
</div>
);
};

test('clicking button updates count', async () => {
render(<Counter />);

const button = screen.getByRole('button');
const heading = screen.getByRole('heading');

expect(heading).toHaveTextContent('Count: 0');

// DOM 요소에서 click()을 직접 호출 - 이 경우 act()가 필요합니다!
await act(async () => {
button.click();
});

expect(heading).toHaveTextContent('Count: 1'); // ✅ 통과!
// 하지만 `act()`를 사용하지 않았다면 실패했을 것입니다. 현재 `Count: 0`일 것입니다.
});
이것이 act()를 사용해야 하는 가장 일반적인 방법 중 하나라고 생각합니다.
renderHook()을 사용하여 훅을 격리하여 테스트할 때, 상태를 업데이트하는 함수를 호출할 수 있습니다. 이러한 경우에는 act()로 감싸야 합니다.
훅 테스트에서 act()를 사용하는 예제:
다음은 테스트하려는 간단한 훅입니다:
const useCounter = () => {
const [value, setValue] = useState(0);
const increment = () => {
setValue(val => val + 1);
};
return {
increment,
value,
};
};
다음 테스트는 result.current.value의 값이 0으로 유지되기 때문에 실패합니다:
test('can increment the counter', async () => {
const { result } = renderHook(useCounter);

expect(result.current.value).toBe(0); // ✅ 통과

result.current.increment();

expect(result.current.value).toBe(1); // ❌ 실패!

result.current.increment();
expect(result.current.value).toBe(2); // ❌ 실패!
});
하지만 act()를 사용하면 이제 올바른 상태 값을 갖게 됩니다:
test('can increment the counter', async () => {
const { result } = renderHook(useCounter);

expect(result.current.value).toBe(0); // ✅ 통과

await act(async () => {
result.current.increment();
});

expect(result.current.value).toBe(1); // ✅ 통과

await act(async () => {
result.current.increment();
});
expect(result.current.value).toBe(2); // ✅ 통과
});
  • 훅을 테스트할 때 상태 변경을 수행하는 경우
  • 상태 변경을 수동으로 트리거하는 경우, 예를 들어 someButtonElement.click()을 직접 호출하는 경우
  • 이벤트를 수동으로 디스패치하는 경우 (userEvent를 통해서가 아님)
  • 이벤트를 트리거할 때 (fireEvent를 통해 클릭 등) - 특히 비동기 기능을 트리거할 때
  • 타이머에서 상태 변경이 발생할 때, 예: setTimeout, setInterval
  • Jest/Vitest에서 가짜 타이머를 사용할 때
  • 비동기 프로미스가 해결되고 상태 변경을 초래할 때
이 게시물의 다른 곳에서 설명한 것처럼, act()를 과도하게 사용하기보다는 렌더링/상태 변경을 기다리도록 (waitFor 또는 findBy...) 시도해야 합니다.
대부분의 경우, fireEvent 호출 (예: fireEvent.click(...))을 act()로 감쌀 필요가 없습니다.
그러나 fireEvent가 비동기 함수를 트리거하고 그 자체로 await를 포함하는 경우에는 감싸야 할 때가 있습니다.
이것은 약간 논란의 여지가 있을 수 있지만, 제 경험상, 터미널에 경고가 나타나거나 예상대로 작동하지 않는 경우를 제외하고는 fireEvent에 대해 act()를 건너뛰는 경향이 있습니다. (참고: 가능하다면 클릭과 같은 것을 트리거하기 위해 userEvent 기능을 사용하는 것이 더 좋습니다.)
다음은 인위적인 예제입니다 (현실적으로, act()를 피하고 await screen.findBy...를 사용할 수 있습니다).
import {
render,
screen,
act,
} from '@testing-library/react';
import { useState } from 'react';

const AsyncButton = () => {
const [status, setStatus] = useState('idle');

const handleClick = async () => {
setStatus('loading');
// API 호출을 시뮬레이트합니다.
await new Promise(resolve =>
setTimeout(resolve, 100)
);
setStatus('done');
};

return (
<div>
<button onClick={handleClick}>
Click me
</button>
<p>Status: {status}</p>
</div>
);
};

test('button updates status after async operation', async () => {
render(<AsyncButton />);

const button = screen.getByRole('button');

expect(
screen.getByText('Status: idle')
).toBeInTheDocument();

// act() 없이 비동기 상태 업데이트가 반영되지 않습니다.
await act(async () => {
button.click();
// 비동기 작업이 완료될 때까지 기다립니다.
await new Promise(resolve =>
setTimeout(resolve, 100)
);
});

expect(
screen.getByText('Status: done')
).toBeInTheDocument();
});
(이 예제들 중 많은 경우, await screen.findByText("Status: done")를 사용하여 act()를 피할 수도 있습니다)
act() 호출 내에서 비동기 함수를 호출하지 않는 경우에는 거의 모든 경우에 비동기/대기 없이 수행할 수 있습니다. 그러나 React 업데이트 방식으로 인해 항상 작동하지 않는 엣지 케이스가 있습니다.
따라서 항상 await act(async () => {...})를 기본으로 사용해야 합니다.
참고: 비동기 버전을 사용하지 않는 버전을 폐기할 계획이 있습니다.
다음은 공식 React 문서에서 설명하는 내용입니다. 더 간결하게 설명할 수 없을 것 같아서 복사/붙여넣기 하겠습니다:
비동기 actFn: 테스트 중인 컴포넌트의 렌더링 또는 상호작용을 감싸는 비동기 함수입니다. actFn 내에서 트리거된 모든 업데이트는 내부 act 큐에 추가되며, 그런 다음 DOM에 대한 변경 사항을 처리하고 적용하기 위해 함께 플러시됩니다. 비동기이기 때문에 React는 비동기 경계를 넘는 모든 코드를 실행하고 예약된 업데이트를 플러시합니다.
다음과 같은 경우에는 항상 비동기를 사용해야 합니다:
  • act() 내부의 코드에 프로미스, 비동기/대기 또는 타이머가 포함된 경우
  • 필요 여부가 확실하지 않은 경우 - 비동기는 항상 안전합니다
다음과 같은 경우에는 동기 버전을 사용할 수 있습니다:
  • 간단한 동기 상태 업데이트만 있는 경우
  • 그러나 일관성을 위해 비동기를 사용하는 것이 여전히 권장됩니다
(이 페이지의 대부분의 예제는 act(() => {...})로도 작동합니다. 하지만 항상 비동기/대기를 사용하는 것이 더 쉽습니다)
act() 사용은 최후의 수단이어야 합니다. 대부분의 코드에서는 필요하지 않습니다.
다음과 같은 경우에는 사용하지 마세요:

React Testing Library 함수들을 act()로 감싸지 마세요

이 게시물의 다른 곳에서 언급했듯이, userEvent.click() 등을 act()로 감싸지 않아야 합니다.
(내부적으로 이미 그렇게 하고 있습니다).
// ❌ 이렇게 하지 마세요:
await act(async () => {
await userEvent.click(button);
});

// ✅ 대신 클릭을 대기할 수 있습니다:
await userEvent.click(button);

await waitFor() 또는 await findBy...로 대체할 수 있다면 act()를 피하세요

act() 호출을 피할 수 있는 경우가 매우 흔합니다. waitFor 또는 findBy... 함수 중 하나를 사용하여 어설션이 통과할 때까지 비동기 코드를 실행합니다.
예를 들어, 다음은 .findBy 호출로 대체할 수 있습니다:
await act(async () => {
someElement.click();
});

expect(
screen.getByText(
'you clicked something'
)
).toBeInTheDocument();
다음 (수동으로 클릭을 트리거한 후, await findByText())은 React에서 상태 변경이 act()로 감싸지지 않았다는 경고 없이 거의 확실히 작동할 것입니다:
someElement.click();

// `await findByText()`로 대체:
expect(
await screen.findByText(
'you clicked something'
)
).toBeInTheDocument();
다음은 act()waitFor로 대체할 수 있는 또 다른 예제입니다:
await act(async () => {
vi.runOnlyPendingTimers();
});

expect(
screen.getByText('some updated text')
).toBeInTheDocument();
다음으로 대체할 수 있습니다:
vi.runOnlyPendingTimers();

await waitFor(() => {
expect(
screen.getByText(
'some updated text'
)
).toBeInTheDocument();
});
테스트 세계에서는 다음과 같은 개념이 있습니다:
  • 설정 (테스트를 위한 세계를 설정)
  • 행동 (테스트할 코드를 실행)
  • 어설션 (테스트 중인 단위가 올바르게 작동하는지 확인하는 어설션 수행)
React Testing Library를 사용할 때는 act()를 피할 수 없습니다. 많은 핵심 기능에서 사용되기 때문입니다. 하지만 과도하게 사용하면 (예: 불필요한 act()로 모든 것을 감싸는 경우) 테스트 실행 시간이 영향을 받을 수 있습니다.
  • 항상 @testing-library/react에서 가져온 act()를 사용하고, react 패키지에서 가져온 것을 사용하지 마세요.
  • 항상 비동기 버전을 사용하세요 - await act(async () => ...)
  • 모든 것을 act()로 감싸지 마세요. 가능한 한 적게 사용하고, 매우 작은 청크 (이상적으로는 한 줄/함수 호출만)를 감싸세요.
act에 대한 경고가 나타나거나 테스트에서 상태 변경이 반영되지 않는 경우, 문제를 디버그하기 위해 일반적으로 수행하는 단계는 다음과 같습니다:
먼저, 경고를 트리거하는 컴포넌트의 코드/함수를 찾습니다:
  • 하나의 테스트에 .only를 추가하여 act() 경고가 있는 테스트를 찾습니다: test.only('my test', ...)
  • 경고가 발생하는 위치를 좁히기 위해 각 단계 후에 초기 return 문을 추가합니다.
  • 찾을 수 없습니까? render() 바로 뒤에 return을 추가하세요.
  • 경고가 나타나면: 컴포넌트에 자동 상태 업데이트가 있습니다 (비동기일 가능성이 높습니다).
이제 원인/위치를 알았으므로 문제를 해결하기 위한 팁 및 아이디어를 사용하세요:

테스트에서 수동 상태 변경을 act()로 감싸세요

테스트에서 상태 변경을 트리거하는 코드를 act()로 감싸세요:
await act(async () => {
// 상태 변경 코드를 여기에 작성하세요
});

DOM 업데이트를 기다리세요 (또는 findBy...)

테스트에서 .findBy...() (또는 waitFor()) 함수를 사용하여 어설션이 통과할 때까지 기다리세요. 이는 내부적으로 act()를 사용하므로 경고가 사라질 것입니다.
await screen.findByText(
'expected text'
);
await waitFor(() =>
expect(
someMockFunction
).toHaveBeenCalled()
);
  • await가 누락되었습니까? 모든 act() 호출 앞에 await를 추가하세요.
  • 이중 래핑? 다른 act() 내부에 act()를 래핑하지 않았는지 확인하세요.
  • 서드파티 컴포넌트? 드롭다운, 모달은 종종 문제를 일으킵니다.
  • 테스트 정리? beforeEach/afterEach 훅이 모든 것을 지우는지 확인하세요.
  • useEffect 정리? 컴포넌트의 useEffect에서 반환된 정리 함수를 확인하세요.
  • 테스트 종료 시 플러시? 테스트 종료 시 상태 변경이 발생할 때 act()가 실행되도록 await act(() => {})를 추가하세요.
이 오류는 act()를 호출할 때 global.IS_REACT_ACT_ENVIRONMENTtrue와 같은지 확인하기 때문에 발생합니다.
React Testing Library를 사용하면 (강력히 권장합니다 - 이 사이트 HowToTestFrontend.com은 이를 사용한다고 가정합니다), 이를 설정할 필요가 없습니다.
하지만 설정해야 하는 경우, Vitest 또는 Jest 설정 파일에서 다음을 설정하세요:
global.IS_REACT_ACT_ENVIRONMENT = true;

0
17

댓글

?

아직 댓글이 없습니다.

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