เรียนรู้วิธีการใช้ `act` utility ในการทดสอบ React อย่างมีประสิทธิภาพ เพื่อให้แน่ใจว่าคอมโพเนนต์ของคุณทำงานตามที่คาดหวัง และหลีกเลี่ยงข้อผิดพลาดทั่วไป เช่น การอัปเดต state แบบอะซิงโครนัส
การทดสอบ React อย่างเชี่ยวชาญด้วย `act` Utility: คู่มือฉบับสมบูรณ์
การทดสอบเป็นรากฐานสำคัญของซอฟต์แวร์ที่แข็งแกร่งและบำรุงรักษาง่าย ในระบบนิเวศของ React การทดสอบอย่างละเอียดถี่ถ้วนเป็นสิ่งสำคัญเพื่อให้แน่ใจว่าคอมโพเนนต์ของคุณทำงานตามที่คาดหวังและมอบประสบการณ์ผู้ใช้ที่เชื่อถือได้ `act` utility ซึ่งมาจาก `react-dom/test-utils` เป็นเครื่องมือที่จำเป็นสำหรับการเขียนเทสต์ React ที่เชื่อถือได้ โดยเฉพาะอย่างยิ่งเมื่อต้องจัดการกับการอัปเดต state และ side effects แบบอะซิงโครนัส
`act` Utility คืออะไร?
`act` utility เป็นฟังก์ชันที่เตรียมคอมโพเนนต์ React ให้พร้อมสำหรับการยืนยันผล (assertions) โดยจะทำให้แน่ใจว่าการอัปเดตและ side effects ที่เกี่ยวข้องทั้งหมดได้ถูกนำไปใช้กับ DOM แล้ว ก่อนที่คุณจะเริ่มทำการยืนยันผล ลองนึกภาพว่ามันเป็นวิธีการซิงโครไนซ์การทดสอบของคุณกับ state ภายในและกระบวนการเรนเดอร์ของ React
โดยพื้นฐานแล้ว `act` จะครอบโค้ดใดๆ ที่ทำให้เกิดการอัปเดต state ของ React ซึ่งรวมถึง:
- Event handlers (เช่น `onClick`, `onChange`)
- `useEffect` hooks
- `useState` setters
- โค้ดอื่นๆ ที่แก้ไข state ของคอมโพเนนต์
หากไม่มี `act` เทสต์ของคุณอาจทำการยืนยันผลก่อนที่ React จะประมวลผลการอัปเดตทั้งหมดเสร็จสิ้น ซึ่งนำไปสู่ผลลัพธ์ที่ไม่แน่นอนและคาดเดายาก คุณอาจเห็นคำเตือนเช่น "An update to [component] inside a test was not wrapped in act(...)." คำเตือนนี้บ่งชี้ถึงสภาวะการแข่งขัน (race condition) ที่อาจเกิดขึ้น โดยที่เทสต์ของคุณกำลังทำการยืนยันผลก่อนที่ React จะอยู่ในสถานะที่สอดคล้องกัน
ทำไม `act` จึงสำคัญ?
เหตุผลหลักในการใช้ `act` คือเพื่อให้แน่ใจว่าคอมโพเนนต์ React ของคุณอยู่ในสถานะที่สอดคล้องและคาดเดาได้ระหว่างการทดสอบ ซึ่งช่วยแก้ปัญหาทั่วไปหลายประการ:
1. ป้องกันปัญหาการอัปเดต State แบบอะซิงโครนัส
การอัปเดต state ของ React มักจะเป็นแบบอะซิงโครนัส ซึ่งหมายความว่ามันไม่ได้เกิดขึ้นทันที เมื่อคุณเรียก `setState` React จะจัดตารางการอัปเดต แต่ยังไม่ได้นำไปใช้ในทันที หากไม่มี `act` เทสต์ของคุณอาจยืนยันค่าก่อนที่การอัปเดต state จะถูกประมวลผล ซึ่งนำไปสู่ผลลัพธ์ที่ไม่ถูกต้อง
ตัวอย่าง: การทดสอบที่ไม่ถูกต้อง (โดยไม่ใช้ `act`)
import React, { useState } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
function Counter() {
const [count, setCount] = useState(0);
const increment = () => {
setCount(count + 1);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
}
test('increments the counter', () => {
render(<Counter />);
const incrementButton = screen.getByText('Increment');
fireEvent.click(incrementButton);
expect(screen.getByText('Count: 1')).toBeInTheDocument(); // This might fail!
});
ในตัวอย่างนี้ การยืนยันผล `expect(screen.getByText('Count: 1')).toBeInTheDocument();` อาจล้มเหลวเนื่องจากการอัปเดต state ที่เกิดจาก `fireEvent.click` ยังไม่ถูกประมวลผลอย่างสมบูรณ์เมื่อทำการยืนยันผล
2. ทำให้แน่ใจว่า Side Effects ทั้งหมดถูกประมวลผล
`useEffect` hooks มักจะกระตุ้น side effects เช่น การดึงข้อมูลจาก API หรือการอัปเดต DOM โดยตรง `act` จะทำให้แน่ใจว่า side effects เหล่านี้เสร็จสมบูรณ์ก่อนที่เทสต์จะดำเนินต่อไป เพื่อป้องกันสภาวะการแข่งขันและรับประกันว่าคอมโพเนนต์ของคุณทำงานตามที่คาดหวัง
3. ปรับปรุงความน่าเชื่อถือและความสามารถในการคาดการณ์ของเทสต์
ด้วยการซิงโครไนซ์เทสต์ของคุณกับกระบวนการภายในของ React `act` ทำให้เทสต์ของคุณมีความน่าเชื่อถือและคาดการณ์ได้มากขึ้น ซึ่งช่วยลดโอกาสที่จะเกิดเทสต์ที่ไม่แน่นอน (flaky tests) ที่บางครั้งผ่านและบางครั้งล้มเหลว ทำให้ชุดการทดสอบของคุณน่าเชื่อถือยิ่งขึ้น
วิธีใช้ `act` Utility
`act` utility ใช้งานง่าย เพียงแค่ครอบโค้ดที่ทำให้เกิดการอัปเดต state หรือ side effects ของ React ด้วยการเรียก `act`
ตัวอย่าง: การทดสอบที่ถูกต้อง (ด้วย `act`)
import React, { useState } from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
function Counter() {
const [count, setCount] = useState(0);
const increment = () => {
setCount(count + 1);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
}
test('increments the counter', async () => {
render(<Counter />);
const incrementButton = screen.getByText('Increment');
await act(async () => {
fireEvent.click(incrementButton);
});
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
ในตัวอย่างที่แก้ไขนี้ การเรียก `fireEvent.click` ถูกครอบด้วยการเรียก `act` ซึ่งทำให้แน่ใจว่า React ได้ประมวลผลการอัปเดต state อย่างสมบูรณ์ก่อนที่จะทำการยืนยันผล
`act` แบบอะซิงโครนัส
`act` utility สามารถใช้ได้ทั้งแบบซิงโครนัสและอะซิงโครนัส เมื่อต้องจัดการกับโค้ดอะซิงโครนัส (เช่น `useEffect` hooks ที่ดึงข้อมูล) คุณควรใช้ `act` เวอร์ชันอะซิงโครนัส
ตัวอย่าง: การทดสอบ Side Effects แบบอะซิงโครนัส
import React, { useState, useEffect } from 'react';
import { render, screen, act } from '@testing-library/react';
async function fetchData() {
return new Promise(resolve => {
setTimeout(() => {
resolve('Fetched Data');
}, 50);
});
}
function MyComponent() {
const [data, setData] = useState(null);
useEffect(() => {
async function loadData() {
const result = await fetchData();
setData(result);
}
loadData();
}, []);
return <div>{data ? <p>{data}</p> : <p>Loading...</p>}</div>;
}
test('fetches data correctly', async () => {
render(<MyComponent />);
// Initial render shows "Loading..."
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Wait for the data to load and the component to update
await act(async () => {
// The fetchData function will resolve after 50ms, triggering a state update.
// The await here ensures we wait for act to complete all updates.
await new Promise(resolve => setTimeout(resolve, 0)); // A small delay to allow act to process.
});
// Assert that the data is displayed
expect(screen.getByText('Fetched Data')).toBeInTheDocument();
});
ในตัวอย่างนี้ `useEffect` hook จะดึงข้อมูลแบบอะซิงโครนัส การเรียก `act` ถูกใช้เพื่อครอบโค้ดอะซิงโครนัส เพื่อให้แน่ใจว่าคอมโพเนนต์ได้อัปเดตอย่างสมบูรณ์ก่อนที่จะทำการยืนยันผล บรรทัด `await new Promise` จำเป็นเพื่อให้ `act` มีเวลาในการประมวลผลการอัปเดตที่ถูกกระตุ้นโดยการเรียก `setData` ภายใน `useEffect` hook โดยเฉพาะในสภาพแวดล้อมที่ scheduler อาจทำให้การอัปเดตล่าช้า
แนวทางปฏิบัติที่ดีที่สุดสำหรับการใช้ `act`
เพื่อให้ได้ประโยชน์สูงสุดจาก `act` utility ให้ปฏิบัติตามแนวทางที่ดีที่สุดเหล่านี้:
1. ครอบการอัปเดต State ทั้งหมด
ตรวจสอบให้แน่ใจว่าโค้ดทั้งหมดที่ทำให้เกิดการอัปเดต state ของ React ถูกครอบอยู่ในการเรียก `act` ซึ่งรวมถึง event handlers, `useEffect` hooks และ `useState` setters
2. ใช้ `act` แบบอะซิงโครนัสสำหรับโค้ดอะซิงโครนัส
เมื่อจัดการกับโค้ดอะซิงโครนัส ให้ใช้ `act` เวอร์ชันอะซิงโครนัสเพื่อให้แน่ใจว่า side effects ทั้งหมดเสร็จสมบูรณ์ก่อนที่เทสต์จะดำเนินต่อไป
3. หลีกเลี่ยงการเรียก `act` ซ้อนกัน
หลีกเลี่ยงการเรียก `act` ซ้อนกัน การซ้อนกันอาจนำไปสู่พฤติกรรมที่ไม่คาดคิดและทำให้เทสต์ของคุณดีบักได้ยากขึ้น หากคุณต้องการดำเนินการหลายอย่าง ให้ครอบทั้งหมดไว้ในการเรียก `act` เพียงครั้งเดียว
4. ใช้ `await` กับ `act` แบบอะซิงโครนัส
เมื่อใช้ `act` เวอร์ชันอะซิงโครนัส ให้ใช้ `await` เสมอเพื่อให้แน่ใจว่าการเรียก `act` เสร็จสมบูรณ์ก่อนที่เทสต์จะดำเนินต่อไป สิ่งนี้สำคัญอย่างยิ่งเมื่อต้องจัดการกับ side effects แบบอะซิงโครนัส
5. หลีกเลี่ยงการครอบโค้ดที่ไม่จำเป็น (Over-Wrapping)
แม้ว่าการครอบการอัปเดต state จะเป็นสิ่งสำคัญ แต่ควรหลีกเลี่ยงการครอบโค้ดที่ไม่ได้ทำให้เกิดการเปลี่ยนแปลง state หรือ side effects โดยตรง การครอบโค้ดที่ไม่จำเป็นอาจทำให้เทสต์ของคุณซับซ้อนและอ่านยากขึ้น
6. ทำความเข้าใจ `flushMicrotasks` และ `advanceTimersByTime`
ในบางสถานการณ์ โดยเฉพาะอย่างยิ่งเมื่อต้องจัดการกับ mocked timers หรือ promises คุณอาจต้องใช้ `act(() => jest.advanceTimersByTime(time))` หรือ `act(() => flushMicrotasks())` เพื่อบังคับให้ React ประมวลผลการอัปเดตทันที เหล่านี้เป็นเทคนิคขั้นสูง แต่การทำความเข้าใจอาจเป็นประโยชน์สำหรับสถานการณ์อะซิงโครนัสที่ซับซ้อน
7. พิจารณาใช้ `userEvent` จาก `@testing-library/user-event`
แทนที่จะใช้ `fireEvent` ให้พิจารณาใช้ `userEvent` จาก `@testing-library/user-event` `userEvent` จำลองการโต้ตอบของผู้ใช้จริงได้แม่นยำกว่า และมักจะจัดการการเรียก `act` ภายในให้ ซึ่งนำไปสู่เทสต์ที่สะอาดและเชื่อถือได้มากขึ้น ตัวอย่างเช่น:
import React, { useState } from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
function MyComponent() {
const [value, setValue] = useState('');
const handleChange = (event) => {
setValue(event.target.value);
};
return (
<input type="text" value={value} onChange={handleChange} />
);
}
test('updates the input value', async () => {
render(<MyComponent />);
const inputElement = screen.getByRole('textbox');
await userEvent.type(inputElement, 'hello');
expect(inputElement.value).toBe('hello');
});
ในตัวอย่างนี้ `userEvent.type` จะจัดการการเรียก `act` ที่จำเป็นภายใน ทำให้เทสต์สะอาดและอ่านง่ายขึ้น
ข้อผิดพลาดทั่วไปและวิธีหลีกเลี่ยง
แม้ว่า `act` utility จะเป็นเครื่องมือที่มีประสิทธิภาพ แต่สิ่งสำคัญคือต้องระวังข้อผิดพลาดทั่วไปและวิธีหลีกเลี่ยง:
1. ลืมครอบการอัปเดต State
ข้อผิดพลาดที่พบบ่อยที่สุดคือการลืมครอบการอัปเดต state ด้วยการเรียก `act` ซึ่งอาจนำไปสู่เทสต์ที่ไม่แน่นอนและพฤติกรรมที่คาดเดาไม่ได้ ควรตรวจสอบซ้ำเสมอว่าโค้ดทั้งหมดที่ทำให้เกิดการอัปเดต state ถูกครอบด้วย `act`
2. การใช้ `act` แบบอะซิงโครนัสอย่างไม่ถูกต้อง
เมื่อใช้ `act` เวอร์ชันอะซิงโครนัส สิ่งสำคัญคือต้อง `await` การเรียก `act` การไม่ทำเช่นนั้นอาจนำไปสู่สภาวะการแข่งขันและผลลัพธ์ที่ไม่ถูกต้อง
3. การพึ่งพา `setTimeout` หรือ `flushPromises` มากเกินไป
แม้ว่าบางครั้ง `setTimeout` หรือ `flushPromises` สามารถใช้เพื่อแก้ปัญหาการอัปเดต state แบบอะซิงโครนัสได้ แต่ควรใช้อย่างประหยัด ในกรณีส่วนใหญ่ การใช้ `act` อย่างถูกต้องเป็นวิธีที่ดีที่สุดเพื่อให้แน่ใจว่าเทสต์ของคุณเชื่อถือได้
4. การเพิกเฉยต่อคำเตือน
หากคุณเห็นคำเตือนเช่น "An update to [component] inside a test was not wrapped in act(...)." อย่าเพิกเฉย! คำเตือนนี้บ่งชี้ถึงสภาวะการแข่งขันที่อาจเกิดขึ้นซึ่งจำเป็นต้องได้รับการแก้ไข
ตัวอย่างใน Testing Frameworks ต่างๆ
`act` utility ส่วนใหญ่เกี่ยวข้องกับ testing utilities ของ React แต่หลักการนี้สามารถนำไปใช้ได้ไม่ว่าคุณจะใช้ testing framework ใดก็ตาม
1. การใช้ `act` กับ Jest และ React Testing Library
นี่เป็นสถานการณ์ที่พบบ่อยที่สุด React Testing Library สนับสนุนการใช้ `act` เพื่อให้แน่ใจว่าการอัปเดต state เป็นไปอย่างถูกต้อง
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
// Component and test (as shown previously)
2. การใช้ `act` กับ Enzyme
Enzyme เป็นอีกหนึ่งไลบรารีการทดสอบ React ที่เป็นที่นิยม แม้ว่าจะพบได้น้อยลงเนื่องจาก React Testing Library ได้รับความนิยมมากขึ้น คุณยังสามารถใช้ `act` กับ Enzyme เพื่อให้แน่ใจว่าการอัปเดต state เป็นไปอย่างถูกต้อง
import React from 'react';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
// Example component (e.g., Counter from previous examples)
it('increments the counter', () => {
const wrapper = mount(<Counter />);
const button = wrapper.find('button');
act(() => {
button.simulate('click');
});
wrapper.update(); // Force re-render
expect(wrapper.find('p').text()).toEqual('Count: 1');
});
หมายเหตุ: กับ Enzyme คุณอาจต้องเรียก `wrapper.update()` เพื่อบังคับให้ re-render หลังจากเรียก `act`
`act` ในบริบทสากลต่างๆ
หลักการใช้ `act` เป็นสากล แต่การใช้งานจริงอาจแตกต่างกันเล็กน้อยขึ้นอยู่กับสภาพแวดล้อมและเครื่องมือเฉพาะที่ทีมพัฒนาต่างๆ ทั่วโลกใช้ ตัวอย่างเช่น:
- ทีมที่ใช้ TypeScript: types ที่มาจาก `@types/react-dom` ช่วยให้แน่ใจว่า `act` ถูกใช้อย่างถูกต้องและให้การตรวจสอบขณะคอมไพล์สำหรับปัญหาที่อาจเกิดขึ้น
- ทีมที่ใช้ CI/CD pipelines: การใช้ `act` อย่างสม่ำเสมอทำให้แน่ใจว่าเทสต์มีความน่าเชื่อถือและป้องกันความล้มเหลวที่ไม่มีสาเหตุในสภาพแวดล้อม CI/CD ไม่ว่าจะเป็นผู้ให้บริการโครงสร้างพื้นฐานใด (เช่น GitHub Actions, GitLab CI, Jenkins)
- ทีมที่ทำงานกับ internationalization (i18n): เมื่อทดสอบคอมโพเนนต์ที่แสดงเนื้อหาที่แปลเป็นภาษาท้องถิ่น สิ่งสำคัญคือต้องแน่ใจว่า `act` ถูกใช้อย่างถูกต้องเพื่อจัดการกับการอัปเดตแบบอะซิงโครนัสหรือ side effects ที่เกี่ยวข้องกับการโหลดหรืออัปเดตสตริงที่แปลแล้ว
บทสรุป
`act` utility เป็นเครื่องมือสำคัญสำหรับการเขียนเทสต์ React ที่เชื่อถือได้และคาดการณ์ได้ ด้วยการทำให้แน่ใจว่าเทสต์ของคุณซิงโครไนซ์กับกระบวนการภายในของ React `act` ช่วยป้องกันสภาวะการแข่งขันและทำให้แน่ใจว่าคอมโพเนนต์ของคุณทำงานตามที่คาดหวัง โดยการปฏิบัติตามแนวทางปฏิบัติที่ดีที่สุดที่ระบุไว้ในคู่มือนี้ คุณจะสามารถเชี่ยวชาญ `act` utility และเขียนแอปพลิเคชัน React ที่แข็งแกร่งและบำรุงรักษาง่ายขึ้น การเพิกเฉยต่อคำเตือนและข้ามการใช้ `act` จะสร้างชุดการทดสอบที่หลอกลวงนักพัฒนาและผู้มีส่วนได้ส่วนเสีย ซึ่งนำไปสู่ข้อบกพร่องในเวอร์ชันโปรดักชัน ควรใช้ `act` เสมอเพื่อสร้างเทสต์ที่น่าเชื่อถือ