Testing React Hook với Vitest một cách hiệu quả

Ngày nay thì việc làm ra một ứng dụng web xịn xò thì ngoài việc code theo chuẩn thì để dự án có chất lượng hơn bằng việc đọc code dễ hiểu, nhanh chóng nắm được luồng xử lý, dễ bảo trì và mở rộng trong tương lai, hầu hết mọi người đều áp dụng thêm phần Testing cho dự án và đặc biệt việc này không thể thiếu trong các sản phẩm lớn và sẽ được làm và mở rộng theo thời gian dài trong tương lai. Bài viết này chúng ta sẽ đi khám khá làm thế nào để viết test cho React Hook bằng cách sử dụng VitestReact Testing library sao cho để bảo trì và mở rộng

Cài đặt Vitest và JSdom

Được cung cấp bởi ViteVitest tuyên bố là “khung sườn kiểm thử đơn vị nhanh chóng” cho dự án khởi tạo từ Vite. Vitest cung cấp các chức năng và cú pháp tương tự như Jest, với TypeScript/JSX được hổ trợ đầy đủ đồng thời chế độ xem (HRM) và chạy kiểm thử của bạn nhanh hơn.

Mặc dù ban đầu được xây dựng cho dự án do Vite cung cấp, chúng ta cũng có thể sử dụng Vitest với một cấu hình phù hợp trong dự án không phải Vite, chẳng hạn Webpack.

Để cài đặt Vitest trong dự án React, chúng ta có thể cài đặt Vitest ở dạng thành phần thụ thuộc của nhà phát triển (devDependencies) với câu lệnh dưới đây

npm install -D vitest

Chúng ta cũng cài đặt jsdom (or hoặc bất kì triển khai DOM chuẩn nào) dưới dạng devDependencies:

npm install -D jsdom

Sau đó, trong vitest.config.js (hoặc vite.config.js cho các dự án Vite), chúng ta thêm đối tượng thử nghiệm sau vào cấu hình, với jsdom làm môi trường DOM để thử nghiệm:

//...
export default defineConfig({
  test: {
    environment: 'jsdom',
  },
})

Chúng ta cũng có thể thiết lập trường global: true như vậy chúng ta không cần phải nhập 1 cách rõ ràng cho mổi phương thức như expectdescribeit, hoặc vi instance từ gói vitest trong mổi file test

Sau khi thực hiện xong với vitest.config.js, chúng ta thêm 1 câu lệnh mới trong scripts trong tệp package.json cho việc chạy unit tests như sau:

"scripts": {
    "test:unit": "vitest --root src/"
}

Trong đoạn mã trên, chúng tôi đặt thư mục gốc của các thử nghiệm là thư mục src/. Ngoài ra, chúng ta có thể đặt nó trong vite.config.js trong trường test.root. Cả hai cách tiếp cận đều hoạt động như nhau. Tiếp theo, hãy thêm một số bài kiểm tra.

Kiểm tra hook thông thường với React Testing Library

Vitest hỗ trợ kiểm tra mọi mã JavaScript và TypeScript. Tuy nhiên, để kiểm tra các tính năng dành riêng cho thành phần React như hook React, chúng ta vẫn cần tạo một trình bao bọc cho hook mong muốn và mô phỏng quá trình thực thi hook.

Để làm được như vậy, chúng ta có thể cài đặt và sử dụng Render hooks from React Testing Library:

yarn add -D @testing-library/react-hooks

Sau khi hoàn thành, chúng ta có thể sử dụng phương thức renderHook từ package này để kết xuất hook mong muốn. renderHook sẽ trả về 1 đối tượng chứa một thuộc tính result và các phương thức hữu dụng khác như unmount và waitFor. Chúng ta sau đó có thể truy cập giá trị trả về của hook từ thuộc tính result.current.

Ví dụ, chúng ta hãy nhìn vào một hook useSearch mà nhận vào 1 mảng ban đầu và trả về 1 đối tưởng phản ứng searchTerm, một danh sách các mục đã được lọc, và một phương thức cập nhật cụm từ tìm kiếm. Việc thực hiện nó như sau:

//hooks/useSearch.ts
import { useState, useMemo } from "react";

export const useSearch = (items: any[]) => {
    const [searchTerm, setSearchTerm] = useState('');
    const filteredItems = useMemo(
            () => items.filter(
                movie => movie.title.toLowerCase().includes(searchTerm.toLowerCase())
            )
        , [items, searchTerm]);

    return {
        searchTerm,
        setSearchTerm,
        filteredItems
    };
}

Chúng ta có thể việc một kiểm thử để kiểm tra giá trị trả về mặc định của hook bao gồm searchTerm và cho filterItems như bên dưới:

import { expect, it, describe } from "vitest";
import { renderHook } from '@testing-library/react-hooks'
import { useSearch } from "./useSearch"

describe('useSearch', () => { 
    it('should return a default search term and original items', () => { 
        const items = [{ title: 'Star Wars' }];

        const { result } = renderHook(() => useSearch(items));

        expect(result.current.searchTerm).toBe('');
        expect(result.current.filteredItems).toEqual(items);
    });
});

Để kiểm tra xem hook có hoạt động hay không khi chúng ta cập nhật searchTerm, chúng ta có thể sử dụng phương thức act() để mô phỏng quá trình thực thi setSearchTerm, như trong trường hợp kiểm tra bên dưới:

import { /**... */ act } from "vitest";

//...
    it('should return a filtered list of items', () => { 
        const items = [
            { title: 'Star Wars' },
            { title: 'Starship Troopers' }
        ];

        const { result } = renderHook(() => useSearch(items));

        act(() => {
            result.current.setSearchTerm('Wars');
        });
        
        expect(result.current.searchTerm).toBe('Wars');
        expect(result.current.filteredItems).toEqual([{ title: 'Star Wars' }]);
    });
//...

Lưu ý ở đây bạn không thể hủy cấu trúc các thuộc tính phản ứng của phiên bản result.current, nếu không chúng sẽ mất khả năng phản ứng. Ví dụ: đoạn mã dưới đây sẽ không hoạt động:

const { searchTerm } = result.current;

act(() => {
    result.current.setSearchTerm('Wars');
});

expect(searchTerm).toBe('Wars'); // This won't work

Tiếp theo, chúng ta có thể chuyển sang thử nghiệm hook useMovies phức tạp hơn chứa logic bất đồng bộ.

Thử nghiệm hook với logic bất đồng bộ

Hãy xem ví dụ triển khai useMovies hook dưới đây:

export const useMovies = ():{ movies: Movie[], isLoading: boolean, error: any } => {
    const [movies, setMovies] = useState([]);

    const fetchMovies = async () => {
        try {
            setIsLoading(true);
            const response = await fetch("https://swapi.dev/api/films");

            if (!response.ok) {
                throw new Error("Failed to fetch movies");
            }

            const data = await response.json();
            setMovies(data.results);
        } catch (err) {
        //do something
        } finally {
        //do something
        }
    };

    useEffect(() => {
        fetchMovies();
    }, []);

    return { movies }
}

Trong đoạn mã ở trên, hook chạy phương thức bất đồng bộ fetchMovies trong lần kết suất đầu tiên bằng sử dụng hook useEffect. Việc triển khai này dẫn tới 1 vấn đề khi chúng ta cố gắng kiểm thử hook, vì phương thức renderHook từ @testing-library/react-hooks không đợi cuộc gọi bất đồng bộ kết thúc. Vì vậy chúng ta không biết khi nào quá trình nạp sẽ được giải quyết, chúng ta sẽ không thể biết được giá trị movies trả về khi nó hoàn thành.

Để giải quyết điều đó, chúng ta có thể sử dụng phương thức waitFor từ gói @testing-library/react-hooks, như đoạn mã sau:

/**useMovies.test.ts */
describe('useMovies', () => {
    //...
    it('should fetch movies', async () => {
        const { result, waitFor } = renderHook(() => useMovies());

        await waitFor(() => {
            expect(result.current.movies).toEqual([{ title: 'Star Wars' }]);
        });
    });
    //...
});

waitFor chấp nhận một callback và trả về một Promise mà giải quyết khi callback được thực thi thành công. Trong đoạn mã trên, chúng ta đợi cho giá trị movies bằng với giá trị kì vọng. Tùy chọn, chúng ta có thể truyền một đối tượng làm đối số thứ hai cho waitFor để cấu hình thời gian chờ và khoảng thời gian bỏ phiếu. Ví dụ, chúng ta có thể cấu hình thời gian chờ 1000ms như bên dưới:

await waitFor(() => {
    expect(result.current.movies).toEqual([{ title: 'Star Wars' }]);
}, {
    timeout: 1000
});

Bằng việc làm như vậy, nếu giá trị movies không bằng với giá trị kì vọng sau 1000ms, kiểm thử sẽ lỗi.

Theo dõi và kiểm tra cuộc gọi API bên ngoài bằng cách sử dụng spyOn và waitFor

Trong bài kiểm tra trước cho hook useMovies, chúng ta đang tìm nạp dữ liệu bên ngoài bằng fetch API, điều này không lý tưởng cho việc thử nghiệm đơn vị. Thay vì, chúng ta nên sử dụng phương thức vi.spyOn (với vi như một thể hiện của ViTest) để giả mạo trên phương thức global.fetch và mô phỏng việc thực hiện nó để trả về 1 phản hồi giả, như đoạn mã bên dưới:

import { /**... */ vi, beforeAll } from "vitest";

describe('useMovies', () => {
    //Spy on the global fetch function
    const fetchSpy = vi.spyOn(global, 'fetch');

    //Run before all the tests
    beforeAll(() => {
        //Mock the return value of the global fetch function
        const mockResolveValue = { 
            ok: true,
            json: () => new Promise((resolve) => resolve({ 
                results: [{ title: 'Star Wars' }] 
            }))
        };

        fetchSpy.mockReturnValue(mockResolveValue as any);
    });

    it('should fetch movies', async () => { /**... */ }
});

Trong đoạn mã phía trên, chúng ta giả định giá trị trả về của fetchSpy sử dụng phương thức  mockReturnValue() của nó với giá trị chúng ta đã tạo. Với việc triển khai này, chúng ta có thể chạy thử nghiệm của chúng ta mà không phải kích hoạt cuộc giọ API thật, làm giảm cơ hội kiểm tra thất bại do yếu tố bên ngoài.

Và vì chúng tôi mô phỏng giá trị trả về của phương thức tìm nạp, chúng ta cần phải phục hồi triển khai ban đầu của nó sau khi thử nghiệm kết thúc, bằng cách sử dụng phương thức mockRestore từ Vitest, như đoạn mã bên dưới:

import { /**... */ vi, beforeAll, afterAll } from "vitest";

describe('useMovies', () => {
    const fetchSpy = vi.spyOn(global, 'fetch');

    /**... */

    //Run after all the tests
    afterAll(() => {
        fetchSpy.mockRestore();
    });
});

Ngoài ra, chúng ta cũng có thể sử dụng phương thức mockClear() để xóa tất cả thông tin giả, chẳng hạn như số lượng cuộc gọi và kết quả của bản mô phỏng. Phương pháp này tiện dụng khi khẳng định các cuộc gọi mô phỏng hoặc mô phỏng cùng một chức năng với các giá trị trả về khác nhau cho các thử nghiệm khác nhau. Chúng ta thường sử dụng phương thức mockClear() trong beforeEach hoặc afterEach để chắn chắn kiểm thử của chúng ta được cô lập 1 cách hoàn toàn.

Đó là tất cả. Bây giờ bạn có thể tiếp tục và thử nghiệm các móc tùy chỉnh của mình một cách hiệu quả.

Nguồn: https://mayashavin.com/articles/test-react-hooks-with-vitest

Happy testing 🙂

5/5 - (1 vote)

About the Author: truongluu

Leave a Reply

Your email address will not be published. Required fields are marked *