Xin chào các bạn trên chặng đường chinh phục Lộ trình React Roadmap! Sau khi đã cùng nhau khám phá những viên gạch nền móng như React là gì, làm quen với JSX, hiểu về Props vs State, xử lý sự kiện, quản lý state với Hooks (useReducer, useContext, hay các thư viện như Redux Toolkit…), chúng ta đã xây dựng nên những component hoạt động. Nhưng làm sao để chắc chắn rằng các component này *luôn* hoạt động đúng như mong đợi, ngay cả khi chúng ta thay đổi code bên trong? Đó chính là lúc kiểm thử phát huy sức mạnh.
Trong thế giới frontend hiện đại, kiểm thử (testing) không còn là lựa chọn mà là một phần thiết yếu của quy trình phát triển. Nó giúp chúng ta phát hiện lỗi sớm, tăng sự tự tin khi refactoring (tái cấu trúc mã), và đảm bảo ứng dụng hoạt động ổn định cho người dùng cuối. Đặc biệt với React, một thư viện xây dựng giao diện người dùng, việc kiểm thử UI components là cực kỳ quan trọng.
Có nhiều cách tiếp cận kiểm thử UI. Một số cách truyền thống tập trung vào việc kiểm tra chi tiết triển khai bên trong component (như state nội bộ, props được truyền, phương thức nào được gọi…). Tuy nhiên, cách tiếp cận này có một nhược điểm lớn: các bài test trở nên "mong manh" (fragile). Chỉ cần bạn thay đổi một chút cách component quản lý state, hoặc đổi tên một biến nội bộ, bài test có thể bị fail dù chức năng mà người dùng trải nghiệm vẫn hoạt động đúng.
React Testing Library (RTL) ra đời với một triết lý hoàn toàn khác biệt và mạnh mẽ hơn: **Kiểm thử hành vi người dùng, không phải chi tiết triển khai.** (Testing User Behavior, Not Implementation Details). Bài viết này sẽ đi sâu vào triết lý này và cách RTL giúp chúng ta hiện thực hóa nó.
Mục lục
Kiểm Thử Hành Vi Người Dùng Là Gì? Tại Sao Nó Quan Trọng?
Hãy tưởng tượng bạn là một người dùng bình thường đang tương tác với ứng dụng web. Bạn không quan tâm component của trang đó được viết bằng Class hay Functional Component (như chúng ta đã tìm hiểu), hay state được lưu trữ bằng `useState` hay `useReducer`. Điều bạn quan tâm là:
- Bạn nhìn thấy những gì trên màn hình? Có đúng nội dung bạn mong đợi không?
- Khi bạn nhấn vào một nút, hành động đó có xảy ra không? Kết quả hiển thị có thay đổi đúng không?
- Khi bạn nhập dữ liệu vào một ô input, dữ liệu đó có được xử lý đúng không?
Kiểm thử hành vi người dùng chính là mô phỏng lại cách một người dùng thực sự tương tác với ứng dụng của bạn thông qua giao diện đồ họa (DOM). Thay vì kiểm tra xem state có giá trị là X hay không, chúng ta kiểm tra xem text hiển thị trên màn hình có phải là "Số lượng: 1" sau khi nhấn nút tăng hay không.
Ưu điểm của việc kiểm thử hành vi người dùng với RTL:
- Độ tin cậy cao hơn: Bài test kiểm chứng xem ứng dụng có *thực sự hoạt động* cho người dùng cuối hay không. Một bài test qua pass với RTL mang lại sự tin tưởng lớn hơn rằng chức năng đó hoạt động đúng trong môi trường thực tế.
- Dễ dàng Refactor: Khi bạn cần thay đổi cấu trúc nội bộ của component (ví dụ: chuyển từ Class sang Function, đổi tên hook tùy chỉnh – custom hook, thay đổi cách xử lý state – ví dụ dùng useMemo/useCallback), nếu hành vi hiển thị và tương tác cho người dùng không thay đổi, các bài test RTL của bạn sẽ vẫn pass. Điều này làm cho việc refactor trở nên an toàn và ít tốn kém hơn.
- Khuyến khích khả năng tiếp cận (Accessibility): RTL khuyến khích bạn tìm kiếm các phần tử trên trang giống như cách mà các công nghệ hỗ trợ (như trình đọc màn hình) hoặc người dùng sử dụng bàn phím thực hiện. Điều này thường dẫn đến việc sử dụng các thuộc tính HTML ngữ nghĩa (`role`, `aria-label`, `alt`, `label` kết hợp với input) thay vì các selector dựa trên class CSS hoặc ID tự tạo không có ý nghĩa. Kết quả là code của bạn không chỉ dễ test hơn mà còn dễ tiếp cận hơn cho người dùng khuyết tật.
- Gần với kiểm thử tích hợp (Integration Testing): Mặc dù thường được xếp vào loại unit test (vì kiểm thử từng component riêng lẻ), RTL có cách tiếp cận gần với integration test hơn. Nó render component trong một môi trường DOM giả lập (hoặc thật trong một số trường hợp), cho phép bạn kiểm tra sự tương tác giữa các phần tử con và cách component đó hoạt động như một khối hoàn chỉnh, thay vì chỉ kiểm tra logic nội bộ tách rời.
Giới Thiệu React Testing Library
React Testing Library là một tập hợp các utility giúp bạn kiểm thử React components một cách thân thiện với người dùng. Nó được xây dựng bởi Kent C. Dodds và cộng đồng, và nhanh chóng trở thành thư viện kiểm thử React được đề xuất và sử dụng rộng rãi nhất hiện nay.
RTL không phải là một framework kiểm thử hoàn chỉnh (như Jest hay Mocha). Nó là một thư viện cung cấp các phương thức để render component của bạn và tương tác với DOM được tạo ra. Bạn thường sử dụng RTL kết hợp với một framework kiểm thử như Jest (như chúng ta đã nói về unit test với Jest).
Cài đặt:
npm install --save-dev @testing-library/react @testing-library/jest-dom jest
hoặc
yarn add --dev @testing-library/react @testing-library/jest-dom jest
(Lưu ý: Nếu bạn dùng `create-react-app` hoặc Vite, RTL và Jest thường đã được cài đặt sẵn)
Thư viện `@testing-library/jest-dom` cung cấp các custom matchers cho Jest, giúp bạn viết các assertion về trạng thái của DOM một cách dễ đọc hơn (ví dụ: `toBeInTheDocument()`, `toHaveTextContent()`).
Các Khái Niệm Cơ Bản trong RTL
1. Rendering Components (`render`)
Để bắt đầu kiểm thử một component, bạn cần render nó vào một môi trường DOM giả lập (hoặc thật). Hàm `render` từ `@testing-library/react` làm điều này:
import { render, screen } from '@testing-library/react';
import MyComponent from './MyComponent';
test('renders MyComponent', () => {
render(<MyComponent />);
// ... giờ bạn có thể tương tác với DOM
});
Hàm `render` trả về một số utility, nhưng phổ biến nhất và được khuyến khích sử dụng là đối tượng `screen`. `screen` chứa tất cả các query methods (sẽ nói ở phần sau) và có thể được sử dụng ở bất kỳ đâu trong file test sau khi component đã được render.
2. Queries: Tìm Kiếm Phần Tử Giống Như Người Dùng
Đây là trái tim của RTL và là nơi triết lý "kiểm thử hành vi người dùng" tỏa sáng. Thay vì tìm kiếm phần tử bằng class CSS hay ID ngẫu nhiên, RTL khuyến khích bạn tìm kiếm bằng các cách mà người dùng hoặc công nghệ hỗ trợ sẽ làm:
- `getByRole`: Tìm kiếm theo vai trò ARIA (Accessible Rich Internet Applications). Đây là phương thức ưu tiên nhất. Ví dụ: nút (`button`), liên kết (`link`), tiêu đề (`heading`), checkbox (`checkbox`), textbox (`textbox`). Bạn có thể lọc thêm bằng `name` (text hiển thị hoặc `aria-label`). Ví dụ: `screen.getByRole(‘button’, { name: /submit/i })`.
- `getByLabelText`: Tìm kiếm phần tử (thường là input, textarea, select) bằng text của label liên kết với nó. Rất tốt cho form. Ví dụ: `screen.getByLabelText(/username/i)`.
- `getByPlaceholderText`: Tìm kiếm input hoặc textarea bằng text của placeholder. Ít ưu tiên hơn `getByLabelText`.
- `getByText`: Tìm kiếm phần tử chứa text cụ thể. Hữu ích cho đoạn văn bản, tiêu đề, nội dung nút không có vai trò rõ ràng. Ví dụ: `screen.getByText(/Hello, world!/i)`.
- `getByDisplayValue`: Tìm kiếm input, textarea, select bằng giá trị hiện tại của nó.
-
`getByAltText`: Tìm kiếm các phần tử như `
`, ` `, `` bằng text của thuộc tính `alt`. Quan trọng cho hình ảnh.
- `getByTitle`: Tìm kiếm phần tử bằng text của thuộc tính `title`.
- `getByTestId`: Phương pháp cuối cùng nếu không có cách nào trên hoạt động. Bạn thêm thuộc tính `data-testid` vào phần tử HTML. Ví dụ: `<div data-testid=”user-greeting”>Hi!</div>`. Sau đó tìm kiếm: `screen.getByTestId(‘user-greeting’)`. **Chỉ sử dụng cách này khi không có cách nào dựa trên text hoặc vai trò hoạt động**, vì nó dễ bị ảnh hưởng bởi thay đổi code hơn các phương thức khác.
Mỗi loại query (`getBy`, `queryBy`, `findBy`) có các tiền tố (prefix) khác nhau, ảnh hưởng đến hành vi của nó:
Tiền Tố Query | Mục Đích | Hành Vi | Trường Hợp Sử Dụng |
---|---|---|---|
get* |
Tìm một (hoặc nhiều) phần tử đồng bộ. | Nếu tìm thấy: trả về phần tử. Nếu không tìm thấy: ném lỗi (error). Nếu tìm thấy nhiều hơn một (với variant số nhiều `getAll*`): ném lỗi. |
Dùng cho các phần tử chắc chắn có trên trang khi component được render. |
query* |
Tìm một (hoặc nhiều) phần tử đồng bộ. | Nếu tìm thấy: trả về phần tử. Nếu không tìm thấy: trả về `null`. Nếu tìm thấy nhiều hơn một (với variant số nhiều `queryAll*`): ném lỗi. |
Dùng để kiểm tra xem một phần tử không có trên trang hay không (`expect(queryBy…).toBeNull()`) hoặc khi phần tử có thể có hoặc không có tùy theo điều kiện ban đầu. |
find* |
Tìm một (hoặc nhiều) phần tử bất đồng bộ. | Trả về một `Promise`. Promise phân giải (resolve) khi tìm thấy phần tử. Promise bị từ chối (reject) nếu không tìm thấy phần tử sau một thời gian chờ mặc định (hoặc được cấu hình). |
Dùng cho các phần tử xuất hiện trên trang sau một hành động bất đồng bộ (ví dụ: gọi API, đợi hiệu ứng chuyển động kết thúc, cập nhật state sau một khoảng thời gian). Yêu cầu sử dụng `async/await`. |
Lưu ý: Luôn ưu tiên các phương thức query dựa trên text, vai trò hoặc label trước khi dùng `getByTestId`.
3. Tương Tác với Phần Tử (`fireEvent`, `userEvent`)
Sau khi tìm thấy phần tử, bạn cần mô phỏng hành vi của người dùng, ví dụ như click chuột, nhập text, blur input, v.v. RTL cung cấp hai cách chính:
- `fireEvent`: Đây là cách đơn giản để kích hoạt các sự kiện DOM. Ví dụ: `fireEvent.click(myButton)`. Nó hoạt động ở mức độ thấp, chỉ kích hoạt sự kiện DOM cụ thể mà không mô phỏng đầy đủ trình tự các sự kiện mà người dùng thực hiện.
- `@testing-library/user-event`: Đây là thư viện đi kèm và được khuyến khích sử dụng hơn. Nó mô phỏng hành vi người dùng một cách chân thực hơn bằng cách kích hoạt chuỗi các sự kiện đầy đủ mà trình duyệt sẽ tạo ra. Ví dụ: `userEvent.click(myButton)` sẽ kích hoạt `mousedown`, `focus`, `mouseup`, và `click` theo đúng thứ tự. `userEvent.type(myInput, ‘hello’)` sẽ mô phỏng từng phím gõ, bao gồm cả các sự kiện `keydown`, `keypress`, `input`, `keyup`.
Để sử dụng `userEvent`, bạn cần cài đặt thêm:
npm install --save-dev @testing-library/user-event
và import vào file test:
import userEvent from '@testing-library/user-event';
4. Assertions: Kiểm Chứng Kết Quả
Sau khi render component và mô phỏng tương tác, bước cuối cùng là kiểm tra xem giao diện có thay đổi đúng như mong đợi hay không. RTL kết hợp tốt với Jest và các matchers từ `@testing-library/jest-dom`.
Một số matchers phổ biến:
- `toBeInTheDocument()`: Kiểm tra xem phần tử có tồn tại trong DOM hay không.
- `toBeVisible()`: Kiểm tra xem phần tử có hiển thị cho người dùng hay không (không bị `display: none`, `visibility: hidden`, v.v.).
- `toHaveTextContent()`: Kiểm tra nội dung text của phần tử.
- `toHaveAttribute()`: Kiểm tra thuộc tính HTML.
- `toHaveClass()`: Kiểm tra class CSS.
- `toBeDisabled()`, `toBeEnabled()`: Kiểm tra trạng thái của input, button, v.v.
- `toBeChecked()`, `toBePartiallyChecked()`: Kiểm tra trạng thái của checkbox, radio.
- `toHaveValue()`: Kiểm tra giá trị của input, textarea, select.
Ví Dụ Thực Tế: Kiểm Thử Component Counter
Hãy cùng áp dụng các khái niệm trên vào việc kiểm thử một component Counter đơn giản.
Component `Counter.js`:
import React, { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
<button onClick={() => setCount(count - 1)} disabled={count === 0}>
Decrement
</button>
</div>
);
}
export default Counter;
File test `Counter.test.js`:
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; // Sử dụng user-event để mô phỏng tương tác người dùng
import '@testing-library/jest-dom'; // Để sử dụng các matchers như toBeInTheDocument
import Counter from './Counter';
describe('Counter Component', () => {
test('renders initial count', () => {
render(<Counter />);
// Tìm phần tử hiển thị số đếm ban đầu
// Sử dụng getByText vì đây là text mà người dùng nhìn thấy
const countElement = screen.getByText('Count: 0');
// Kiểm tra xem phần tử có hiển thị trong DOM không
expect(countElement).toBeInTheDocument();
});
test('increments count when Increment button is clicked', async () => { // Sử dụng async vì userEvent.click trả về Promise
render(<Counter />);
// Tìm nút Increment
// Sử dụng getByRole vì button có vai trò rõ ràng, kết hợp với name (text hiển thị)
const incrementButton = screen.getByRole('button', { name: /increment/i });
// Mô phỏng hành vi click của người dùng
await userEvent.click(incrementButton); // Await vì userEvent trả về Promise
// Kiểm tra xem số đếm đã cập nhật đúng chưa
// Tìm phần tử hiển thị số đếm mới ('Count: 1')
const updatedCountElement = screen.getByText('Count: 1');
expect(updatedCountElement).toBeInTheDocument();
// Có thể kiểm tra luôn phần tử cũ không còn tồn tại nếu muốn, nhưng thường không cần thiết
// const initialCountElement = screen.queryByText('Count: 0');
// expect(initialCountElement).toBeNull();
});
test('decrements count when Decrement button is clicked', async () => {
render(<Counter />);
// Đầu tiên tăng count lên 1 để nút Decrement không bị disabled
const incrementButton = screen.getByRole('button', { name: /increment/i });
await userEvent.click(incrementButton);
// Kiểm tra lại số đếm đã là 1
expect(screen.getByText('Count: 1')).toBeInTheDocument();
// Tìm nút Decrement
const decrementButton = screen.getByRole('button', { name: /decrement/i });
// Kiểm tra nút Decrement ban đầu không bị disabled sau khi tăng
expect(decrementButton).toBeEnabled();
// Mô phỏng hành vi click vào nút Decrement
await userEvent.click(decrementButton);
// Kiểm tra xem số đếm đã giảm về 0 chưa
expect(screen.getByText('Count: 0')).toBeInTheDocument();
// Kiểm tra nút Decrement đã bị disabled sau khi giảm về 0
expect(decrementButton).toBeDisabled();
});
test('Decrement button is disabled when count is 0', () => {
render(<Counter />);
// Tìm nút Decrement
const decrementButton = screen.getByRole('button', { name: /decrement/i });
// Kiểm tra xem nút có bị disabled ngay từ đầu không
expect(decrementButton).toBeDisabled();
});
});
Trong ví dụ trên:
- Chúng ta render component `Counter`.
- Sử dụng `screen.getByText` để tìm phần tử hiển thị số đếm ban đầu, đúng như cách người dùng nhìn thấy nội dung trên trang.
- Sử dụng `screen.getByRole` để tìm các nút, dựa vào vai trò ngữ nghĩa (`button`) và text hiển thị (`Increment`, `Decrement`).
- Sử dụng `userEvent.click` để mô phỏng hành vi click chuột của người dùng. Lưu ý dùng `await` vì `userEvent` trả về Promise.
- Sử dụng các matchers từ `jest-dom` (`toBeInTheDocument`, `toBeEnabled`, `toBeDisabled`) để kiểm tra trạng thái hiển thị và tương tác của các phần tử sau khi tương tác.
Không có chỗ nào trong bài test này chúng ta truy cập trực tiếp vào state `count` của component, hay gọi trực tiếp hàm `setCount`. Chúng ta chỉ tương tác với component thông qua giao diện DOM được render, đúng như cách người dùng thực sự sử dụng ứng dụng.
Một Vài Lời Khuyên Khi Sử Dụng RTL
- Ưu tiên Queries theo Thứ tự: Luôn cố gắng sử dụng các query ưu tiên cao hơn (`getByRole`, `getByLabelText`, `getByText`, `getByDisplayValue`, `getByAltText`, `getByTitle`) trước khi nghĩ đến `getByTestId`. Điều này giúp bài test của bạn ít phụ thuộc vào cấu trúc DOM cụ thể và khuyến khích code React của bạn thân thiện với Accessibility.
- Sử Dụng `userEvent` thay vì `fireEvent`: Để mô phỏng hành vi người dùng chân thực nhất, luôn ưu tiên `userEvent`.
- Test Tính Năng, Không Phải Phương Thức Nội Bộ: Đừng gọi trực tiếp các hàm xử lý sự kiện hay phương thức helper nội bộ của component. Hãy tương tác thông qua DOM và kiểm tra kết quả hiển thị.
- Sử Dụng `find*` cho Async Operations: Nếu bạn đang test một hành động dẫn đến thay đổi UI sau khi một Promise được giải quyết (ví dụ: gọi API – Fetch/Axios, chờ data từ React Query/SWR), hãy sử dụng `find*` queries với `async/await` để chờ đợi phần tử xuất hiện.
- Giữ Component Nhỏ Gọn: Việc kiểm thử sẽ dễ dàng hơn nhiều khi component của bạn tuân thủ nguyên tắc trách nhiệm đơn lẻ và có cấu trúc rõ ràng (kết hợp component hiệu quả).
- Kết Hợp Nhiều Loại Test: RTL rất tuyệt vời cho unit/integration test ở mức component. Tuy nhiên, bạn vẫn cần các loại test khác như E2E test với Cypress (như đã giới thiệu) để kiểm thử luồng đi toàn bộ ứng dụng, hoặc snapshot test để kiểm tra sự thay đổi không mong muốn của cấu trúc DOM.
RTL Trên Lộ Trình React
Việc học cách kiểm thử bằng React Testing Library là một cột mốc quan trọng trên hành trình trở thành một React Developer chuyên nghiệp. Nó không chỉ cung cấp cho bạn công cụ để viết test mà còn thay đổi cách bạn suy nghĩ về việc xây dựng giao diện – hướng tới người dùng và khả năng tiếp cận. Kiến thức về RTL sẽ bổ trợ đắc lực cho việc xây dựng các components (vòng đời component, làm việc với danh sách), xử lý form (React Hook Form, validation), và thậm chí là quản lý state phức tạp.
Kết Luận
React Testing Library với triết lý "kiểm thử hành vi người dùng, không phải chi tiết triển khai" đã cách mạng hóa cách chúng ta tiếp cận kiểm thử React components. Bằng cách tập trung vào cách người dùng tương tác và trải nghiệm ứng dụng, RTL giúp chúng ta viết các bài test đáng tin cậy, dễ bảo trì và khuyến khích xây dựng giao diện có khả năng tiếp cận cao.
Việc làm quen và thành thạo RTL là một kỹ năng vô giá cho bất kỳ React developer nào muốn xây dựng ứng dụng chất lượng cao và bền vững. Hãy bắt tay vào thực hành, viết test cho các component bạn đã tạo, và bạn sẽ thấy sự tự tin vào code của mình tăng lên đáng kể.
Chúc bạn thành công trên chặng đường học React! Hẹn gặp lại trong các bài viết tiếp theo của Lộ trình React Roadmap, nơi chúng ta sẽ tiếp tục khám phá những khía cạnh quan trọng khác của hệ sinh thái React.