React Testing Library — Custom Hook testlash

~20 daq · React · Testing

Muammodan boshlaylik

Sizda bir nechta custom hook bor (24–25-darslar):

function useHisoblagich(boshlangich = 0, { min = -Infinity, max = Infinity } = {}) {
  const [son, setSon] = useState(boshlangich);
  const oshir = () => setSon((s) => Math.min(s + 1, max));
  const kamaytir = () => setSon((s) => Math.max(s - 1, min));
  const tozala = () => setSon(boshlangich);
  return { son, oshir, kamaytir, tozala };
}

Uni oddiy funksiya kabi testlab bo'lmaydi:

const natija = useHisoblagich(5);   // ❌ Invalid hook call

Hook lar faqat komponent ichida chaqirilishi mumkin. Har bir hook uchun test komponenti yasash — noqulay. RTL buning uchun renderHook ni beradi.


renderHook

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

describe('useHisoblagich', () => {
  it('boshlang\'ich qiymatdan boshlaydi', () => {
    const { result } = renderHook(() => useHisoblagich(5));
    expect(result.current.son).toBe(5);
  });

  it('oshiradi', () => {
    const { result } = renderHook(() => useHisoblagich(0));

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

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

  it('max dan oshmaydi', () => {
    const { result } = renderHook(() => useHisoblagich(9, { max: 10 }));

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

    expect(result.current.son).toBe(10);
  });
});
  • renderHook(() => useX(...)) — hook ni kichik ichki komponentda chaqiradi
  • result.current — hook ning joriy qaytargan qiymati
  • act(() => ...) — state ni o'zgartiradigan chaqiruvlarni o'rash; React yangilanishlarni qo'llaydi

result.current — har doim yangi

const { result } = renderHook(() => useHisoblagich(0));
const { son, oshir } = result.current;     // ❌ eski qiymatni saqlab qoldik

act(() => oshir());
expect(son).toBe(1);                        // ❌ 0 — bu render dan oldingi qiymat

expect(result.current.son).toBe(1);         // ✅ har safar result.current dan o'qing

result.current har renderdan keyin yangilanadi. Destructuring qilsangiz — o'sha paytdagi "surat" qoladi.

[!INFO] Nima uchun bu yerda act kerak, 75-darsda esa yo'q edi? userEvent va render o'zlari act ga o'ralgan. renderHook da esa hook funksiyalarini to'g'ridan-to'g'ri chaqiramiz — React ga "state o'zgaradi, yangilanishlarni qo'lla" deyish uchun act kerak.


Props ni o'zgartirish — rerender

function useDebounce(qiymat, kechikish) {
  const [debounced, setDebounced] = useState(qiymat);
  useEffect(() => {
    const t = setTimeout(() => setDebounced(qiymat), kechikish);
    return () => clearTimeout(t);
  }, [qiymat, kechikish]);
  return debounced;
}
describe('useDebounce', () => {
  beforeEach(() => vi.useFakeTimers());
  afterEach(() => vi.useRealTimers());

  it('kechikishdan keyin yangi qiymatni qaytaradi', () => {
    const { result, rerender } = renderHook(
      ({ qiymat }) => useDebounce(qiymat, 300),
      { initialProps: { qiymat: 'a' } }
    );

    expect(result.current).toBe('a');

    rerender({ qiymat: 'ab' });
    expect(result.current).toBe('a');            // hali eski

    act(() => {
      vi.advanceTimersByTime(299);
    });
    expect(result.current).toBe('a');            // hali ham

    act(() => {
      vi.advanceTimersByTime(1);
    });
    expect(result.current).toBe('ab');           // ✅ 300 ms o'tdi
  });

  it('tez o\'zgarishlarda faqat oxirgisini qaytaradi', () => {
    const { result, rerender } = renderHook(
      ({ qiymat }) => useDebounce(qiymat, 300),
      { initialProps: { qiymat: 'r' } }
    );

    rerender({ qiymat: 're' });
    act(() => vi.advanceTimersByTime(100));
    rerender({ qiymat: 'rea' });
    act(() => vi.advanceTimersByTime(100));
    rerender({ qiymat: 'react' });
    act(() => vi.advanceTimersByTime(300));

    expect(result.current).toBe('react');
  });
});
  • initialProps — birinchi render props i
  • rerender(yangiProps) — hook ni yangi argumentlar bilan qayta chaqirish (komponent props i o'zgargandek)

unmount — cleanup ni tekshirish

it('unmount da taymer tozalanadi', () => {
  const clearSpy = vi.spyOn(globalThis, 'clearTimeout');
  const { unmount } = renderHook(() => useDebounce('a', 300));

  unmount();

  expect(clearSpy).toHaveBeenCalled();
});

Async hook lar

function useFoydalanuvchi(id) {
  return useQuery({
    queryKey: ['user', id],
    queryFn: () => getUser(id),
  });
}

TanStack Query ishlatadigan hook ga QueryClientProvider kerak — wrapper orqali:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

function createWrapper() {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
  return ({ children }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

vi.mock('../api/users');

it('foydalanuvchini yuklaydi', async () => {
  usersApi.getUser.mockResolvedValue({ id: 1, ism: 'Ali' });

  const { result } = renderHook(() => useFoydalanuvchi(1), {
    wrapper: createWrapper(),
  });

  expect(result.current.isPending).toBe(true);

  await waitFor(() => expect(result.current.isSuccess).toBe(true));

  expect(result.current.data).toEqual({ id: 1, ism: 'Ali' });
});

it('xatoni qaytaradi', async () => {
  usersApi.getUser.mockRejectedValue(new Error('404'));

  const { result } = renderHook(() => useFoydalanuvchi(999), {
    wrapper: createWrapper(),
  });

  await waitFor(() => expect(result.current.isError).toBe(true));
  expect(result.current.error.message).toBe('404');
});
  • wrapper — hook ni o'raydigan Provider komponenti (Context, Router, Query, Redux)
  • Async natija — waitFor (76-dars)
  • retry: false — aks holda xato testi 3 marta qayta urinishni kutadi va sekinlashadi

Context ishlatadigan hook

const wrapper = ({ children }) => (
  <ThemeProvider boshlangich="qorongi">{children}</ThemeProvider>
);

const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.mavzu).toBe('qorongi');

Zustand store ni testlash

Zustand store — React dan tashqarida ham ishlaydi (57-dars), shuning uchun ko'pincha renderHook ham kerak emas:

import { useSavatStore } from './savatStore';

const boshlangich = useSavatStore.getState();

beforeEach(() => {
  useSavatStore.setState(boshlangich, true);       // har testdan oldin toza holat
});

it('mahsulot qo\'shadi', () => {
  useSavatStore.getState().qoshish({ id: 1, nomi: 'Kitob', narx: 50000 });
  expect(useSavatStore.getState().mahsulotlar).toHaveLength(1);
});

it('bir xil mahsulotning sonini oshiradi', () => {
  const { qoshish } = useSavatStore.getState();
  qoshish({ id: 1, nomi: 'Kitob', narx: 50000 });
  qoshish({ id: 1, nomi: 'Kitob', narx: 50000 });
  expect(useSavatStore.getState().mahsulotlar[0].soni).toBe(2);
});

[!WARNING] Zustand store — modul darajasidagi global obyekt: testlar orasida holat saqlanib qoladi. beforeEach da tozalamasangiz, bir test boshqasiga ta'sir qiladi va natija testlar tartibiga bog'liq bo'lib qoladi. Redux store uchun esa har testda configureStore bilan yangi store yarating.


Hook ni alohida yoki komponent orqali?

Holat Tavsiya
Hook — qayta ishlatiladigan kutubxona kabi (useDebounce, useLocalStorage) renderHook bilan alohida
Hook da murakkab mantiq (hisob-kitob, holat mashinasi) renderHook bilan alohida
Hook faqat bitta komponentda, oddiy Komponent testi orqali — hook avtomatik tekshiriladi
useQuery ning oddiy o'rami Ko'pincha komponent testi yetarli

[!TIP] renderHook — hook ning API si ni tekshiradi; komponent testi — foydalanuvchi natijasi ni. Agar hook faqat bitta komponentning ichki tafsiloti bo'lsa, uni alohida testlash — implementatsiyani testlash (73-dars): hook ni komponentga qo'shib yuborsangiz, test buziladi, garchi hech narsa o'zgarmagan bo'lsa ham.


Nima uchun shunday?

Nima uchun hook larni oddiy funksiya kabi chaqirib bo'lmaydi — ular ham funksiya-ku?

Chunki hook lar React ning ichki holatiga bog'liq. useState qiymatni funksiyaning o'zida emas, React "hozir render bo'layotgan komponent" uchun saqlaydigan maxsus ro'yxatda saqlaydi — va uni chaqirilish tartibi bo'yicha topadi (hook qoidalari aynan shundan kelib chiqadi). Komponentdan tashqarida "hozirgi komponent" yo'q — React qiymatni qayerga yozishni bilmaydi va xato beradi. renderHook bu muammoni oddiy hiyla bilan hal qiladi: u hook ni chaqiradigan kichik, ko'rinmas test komponentini yaratadi va natijani result.current ga yozib turadi. Siz esa hook bilan xuddi uning API si orqali ishlaysiz.


Xulosa

Eslab qoling:

  • renderHook(() => useX(args)) — hook ni test komponentida chaqiradi
  • result.current — har doim shu yerdan o'qing; destructuring qilmang
  • State ni o'zgartiruvchi chaqiruvlar — act(() => ...) ichida
  • initialProps + rerender(props) — argumentlar o'zgarishi; unmount() — cleanup
  • Provider lar — wrapper orqali; Query uchun har testda yangi QueryClient, retry: false
  • Async natija — waitFor
  • Zustand — getState() bilan to'g'ridan-to'g'ri; beforeEach da holatni tiklash
  • Qayta ishlatiladigan va murakkab hook lar — alohida; oddiylar — komponent orqali

Keyingi dars

78-dars: MSW — API mock qilish, real so'rovlarsiz test