Sửa lỗi 'Warning: An update to inside a test was not wrapped in act(...)' trong React Testing

intermediate⚛️ React2026-04-07| React 16.8+, React Testing Library 8+, Jest, Node.js

Error Message

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

Lỗi Này Là Gì

Bạn viết một test, nó pass, nhưng console in ra thông báo này:

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

Dấu tích xanh, cờ đỏ. Test pass nhưng React đang báo rằng nó xử lý xong state sau khi assertion của bạn đã chạy rồi. Đây là race condition ẩn ngay trước mắt — và nó tạo ra những test chạy lúc được lúc không, thất bại không rõ lý do trên CI.

Tại Sao Lỗi Này Xảy Ra

act() là tiện ích test của React để flush các state update, effect và re-render trước khi bạn assert bất cứ điều gì. Hãy nghĩ nó như một cái cống — nó làm trống hàng đợi để bạn assert trên một snapshot ổn định.

Cảnh báo này xuất hiện khi có gì đó kích hoạt state update ngoài chu kỳ flush đó. Các nguyên nhân điển hình:

  • State update bất đồng bộ — fetch dữ liệu, setTimeout, setInterval
  • Event handler cập nhật state nhưng không được await trong test
  • Component bên thứ ba dùng forwardRef hoặc controlled input nội bộ
  • Gọi fireEvent rồi assert đồng bộ trên state bất đồng bộ
  • Hook useEffect khởi động công việc bất đồng bộ khi mount

Tái Hiện Vấn Đề

Đây là trường hợp tối giản. Một component fetch tên người dùng khi mount:

// UserProfile.tsx
import { useState, useEffect } from 'react';

export function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<string | null>(null);

  useEffect(() => {
    fetchUser(userId).then((name) => setUser(name)); // state update bất đồng bộ
  }, [userId]);

  return <div>{user ?? 'Loading...'}</div>;
}

async function fetchUser(id: string): Promise<string> {
  return Promise.resolve('Alice');
}
// UserProfile.test.tsx — BỊ LỖI
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';

test('renders user name', () => {
  render(<UserProfile userId="1" />);
  // fetchUser resolve SAU khi dòng này chạy,
  // nên setUser kích hoạt ngoài act()
  expect(screen.getByText('Loading...')).toBeInTheDocument();
});
// → Warning: An update to ... inside a test was not wrapped in act(...)

Cách Sửa 1: Dùng waitFor hoặc findBy* (Khuyến Nghị)

Các tiện ích bất đồng bộ của React Testing Library tự bọc chúng trong act() cho bạn. Hãy dùng cách này trước tiên.

// UserProfile.test.tsx — ĐÃ SỬA
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';

test('renders user name after fetch', async () => {
  render(<UserProfile userId="1" />);

  // waitFor thử lại assertion mỗi 50ms (mặc định tối đa 1000ms)
  // và tự xử lý việc bọc act() bên trong
  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeInTheDocument();
  });
});

Ưu tiên dùng findBy* khi bạn chỉ cần chờ một phần tử duy nhất — nó là waitFor + getBy* gộp làm một:

test('renders user name after fetch', async () => {
  render(<UserProfile userId="1" />);

  const name = await screen.findByText('Alice'); // polling đến khi tìm thấy, act() được xử lý tự động
  expect(name).toBeInTheDocument();
});

Cách Sửa 2: Mock Lời Gọi Bất Đồng Bộ Và Flush Thủ Công

Đôi khi bạn cần kiểm soát chặt hơn — ví dụ, assert trạng thái loading trung gian. Mock hàm đó và xả hàng đợi microtask bằng act():

import { render, screen, act } from '@testing-library/react';
import { UserProfile } from './UserProfile';
import * as api from './api';

jest.mock('./api');

test('renders user name', async () => {
  jest.spyOn(api, 'fetchUser').mockResolvedValue('Alice');

  render(<UserProfile userId="1" />);

  await act(async () => {
    await Promise.resolve(); // một tick để flush tất cả microtask đang chờ
  });

  expect(screen.getByText('Alice')).toBeInTheDocument();
});

Cách Sửa 3: Bọc User Event Đúng Cách

Click button và submit form kích hoạt state bất đồng bộ cũng là nguồn gây lỗi phổ biến. fireEvent là đồng bộ — nó dispatch event nhưng không chờ state update kết quả được áp dụng.

// SAI — state update sau click có thể chưa được flush
fireEvent.click(button);
expect(screen.getByText('Saved')).toBeInTheDocument(); // có thể gây cảnh báo

// ĐÚNG — userEvent xử lý việc bọc act() và mô phỏng hành vi trình duyệt thực
import userEvent from '@testing-library/user-event';

test('saves form', async () => {
  const user = userEvent.setup();
  render(<MyForm />);

  await user.click(screen.getByRole('button', { name: /save/i }));

  expect(screen.getByText('Saved')).toBeInTheDocument();
});

Mặc định dùng userEvent cho bất cứ thứ gì mô phỏng tương tác người dùng thực. Dùng fireEvent cho các DOM event đơn giản, đồng bộ mà không có state update theo sau.

Cách Sửa 4: Xử Lý setTimeout Và Timer

Các component trì hoãn state update qua setTimeout cần fake timer của Jest. Tua nhanh chúng bên trong act() để React xử lý state change kết quả trước khi bạn assert:

jest.useFakeTimers();

test('shows message after 2-second delay', () => {
  render(<DelayedMessage />);

  act(() => {
    jest.runAllTimers(); // nhảy đồng hồ về phía trước, kích hoạt các callback đang chờ
  });

  expect(screen.getByText('Hello!')).toBeInTheDocument();
});

jest.useRealTimers();

Cách Sửa 5: Tắt Cảnh Báo Từ Thư Viện Bên Thứ Ba

Date picker, modal và thư viện animation đôi khi kích hoạt cảnh báo nội bộ — trong code bạn không sở hữu và không thể vá. Nếu bạn đã xác nhận logic test của mình là đúng, hãy giới hạn việc tắt cảnh báo chỉ trong file đó:

const originalError = console.error;
beforeAll(() => {
  jest.spyOn(console, 'error').mockImplementation((...args) => {
    if (typeof args[0] === 'string' && args[0].includes('not wrapped in act')) return;
    originalError(...args);
  });
});

afterAll(() => {
  (console.error as jest.Mock).mockRestore();
});

Chỉ dùng như giải pháp cuối cùng. Hãy kiểm tra tài liệu của thư viện để tìm hướng dẫn thiết lập test trước — hầu hết các package phổ biến đều có ghi chú về điều này.

Tăng Cường Test Suite: Biến Cảnh Báo Thành Lỗi

Chạy test ở chế độ verbose để phát hiện cảnh báo còn sót lại:

npx jest --verbose 2>&1 | grep -i 'act\|warning'
# Output sạch nghĩa là không còn cảnh báo act() nào

Để biến cảnh báo thành lỗi test cứng — không có gì lọt qua — hãy thêm đoạn này vào file setup của Jest:

// jest.setup.ts
const originalError = console.error.bind(console);
console.error = (...args) => {
  if (
    typeof args[0] === 'string' &&
    args[0].includes('not wrapped in act')
  ) {
    throw new Error(args[0]); // làm test fail ngay lập tức
  }
  originalError(...args);
};

Kết nối nó trong jest.config.ts:

// jest.config.ts
export default {
  setupFilesAfterEnv: ['./jest.setup.ts'],
};

Hướng Dẫn Quyết Định Nhanh

  • Component fetch dữ liệu khi mountawait screen.findBy*() hoặc await waitFor()
  • Tương tác người dùng kích hoạt state bất đồng bộuserEvent với await
  • Component dùng setTimeout nội bộ → Jest fake timer bên trong act()
  • Cảnh báo từ thư viện bạn không kiểm soát → kiểm tra tài liệu test của thư viện; chỉ tắt cảnh báo nếu không có cách sửa nào khác

Ý Tưởng Cốt Lõi

React thêm cảnh báo này để bắt một loại bug test cụ thể: assert state trước khi chu kỳ update hoàn tất. Chín trong mười lần, cách đúng là làm test thành async và dùng async query của RTL — findBy*, waitFor, userEvent. Chúng đã gọi act() bên dưới. Bọc act() thủ công chỉ dành cho các trường hợp ngoại lệ, không phải con đường thông thường.

Related Error Notes