Vitest و React Testing Library مع Next.js 15: الدليل الشامل لاختبارات الوحدة في 2026

AI Bot
بواسطة AI Bot ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

اختبر بثقة. Vitest هو إطار الاختبارات فائق السرعة المدعوم بـ Vite الذي حل محل Jest في معظم المشاريع الحديثة بحلول 2026. عند دمجه مع React Testing Library، يوفر تجربة اختبار بديهية وسريعة وموثوقة. في هذا الدليل، ستتعلم كيفية اختبار كل طبقة من تطبيق Next.js 15 الخاص بك.

ما ستتعلمه

بنهاية هذا الدليل، ستتمكن من:

  • إعداد Vitest في مشروع Next.js 15 مع App Router
  • كتابة اختبارات المكونات باستخدام React Testing Library
  • اختبار الـ hooks المخصصة باستخدام renderHook
  • عمل Mock لـ Server Components وServer Actions
  • اختبار مسارات API (Route Handlers)
  • قياس وتحسين تغطية الكود
  • دمج الاختبارات في خط أنابيب CI/CD
  • تطبيق أفضل الممارسات لاختبارات قابلة للصيانة

المتطلبات الأساسية

قبل البدء، تأكد من توفر:

  • Node.js 20+ مثبت (node --version)
  • خبرة في TypeScript وReact
  • معرفة بـ Next.js 15 (App Router، Server Components)
  • محرر أكواد — يُنصح بـ VS Code أو Cursor
  • فهم أساسي لـ اختبار البرمجيات (assertions، mocks)

لماذا Vitest بدلاً من Jest؟

في 2026، أصبح Vitest المعيار القياسي للاختبارات في نظام JavaScript البيئي. إليك السبب:

المعيارJestVitest
السرعةبطيء (تحويل CJS)فائق السرعة (ESM أصلي عبر Vite)
الإعدادمعقد مع Next.jsبسيط مع next/vitest
ESMدعم جزئيدعم أصلي كامل
إعادة التحميللاوضع مراقبة ذكي
APIخاصمتوافق مع Jest (ترحيل سهل)
TypeScriptيحتاج ts-jestدعم أصلي

واجهة Vitest متوافقة عمداً مع Jest، مما يعني أنك إذا كنت تعرف Jest، فأنت تعرف Vitest بالفعل.


الخطوة 1: إنشاء مشروع Next.js

أنشئ مشروع Next.js 15 جديد:

npx create-next-app@latest my-tested-app --typescript --tailwind --app --src-dir
cd my-tested-app

تحقق من أن كل شيء يعمل:

npm run dev

الخطوة 2: تثبيت Vitest و React Testing Library

ثبّت حزم الاختبار:

npm install -D vitest @vitejs/plugin-react @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

إليك ما تفعله كل حزمة:

الحزمةالدور
vitestإطار الاختبارات
@vitejs/plugin-reactدعم JSX/TSX في Vitest
@testing-library/reactأدوات لاختبار مكونات React
@testing-library/jest-dommatchers مخصصة للـ DOM (toBeInTheDocument، إلخ.)
@testing-library/user-eventمحاكاة واقعية لتفاعلات المستخدم
jsdomبيئة DOM لـ Node.js

الخطوة 3: إعداد Vitest

أنشئ ملف vitest.config.ts في جذر المشروع:

import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
 
export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./src/test/setup.ts"],
    include: ["src/**/*.{test,spec}.{ts,tsx}"],
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html"],
      include: ["src/**/*.{ts,tsx}"],
      exclude: [
        "src/**/*.{test,spec}.{ts,tsx}",
        "src/**/*.d.ts",
        "src/test/**",
      ],
    },
  },
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
});

أنشئ ملف الإعداد src/test/setup.ts:

import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
 
// تنظيف تلقائي بعد كل اختبار
afterEach(() => {
  cleanup();
});

أضف سكريبتات الاختبار في package.json:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui"
  }
}

لوضع الواجهة التفاعلية (اختياري لكن مفيد جداً):

npm install -D @vitest/ui

الخطوة 4: إعداد TypeScript للاختبارات

أضف أنواع Vitest في tsconfig.json:

{
  "compilerOptions": {
    "types": ["vitest/globals", "@testing-library/jest-dom"]
  }
}

هذا يسمح باستخدام describe وit وexpect وmatchers الخاصة بـ jest-dom بدون استيراد صريح.


الخطوة 5: أول اختبار لمكون

لنُنشئ مكوناً بسيطاً لاختباره. أنشئ src/components/Counter.tsx:

"use client";
 
import { useState } from "react";
 
interface CounterProps {
  initialCount?: number;
  step?: number;
}
 
export function Counter({ initialCount = 0, step = 1 }: CounterProps) {
  const [count, setCount] = useState(initialCount);
 
  return (
    <div>
      <p data-testid="count-display">العداد: {count}</p>
      <button onClick={() => setCount((c) => c + step)}>زيادة</button>
      <button onClick={() => setCount((c) => c - step)}>إنقاص</button>
      <button onClick={() => setCount(0)}>إعادة تعيين</button>
    </div>
  );
}

الآن اكتب الاختبار في src/components/__tests__/Counter.test.tsx:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "../Counter";
 
describe("Counter", () => {
  it("يعرض العداد الأولي بقيمة 0", () => {
    render(<Counter />);
    expect(screen.getByTestId("count-display")).toHaveTextContent("العداد: 0");
  });
 
  it("يقبل قيمة أولية مخصصة", () => {
    render(<Counter initialCount={10} />);
    expect(screen.getByTestId("count-display")).toHaveTextContent("العداد: 10");
  });
 
  it("يزيد العداد عند النقر", async () => {
    const user = userEvent.setup();
    render(<Counter />);
 
    await user.click(screen.getByText("زيادة"));
    expect(screen.getByTestId("count-display")).toHaveTextContent("العداد: 1");
  });
 
  it("ينقص العداد عند النقر", async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={5} />);
 
    await user.click(screen.getByText("إنقاص"));
    expect(screen.getByTestId("count-display")).toHaveTextContent("العداد: 4");
  });
 
  it("يعيد تعيين العداد إلى صفر", async () => {
    const user = userEvent.setup();
    render(<Counter initialCount={42} />);
 
    await user.click(screen.getByText("إعادة تعيين"));
    expect(screen.getByTestId("count-display")).toHaveTextContent("العداد: 0");
  });
 
  it("يستخدم خطوة مخصصة", async () => {
    const user = userEvent.setup();
    render(<Counter step={5} />);
 
    await user.click(screen.getByText("زيادة"));
    expect(screen.getByTestId("count-display")).toHaveTextContent("العداد: 5");
 
    await user.click(screen.getByText("زيادة"));
    expect(screen.getByTestId("count-display")).toHaveTextContent("العداد: 10");
  });
});

شغّل الاختبار:

npm test

يجب أن ترى جميع الاختبارات تمر بنجاح باللون الأخضر.


الخطوة 6: اختبار مكونات النماذج

النماذج هي جوهر معظم التطبيقات. أنشئ src/components/LoginForm.tsx:

"use client";
 
import { useState } from "react";
 
interface LoginFormProps {
  onSubmit: (data: { email: string; password: string }) => Promise<void>;
}
 
export function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const [isLoading, setIsLoading] = useState(false);
 
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError("");
 
    if (!email.includes("@")) {
      setError("عنوان بريد إلكتروني غير صالح");
      return;
    }
 
    if (password.length < 8) {
      setError("يجب أن تكون كلمة المرور 8 أحرف على الأقل");
      return;
    }
 
    setIsLoading(true);
    try {
      await onSubmit({ email, password });
    } catch (err) {
      setError("فشل تسجيل الدخول. يرجى المحاولة مرة أخرى.");
    } finally {
      setIsLoading(false);
    }
  };
 
  return (
    <form onSubmit={handleSubmit} aria-label="نموذج تسجيل الدخول">
      {error && (
        <div role="alert" className="text-red-500">
          {error}
        </div>
      )}
 
      <label htmlFor="email">البريد الإلكتروني</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />
 
      <label htmlFor="password">كلمة المرور</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        required
      />
 
      <button type="submit" disabled={isLoading}>
        {isLoading ? "جارٍ تسجيل الدخول..." : "تسجيل الدخول"}
      </button>
    </form>
  );
}

اختبره في src/components/__tests__/LoginForm.test.tsx:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import { LoginForm } from "../LoginForm";
 
describe("LoginForm", () => {
  const mockOnSubmit = vi.fn();
 
  beforeEach(() => {
    mockOnSubmit.mockReset();
  });
 
  it("يعرض النموذج بجميع الحقول", () => {
    render(<LoginForm onSubmit={mockOnSubmit} />);
 
    expect(screen.getByLabelText("البريد الإلكتروني")).toBeInTheDocument();
    expect(screen.getByLabelText("كلمة المرور")).toBeInTheDocument();
    expect(screen.getByText("تسجيل الدخول")).toBeInTheDocument();
  });
 
  it("يعرض خطأ لبريد إلكتروني غير صالح", async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockOnSubmit} />);
 
    await user.type(screen.getByLabelText("البريد الإلكتروني"), "invalid-email");
    await user.type(screen.getByLabelText("كلمة المرور"), "password123");
    await user.click(screen.getByText("تسجيل الدخول"));
 
    expect(screen.getByRole("alert")).toHaveTextContent(
      "عنوان بريد إلكتروني غير صالح"
    );
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });
 
  it("يعرض خطأ لكلمة مرور قصيرة", async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockOnSubmit} />);
 
    await user.type(screen.getByLabelText("البريد الإلكتروني"), "user@example.com");
    await user.type(screen.getByLabelText("كلمة المرور"), "short");
    await user.click(screen.getByText("تسجيل الدخول"));
 
    expect(screen.getByRole("alert")).toHaveTextContent(
      "يجب أن تكون كلمة المرور 8 أحرف على الأقل"
    );
  });
 
  it("يرسل النموذج ببيانات صحيحة", async () => {
    mockOnSubmit.mockResolvedValue(undefined);
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockOnSubmit} />);
 
    await user.type(screen.getByLabelText("البريد الإلكتروني"), "user@example.com");
    await user.type(screen.getByLabelText("كلمة المرور"), "password123");
    await user.click(screen.getByText("تسجيل الدخول"));
 
    expect(mockOnSubmit).toHaveBeenCalledWith({
      email: "user@example.com",
      password: "password123",
    });
  });
 
  it("يعطل الزر أثناء التحميل", async () => {
    mockOnSubmit.mockImplementation(
      () => new Promise((resolve) => setTimeout(resolve, 1000))
    );
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockOnSubmit} />);
 
    await user.type(screen.getByLabelText("البريد الإلكتروني"), "user@example.com");
    await user.type(screen.getByLabelText("كلمة المرور"), "password123");
    await user.click(screen.getByText("تسجيل الدخول"));
 
    expect(screen.getByText("جارٍ تسجيل الدخول...")).toBeDisabled();
  });
 
  it("يعرض خطأ عند فشل الإرسال", async () => {
    mockOnSubmit.mockRejectedValue(new Error("Network error"));
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockOnSubmit} />);
 
    await user.type(screen.getByLabelText("البريد الإلكتروني"), "user@example.com");
    await user.type(screen.getByLabelText("كلمة المرور"), "password123");
    await user.click(screen.getByText("تسجيل الدخول"));
 
    expect(
      await screen.findByText("فشل تسجيل الدخول. يرجى المحاولة مرة أخرى.")
    ).toBeInTheDocument();
  });
});

الخطوة 7: اختبار الـ Hooks المخصصة

توفر React Testing Library دالة renderHook لاختبار الـ hooks بشكل منفصل. أنشئ src/hooks/useLocalStorage.ts:

"use client";
 
import { useState, useEffect } from "react";
 
export function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === "undefined") return initialValue;
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });
 
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch {
      console.error(`خطأ في حفظ "${key}" في localStorage`);
    }
  }, [key, storedValue]);
 
  return [storedValue, setStoredValue] as const;
}

اختبره في src/hooks/__tests__/useLocalStorage.test.ts:

import { renderHook, act } from "@testing-library/react";
import { useLocalStorage } from "../useLocalStorage";
 
describe("useLocalStorage", () => {
  beforeEach(() => {
    localStorage.clear();
  });
 
  it("يرجع القيمة الأولية عندما يكون localStorage فارغاً", () => {
    const { result } = renderHook(() => useLocalStorage("theme", "light"));
    expect(result.current[0]).toBe("light");
  });
 
  it("يقرأ القيمة الموجودة من localStorage", () => {
    localStorage.setItem("theme", JSON.stringify("dark"));
    const { result } = renderHook(() => useLocalStorage("theme", "light"));
    expect(result.current[0]).toBe("dark");
  });
 
  it("يحدث القيمة في localStorage", () => {
    const { result } = renderHook(() => useLocalStorage("count", 0));
 
    act(() => {
      result.current[1](42);
    });
 
    expect(result.current[0]).toBe(42);
    expect(JSON.parse(localStorage.getItem("count")!)).toBe(42);
  });
 
  it("يتعامل مع الكائنات المعقدة", () => {
    const initial = { name: "أحمد", age: 30 };
    const { result } = renderHook(() => useLocalStorage("user", initial));
 
    act(() => {
      result.current[1]({ name: "سارة", age: 25 });
    });
 
    expect(result.current[0]).toEqual({ name: "سارة", age: 25 });
  });
});

الخطوة 8: عمل Mock للوحدات والـ APIs

يوفر Vitest نظام mocking قوي. إليك التقنيات الأكثر شيوعاً.

عمل Mock لوحدة كاملة

import { vi } from "vitest";
 
// عمل Mock لـ next/navigation
vi.mock("next/navigation", () => ({
  useRouter: () => ({
    push: vi.fn(),
    replace: vi.fn(),
    back: vi.fn(),
  }),
  useSearchParams: () => new URLSearchParams(),
  usePathname: () => "/",
}));

عمل Mock لاستدعاءات fetch

أنشئ src/lib/api.ts:

export async function fetchUsers() {
  const response = await fetch("/api/users");
  if (!response.ok) throw new Error("فشل في الجلب");
  return response.json();
}

اختبر مع mock لـ fetch:

import { vi } from "vitest";
import { fetchUsers } from "../api";
 
describe("fetchUsers", () => {
  it("يرجع المستخدمين", async () => {
    const mockUsers = [
      { id: 1, name: "أحمد" },
      { id: 2, name: "سارة" },
    ];
 
    global.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(mockUsers),
    });
 
    const users = await fetchUsers();
    expect(users).toEqual(mockUsers);
    expect(fetch).toHaveBeenCalledWith("/api/users");
  });
 
  it("يرمي خطأ عند الفشل", async () => {
    global.fetch = vi.fn().mockResolvedValue({
      ok: false,
      status: 500,
    });
 
    await expect(fetchUsers()).rejects.toThrow("فشل في الجلب");
  });
});

عمل Mock باستخدام vi.spyOn

import * as api from "../api";
 
it("يتجسس على استدعاء دالة", async () => {
  const spy = vi.spyOn(api, "fetchUsers").mockResolvedValue([]);
 
  // ... الكود الذي يستدعي fetchUsers
 
  expect(spy).toHaveBeenCalledTimes(1);
  spy.mockRestore();
});

الخطوة 9: اختبار المكونات مع بيانات غير متزامنة

أنشئ مكوناً يجلب البيانات. src/components/UserList.tsx:

"use client";
 
import { useState, useEffect } from "react";
 
interface User {
  id: number;
  name: string;
  email: string;
}
 
export function UserList() {
  const [users, setUsers] = useState<User[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
 
  useEffect(() => {
    fetch("/api/users")
      .then((res) => {
        if (!res.ok) throw new Error("خطأ في الخادم");
        return res.json();
      })
      .then(setUsers)
      .catch((err) => setError(err.message))
      .finally(() => setIsLoading(false));
  }, []);
 
  if (isLoading) return <p>جارٍ التحميل...</p>;
  if (error) return <p role="alert">خطأ: {error}</p>;
 
  return (
    <ul aria-label="قائمة المستخدمين">
      {users.map((user) => (
        <li key={user.id}>
          <strong>{user.name}</strong> — {user.email}
        </li>
      ))}
    </ul>
  );
}

اختبره:

import { render, screen } from "@testing-library/react";
import { vi } from "vitest";
import { UserList } from "../UserList";
 
describe("UserList", () => {
  it("يعرض حالة التحميل", () => {
    global.fetch = vi.fn().mockImplementation(
      () => new Promise(() => {}) // وعد لا يُحل أبداً
    );
 
    render(<UserList />);
    expect(screen.getByText("جارٍ التحميل...")).toBeInTheDocument();
  });
 
  it("يعرض قائمة المستخدمين", async () => {
    const mockUsers = [
      { id: 1, name: "أحمد", email: "ahmed@example.com" },
      { id: 2, name: "سارة", email: "sara@example.com" },
    ];
 
    global.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: () => Promise.resolve(mockUsers),
    });
 
    render(<UserList />);
 
    expect(await screen.findByText("أحمد")).toBeInTheDocument();
    expect(screen.getByText("سارة")).toBeInTheDocument();
    expect(screen.getByRole("list")).toHaveAttribute(
      "aria-label",
      "قائمة المستخدمين"
    );
  });
 
  it("يعرض رسالة خطأ عند الفشل", async () => {
    global.fetch = vi.fn().mockResolvedValue({
      ok: false,
      status: 500,
    });
 
    render(<UserList />);
 
    expect(
      await screen.findByText("خطأ: خطأ في الخادم")
    ).toBeInTheDocument();
  });
});

نصيحة: استخدم findByText بدلاً من getByText للعناصر التي تظهر بشكل غير متزامن. findByText تنتظر تلقائياً حتى يكون العنصر موجوداً في الـ DOM.


الخطوة 10: اختبار مسارات API (Route Handlers)

يستخدم Next.js 15 الـ Route Handlers في مجلد app/api/. أنشئ src/app/api/users/route.ts:

import { NextRequest, NextResponse } from "next/server";
 
const users = [
  { id: 1, name: "أحمد", email: "ahmed@example.com" },
  { id: 2, name: "سارة", email: "sara@example.com" },
];
 
export async function GET() {
  return NextResponse.json(users);
}
 
export async function POST(request: NextRequest) {
  const body = await request.json();
 
  if (!body.name || !body.email) {
    return NextResponse.json(
      { error: "الاسم والبريد الإلكتروني مطلوبان" },
      { status: 400 }
    );
  }
 
  const newUser = {
    id: users.length + 1,
    name: body.name,
    email: body.email,
  };
 
  return NextResponse.json(newUser, { status: 201 });
}

اختبر الـ Route Handlers مباشرة:

import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
 
describe("API /api/users", () => {
  describe("GET", () => {
    it("يرجع قائمة المستخدمين", async () => {
      const response = await GET();
      const data = await response.json();
 
      expect(response.status).toBe(200);
      expect(data).toHaveLength(2);
      expect(data[0]).toHaveProperty("name", "أحمد");
    });
  });
 
  describe("POST", () => {
    it("ينشئ مستخدماً جديداً", async () => {
      const request = new NextRequest("http://localhost/api/users", {
        method: "POST",
        body: JSON.stringify({ name: "خالد", email: "khaled@example.com" }),
      });
 
      const response = await POST(request);
      const data = await response.json();
 
      expect(response.status).toBe(201);
      expect(data).toMatchObject({
        name: "خالد",
        email: "khaled@example.com",
      });
    });
 
    it("يرجع 400 إذا كانت البيانات ناقصة", async () => {
      const request = new NextRequest("http://localhost/api/users", {
        method: "POST",
        body: JSON.stringify({ name: "خالد" }),
      });
 
      const response = await POST(request);
      const data = await response.json();
 
      expect(response.status).toBe(400);
      expect(data.error).toBe("الاسم والبريد الإلكتروني مطلوبان");
    });
  });
});

الخطوة 11: الاختبار مع Providers (Context، Theme، إلخ.)

معظم التطبيقات تستخدم providers في React. أنشئ أداة عرض مخصصة:

// src/test/test-utils.tsx
import { ReactElement } from "react";
import { render, RenderOptions } from "@testing-library/react";
 
function AllProviders({ children }: { children: React.ReactNode }) {
  return <>{children}</>;
}
 
function customRender(
  ui: ReactElement,
  options?: Omit<RenderOptions, "wrapper">
) {
  return render(ui, { wrapper: AllProviders, ...options });
}
 
export * from "@testing-library/react";
export { customRender as render };

استخدمه في اختباراتك:

// بدلاً من:
import { render, screen } from "@testing-library/react";
 
// استخدم:
import { render, screen } from "@/test/test-utils";

الخطوة 12: Snapshots والاختبارات البصرية

يدعم Vitest اختبارات snapshot للكشف عن التغييرات غير المتوقعة:

import { render } from "@testing-library/react";
import { Counter } from "../Counter";
 
it("يطابق الـ snapshot", () => {
  const { container } = render(<Counter initialCount={5} />);
  expect(container).toMatchSnapshot();
});
 
// أو مع inline snapshots (أكثر قابلية للقراءة)
it("يطابق الـ inline snapshot", () => {
  const { container } = render(<Counter />);
  expect(container.firstChild).toMatchInlineSnapshot(`
    <div>
      <p data-testid="count-display">العداد: 0</p>
      <button>زيادة</button>
      <button>إنقاص</button>
      <button>إعادة تعيين</button>
    </div>
  `);
});

تحذير: الـ snapshots مفيدة للكشف عن الانحدارات، لكن لا تستخدمها كبديل عن التأكيدات الصريحة. فضّل toHaveTextContent وtoBeInTheDocument للتحقق من السلوك.


الخطوة 13: تغطية الكود

شغّل الاختبارات مع التغطية:

npm run test:coverage

ينتج Vitest تقرير تغطية مفصل:

-----------------------|---------|----------|---------|---------|
File                   | % Stmts | % Branch | % Funcs | % Lines |
-----------------------|---------|----------|---------|---------|
All files              |   92.3  |   85.7   |   100   |   92.3  |
 components/Counter    |   100   |   100    |   100   |   100   |
 components/LoginForm  |   95.2  |   83.3   |   100   |   95.2  |
 hooks/useLocalStorage |   88.9  |   75.0   |   100   |   88.9  |
-----------------------|---------|----------|---------|---------|

إعداد حدود التغطية

أضف حدوداً دنيا في vitest.config.ts:

coverage: {
  provider: "v8",
  thresholds: {
    statements: 80,
    branches: 75,
    functions: 80,
    lines: 80,
  },
},

إذا انخفضت التغطية تحت هذه الحدود، ستفشل الاختبارات في CI.


الخطوة 14: التكامل مع CI/CD باستخدام GitHub Actions

أنشئ .github/workflows/test.yml:

name: Tests
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  test:
    runs-on: ubuntu-latest
 
    steps:
      - uses: actions/checkout@v4
 
      - name: تثبيت Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"
 
      - name: تثبيت التبعيات
        run: npm ci
 
      - name: تشغيل الاختبارات
        run: npm run test:run
 
      - name: التحقق من التغطية
        run: npm run test:coverage

الخطوة 15: أفضل الممارسات والأنماط المتقدمة

1. اتبع فلسفة Testing Library

"كلما كانت اختباراتك تشبه الطريقة التي يُستخدم بها برنامجك، كلما منحتك ثقة أكبر." — Kent C. Dodds

// ❌ سيء: اختبار تفاصيل التنفيذ
expect(component.state.isOpen).toBe(true);
 
// ✅ جيد: اختبار السلوك المرئي
expect(screen.getByRole("dialog")).toBeVisible();

2. فضّل الاستعلامات بالدور

// ❌ هش: يعتمد على النص الدقيق
screen.getByText("إرسال");
 
// ✅ متين: يعتمد على الدور المتاح
screen.getByRole("button", { name: /إرسال/i });

3. استخدم userEvent بدلاً من fireEvent

// ❌ fireEvent منخفض المستوى
fireEvent.click(button);
 
// ✅ userEvent يحاكي السلوك الحقيقي للمستخدم
const user = userEvent.setup();
await user.click(button);

4. نظّم باستخدام نمط AAA

it("يفلتر المستخدمين بالاسم", async () => {
  // Arrange - الترتيب
  const user = userEvent.setup();
  render(<UserSearch users={mockUsers} />);
 
  // Act - التنفيذ
  await user.type(screen.getByRole("searchbox"), "أحمد");
 
  // Assert - التأكيد
  expect(screen.getByText("أحمد")).toBeInTheDocument();
  expect(screen.queryByText("سارة")).not.toBeInTheDocument();
});

5. تجنب الاختبارات غير المستقرة

// ❌ توقيت هش
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(screen.getByText("تم التحميل")).toBeInTheDocument();
 
// ✅ انتظار مبني على التأكيدات
expect(await screen.findByText("تم التحميل")).toBeInTheDocument();

6. نظّف الـ Mocks

afterEach(() => {
  vi.restoreAllMocks();
});

استكشاف الأخطاء

"ReferenceError: document is not defined"

تحقق من أن البيئة مضبوطة على jsdom في vitest.config.ts:

test: {
  environment: "jsdom",
}

"Cannot find module '@/...'"

تأكد من إعداد الأسماء المستعارة في vitest.config.ts:

resolve: {
  alias: {
    "@": path.resolve(__dirname, "./src"),
  },
},

الاختبارات بطيئة

  • استخدم --reporter=verbose لتحديد الاختبارات البطيئة
  • تجنب setTimeout في الاختبارات
  • استخدم vi.useFakeTimers() للمكونات ذات المؤقتات
  • شغّل الاختبارات بالتوازي (السلوك الافتراضي لـ Vitest)

الـ Mock لا يعمل

تأكد من أن vi.mock() يُستدعى على مستوى الوحدة (وليس داخل describe أو it):

// ✅ صحيح: على مستوى الوحدة
vi.mock("next/navigation", () => ({
  useRouter: () => ({ push: vi.fn() }),
}));
 
// ❌ خاطئ: داخل كتلة اختبار
describe("MyComponent", () => {
  vi.mock("next/navigation"); // متأخر جداً!
});

الخطوات التالية

الآن بعد أن أصبحت اختبارات الوحدة جاهزة:

  • استكشف اختبارات E2E مع Playwright لإكمال استراتيجية الاختبار — راجع دليل Playwright
  • أضف اختبارات snapshot بصرية مع Chromatic أو Percy
  • أعدّ اختبار الطفرات (mutation testing) مع Stryker للتحقق من جودة اختباراتك
  • استكشف وضع Vitest Browser Mode للاختبار في متصفح حقيقي

الخلاصة

لديك الآن بيئة اختبار كاملة لتطبيق Next.js 15 الخاص بك:

  • Vitest مُعدّ مع React Testing Library لاختبارات سريعة وموثوقة
  • اختبارات للـ مكونات والـ hooks والـ نماذج ومسارات API
  • إتقان الـ mocking للوحدات وfetch والـ providers
  • تغطية الكود مقاسة بحدود قابلة للتعديل
  • خط أنابيب CI/CD جاهز للإنتاج

الاختبارات ليست عبئاً — إنها شبكة أمانك. كل اختبار تكتبه هو مواصفة حية لتطبيقك، حارس ضد الانحدارات، وتوثيق لا يكذب أبداً. استثمر في اختباراتك اليوم، ومستقبلك سيشكرك.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على ضبط Gemma لاستدعاء الدوال.

ناقش مشروعك معنا

نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.

دعنا نجد أفضل الحلول لاحتياجاتك.

مقالات ذات صلة