React / Hook тест

Hook тест

Custom hook бол React-н хамгийн хүчирхэг боломжуудын нэг — логикийг component-аас тусгаарлан, дахин ашиглагдах болгодог. Гэхдээ hook-ыг шууд функц дуудах шиг тест хийж болохгүй, учир нь hook нь зөвхөн React component дотор ажилладаг. renderHook хэрэгсэл нь энэ асуудлыг шийддэг — hook-ыг component-д байрлуулах шаардлагагүйгээр тест хийх боломж олгодог.

renderHook танилцуулга

@testing-library/react сангаас renderHook ба act импортолно:

tsx
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect } from "vitest";

renderHook — hook-ыг виртуал орчинд ажиллуулна act — state өөрчлөлт, async үйлдлийг синхрончилна

Хамгийн энгийн hook-оос эхэлье:

typescript
// src/hooks/useCounter.ts
import { useState } from "react";

interface UseCounterOptions {
  initialValue?: number;
  step?: number;
  min?: number;
  max?: number;
}

function useCounter({
  initialValue = 0,
  step = 1,
  min = -Infinity,
  max = Infinity,
}: UseCounterOptions = {}) {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount((c) => Math.min(c + step, max));
  const decrement = () => setCount((c) => Math.max(c - step, min));
  const reset = () => setCount(initialValue);

  return { count, increment, decrement, reset };
}

export default useCounter;

useCounter-ийн тест

typescript
// src/hooks/useCounter.test.ts
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import useCounter from "./useCounter";

describe("useCounter hook", () => {
  it("анхны утгаар эхэлнэ", () => {
    const { result } = renderHook(() => useCounter({ initialValue: 5 }));

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

  it("increment дуудахад нэмэгдэнэ", () => {
    const { result } = renderHook(() => useCounter());

    // State өөрчлөлтийг act дотор хийх шаардлагатай
    act(() => {
      result.current.increment();
    });

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

  it("step ажиллана", () => {
    const { result } = renderHook(() => useCounter({ step: 5 }));

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

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

  it("max хязгаараас хэтрэхгүй", () => {
    const { result } = renderHook(() =>
      useCounter({ initialValue: 9, max: 10 }),
    );

    act(() => {
      result.current.increment();
      result.current.increment(); // max-аас хэтэрч болохгүй
    });

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

  it("reset анхны утга руу буцаана", () => {
    const { result } = renderHook(() => useCounter({ initialValue: 3 }));

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

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

result.current нь hook-н буцаасан утгыг агуулна. State өөрчлөгдөх бүрт result.current автоматаар шинэчлэгдэнэ.

Async hook тест

Сервераас өгөгдөл татдаг hook-ыг тест хийхэд waitFor хэрэглэнэ:

typescript
// src/hooks/useCourse.ts
import { useState, useEffect } from "react";

interface Course {
  slug: string;
  title: string;
  lessonCount: number;
}

function useCourse(slug: string) {
  const [course, setCourse] = useState<Course | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setIsLoading(true);
    fetch(`/api/courses/${slug}`)
      .then((res) => {
        if (!res.ok) throw new Error("Курс олдсонгүй");
        return res.json();
      })
      .then(setCourse)
      .catch((err: Error) => setError(err.message))
      .finally(() => setIsLoading(false));
  }, [slug]);

  return { course, isLoading, error };
}

export default useCourse;

Тест файл:

typescript
// src/hooks/useCourse.test.ts
import { renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import useCourse from "./useCourse";

const mockFetch = vi.fn();
global.fetch = mockFetch;

describe("useCourse hook", () => {
  beforeEach(() => {
    mockFetch.mockReset();
  });

  it("эхэндээ ачааллаж байгааг харуулна", () => {
    mockFetch.mockImplementation(() => new Promise(() => {}));

    const { result } = renderHook(() => useCourse("javascript"));

    expect(result.current.isLoading).toBe(true);
    expect(result.current.course).toBeNull();
  });

  it("амжилттай татвал курсийн мэдээлэл буцаана", async () => {
    const mockCourse = {
      slug: "javascript",
      title: "JavaScript үндэс",
      lessonCount: 20,
    };
    mockFetch.mockResolvedValueOnce({
      ok: true,
      json: async () => mockCourse,
    });

    const { result } = renderHook(() => useCourse("javascript"));

    await waitFor(() => {
      expect(result.current.isLoading).toBe(false);
    });

    expect(result.current.course).toEqual(mockCourse);
    expect(result.current.error).toBeNull();
  });

  it("алдаа гарвал error state тохируулна", async () => {
    mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });

    const { result } = renderHook(() => useCourse("404-course"));

    await waitFor(() => {
      expect(result.current.isLoading).toBe(false);
    });

    expect(result.current.error).toBe("Курс олдсонгүй");
    expect(result.current.course).toBeNull();
  });

  it("slug өөрчлөгдөхөд дахин татна", async () => {
    const jsData = { slug: "javascript", title: "JavaScript", lessonCount: 20 };
    const tsData = { slug: "typescript", title: "TypeScript", lessonCount: 15 };

    mockFetch
      .mockResolvedValueOnce({ ok: true, json: async () => jsData })
      .mockResolvedValueOnce({ ok: true, json: async () => tsData });

    const { result, rerender } = renderHook(({ slug }) => useCourse(slug), {
      initialProps: { slug: "javascript" },
    });

    await waitFor(() => expect(result.current.course?.slug).toBe("javascript"));

    // slug өөрчлөх
    rerender({ slug: "typescript" });

    await waitFor(() => expect(result.current.course?.slug).toBe("typescript"));
    expect(mockFetch).toHaveBeenCalledTimes(2);
  });
});

rerender нь hook-д өгөгдсөн props-ыг өөрчилж дахин ажиллуулдаг — useEffect-н dependency array зөв ажиллаж байгааг шалгахад маш хэрэгтэй.

useLocalStorage hook тест

Browser API ашигладаг hook-ыг тест хийхэд mock хийх шаардлагатай:

typescript
// src/hooks/useLocalStorage.test.ts
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, beforeEach } from "vitest";
import useLocalStorage from "./useLocalStorage";

describe("useLocalStorage hook", () => {
  beforeEach(() => {
    localStorage.clear();
  });

  it("анхны утгыг ашиглана", () => {
    const { result } = renderHook(() => useLocalStorage("theme", "light"));
    expect(result.current[0]).toBe("light");
  });

  it("утга тохируулахад localStorage-д хадгалдаг", () => {
    const { result } = renderHook(() => useLocalStorage("theme", "light"));

    act(() => {
      result.current[1]("dark");
    });

    expect(result.current[0]).toBe("dark");
    expect(localStorage.getItem("theme")).toBe('"dark"');
  });
});

Hook тест бичихэд суралцах нь component тест бичихтэй адил чухал. Custom hook-уудыг тусдаа тест хийснээр кодын найдвартай байдал мэдэгдэхүйц нэмэгдэнэ — мөн hook-ын логикийг component-аас тусгаарлан бодох дадал хурцадна.

Дараагийн хичээлд:

React Profiler хэрэгслийг ашиглан аппын удаан хэсгийг олж, React.memo, useMemo, useCallback-ыг хэзээ яаж хэрэглэхийг практикаар судална.