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
forwardRefhoặc controlled input nội bộ - Gọi
fireEventrồi assert đồng bộ trên state bất đồng bộ - Hook
useEffectkhở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 mount →
await screen.findBy*()hoặcawait waitFor() - Tương tác người dùng kích hoạt state bất đồng bộ →
userEventvớiawait - Component dùng
setTimeoutnội bộ → Jest fake timer bên trongact() - 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.

