Kiểm Tra Custom Hook Phức Tạp: Đừng Lo, Có `react-hooks-testing-library`!

Kiểm Tra Custom Hook Phức Tạp: Đừng Lo, Có `react-hooks-testing-library`!

Tại Sao Custom Hook Phức Tạp Lại Khó Test?

Chào các bạn, là tôi đây, blogger công nghệ quen thuộc của bạn! Nếu bạn đang "vật lộn" với React, chắc hẳn bạn đã quen thuộc với Custom Hooks – công cụ tuyệt vời để tái sử dụng logic trạng thái. Nhưng khi logic trong hook trở nên phức tạp, việc kiểm tra chúng có thể biến thành một cơn ác mộng. Làm thế nào để đảm bảo hook của bạn hoạt động đúng như mong đợi, đặc biệt khi có các thao tác bất đồng bộ, quản lý state phức tạp hay tương tác với Context API? Đừng lo, hôm nay chúng ta sẽ cùng nhau "giải mã" bí ẩn này với một công cụ cực kỳ mạnh mẽ: react-hooks-testing-library.

Bạn có thể nghĩ: 'Cứ render một component dùng hook đó rồi test như bình thường là được chứ gì?'. Đúng một phần, nhưng cách tiếp cận này thường buộc bạn phải render toàn bộ UI, làm cho test trở nên chậm chạp, cồng kềnh và khó tập trung vào logic của hook. Những thách thức thường gặp bao gồm:

  • Quản lý State nội bộ: Làm sao để kiểm tra các thay đổi state qua nhiều lần render?
  • Thao tác bất đồng bộ: Gọi API, setTimeout, setInterval... làm thế nào để đợi kết quả hoặc mock chúng?
  • Sử dụng Context: Hook của bạn phụ thuộc vào Context? Làm sao để cung cấp một Context giả lập cho test?
  • Tách biệt logic: Mục tiêu của test là kiểm tra hook, không phải toàn bộ component.

react-hooks-testing-library: Vị Cứu Tinh Của Bạn

Đây chính là thư viện được thiết kế đặc biệt để giải quyết các vấn đề trên. Nó cho phép bạn 'render' một hook trong một môi trường component ảo, giúp bạn truy cập vào giá trị trả về của hook, gọi các hàm callback và kiểm tra hành vi của nó một cách độc lập.

Cài Đặt Và Sử Dụng Cơ Bản

Đầu tiên, hãy cài đặt nó:

npm install --save-dev @testing-library/react-hooks react-test-renderer

Hoặc với Yarn:

yarn add --dev @testing-library/react-hooks react-test-renderer

Cú pháp cơ bản rất đơn giản:

import { renderHook, act } from '@testing-library/react-hooks';

// Giả sử bạn có hook này
function useCounter(initialValue = 0) {
  const [count, setCount] = React.useState(initialValue);
  const increment = () => setCount(prev => prev + 1);
  const decrement = () => setCount(prev => prev - 1);
  return { count, increment, decrement };
}

describe('useCounter', () => {
  it('should increment the count', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });

  it('should decrement the count', () => {
    const { result } = renderHook(() => useCounter(5));

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(4);
  });
});

Trong ví dụ trên:

  • renderHook: Tạo một component ảo để 'chạy' hook của bạn.
  • result.current: Chứa giá trị trả về mới nhất của hook.
  • act: Đảm bảo tất cả các cập nhật trạng thái bên trong hook được xử lý trước khi assertion, tương tự như khi test React component.

Xử Lý Logic Bất Đồng Bộ (Async Logic)

Đây là lúc react-hooks-testing-library thực sự tỏa sáng. Giả sử bạn có một hook gọi API:

function useFetch(url) {
  const [data, setData] = React.useState(null);
  const [loading, setLoading] = React.useState(true);
  const [error, setError] = React.useState(null);

  React.useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(url);
        const json = await response.json();
        setData(json);
      } catch (err) {
        setError(err);
      } finally {
        setLoading(false);
      }
    };
    fetchData();
  }, [url]);

  return { data, loading, error };
}

// Cách test:
import { renderHook, act, waitFor } from '@testing-library/react-hooks';

// Mock global fetch
global.fetch = jest.fn(() =>
  Promise.resolve({
    json: () => Promise.resolve({ message: 'Hello World' }),
  })
);

describe('useFetch', () => {
  it('should fetch data successfully', async () => {
    const { result, waitForNextUpdate } = renderHook(() => useFetch('/api/data'));

    expect(result.current.loading).toBe(true);
    expect(result.current.data).toBe(null);
    expect(result.current.error).toBe(null);

    await waitForNextUpdate(); // Đợi cho effect chạy xong và state được cập nhật

    expect(result.current.loading).toBe(false);
    expect(result.current.data).toEqual({ message: 'Hello World' });
    expect(result.current.error).toBe(null);
  });

  it('should handle fetch error', async () => {
    global.fetch.mockImplementationOnce(() =>
      Promise.reject(new Error('Network error'))
    );

    const { result, waitForNextUpdate } = renderHook(() => useFetch('/api/error'));

    await waitForNextUpdate();

    expect(result.current.loading).toBe(false);
    expect(result.current.data).toBe(null);
    expect(result.current.error).toEqual(new Error('Network error'));
  });
});

Lưu ý quan trọng:

  • waitForNextUpdate: Hàm này chờ cho đến khi hook thực hiện xong một cập nhật trạng thái (thường là sau khi một effect chạy xong). Rất hữu ích cho các tác vụ bất đồng bộ.
  • waitFor: Chờ một điều kiện cụ thể trở thành đúng. Ví dụ: await waitFor(() => expect(result.current.data).not.toBeNull());
  • Mocking: Bạn sẽ cần mock các phụ thuộc bên ngoài như fetch, localStorage, hoặc các hàm async khác bằng Jest.

Test Hooks Sử Dụng Context

Nếu hook của bạn cần Context, bạn có thể truyền nó thông qua tùy chọn wrapper:

import { renderHook } from '@testing-library/react-hooks';
import MyContext from './MyContext'; // Giả sử bạn có Context này

// Hook sử dụng context
function useMyContextValue() {
  return React.useContext(MyContext);
}

// Test case
describe('useMyContextValue', () => {
  it('should return context value', () => {
    const wrapper = ({ children }) => (
      <MyContext.Provider value="test-value">
        {children}
      </MyContext.Provider>
    );

    const { result } = renderHook(() => useMyContextValue(), { wrapper });

    expect(result.current).toBe('test-value');
  });
});

Ở đây, wrapper là một React component đơn giản bao bọc hook của bạn, cho phép bạn cung cấp Context hoặc bất kỳ Provider nào khác mà hook cần.

Làm Việc Với Timers (setTimeout, setInterval)

Khi hook của bạn sử dụng setTimeout hoặc setInterval, bạn có thể kiểm soát chúng bằng cách sử dụng Jest's fake timers:

import { renderHook, act } from '@testing-library/react-hooks';

function useTimer(delay = 1000) {
  const [count, setCount] = React.useState(0);

  React.useEffect(() => {
    const id = setInterval(() => {
      setCount(prev => prev + 1);
    }, delay);
    return () => clearInterval(id);
  }, [delay]);

  return count;
}

describe('useTimer', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.runOnlyPendingTimers();
    jest.useRealTimers();
  });

  it('should increment count after delay', () => {
    const { result } = renderHook(() => useTimer(100));

    expect(result.current).toBe(0);

    act(() => {
      jest.advanceTimersByTime(100);
    });
    expect(result.current).toBe(1);

    act(() => {
      jest.advanceTimersByTime(100);
    });
    expect(result.current).toBe(2);
  });
});

Với jest.useFakeTimers()jest.advanceTimersByTime(), bạn có thể kiểm soát thời gian trong test mà không cần phải chờ đợi thực sự.

Kiểm tra Custom Hooks với logic phức tạp không còn là một nhiệm vụ bất khả thi nữa. Với react-hooks-testing-library, bạn có một công cụ mạnh mẽ để cô lập và kiểm tra hành vi của hook một cách hiệu quả, từ state đơn giản đến các tác vụ bất đồng bộ phức tạp và tương tác với Context. Hãy nhớ rằng, việc viết test tốt không chỉ giúp bạn bắt lỗi sớm mà còn là tài liệu sống cho cách hook của bạn hoạt động. Chúc các bạn viết code sạch và test vững chắc!