Testing در اکوسیستم React

در فصل نوزدهم، اصول تست را دیدیم: چرا تست می‌نویسیم، سه سطح Unit و Component و E2E، تست از دید کاربر، Mocking و تست UIهای Async. در این درس، همان اصول را به ابزارهای واقعی امروز نگاشت می‌کنیم: چه چیزی چه کاری می‌کند، چه چیزی تغییر کرده، و تست Server Components چرا استثنا دارد.

نقشه ابزارها

لایه ابزارهای رایج وظیفه
Test Runner Vitest، Jest پیدا کردن و اجرای تست‌ها، expect، Mock، گزارش نتیجه
محیط DOM jsdom شبیه‌سازی مرورگر داخل Node برای رندر کامپوننت
تست کامپوننت React Testing Library، user-event، jest-dom رندر، جست‌وجوی عناصر با نقش و Label، شبیه‌سازی تعامل، Matcherهای خوانا
Mock شبکه MSW (Mock Service Worker) رهگیری درخواست‌های شبکه و جواب دادن به‌جای سرور
E2E Playwright، Cypress کنترل مرورگر واقعی و اجرای یک جریان کامل کاربر

این همان هرم تست درس ۱۹-۲ است، فقط با نام ابزارها. Storybook هم گاهی برای تست تعاملی کامپوننت‌ها به کار می‌رود، ولی در این درس روی ابزارهای اصلی تمرکز می‌کنیم.

Jest یا Vitest

Jest سال‌ها انتخاب پیش‌فرض بود. Vitest یک Test Runner جدیدتر است که روی Vite ساخته شده، با Jest از نظر نحو شباهت زیادی دارد، و در پروژه‌هایی که از Vite استفاده می‌کنند (درس ۲۵-۱) از همان پیکربندی استفاده می‌کند. هر دو در اکوسیستم رایج‌اند. تفاوت‌های روزمره:

  • در Vitest، به‌جای jest.fn() باید vi.fn() نوشت (vi از vitest import می‌شود).
  • به‌طور پیش‌فرض، توابع test و expect در Vitest باید import شوند، مگر این‌که گزینه Globals فعال شود.
  • محیط DOM باید صریحاً تنظیم شود. نمونه پیکربندی:
javascript
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
  },
});
html
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom @testing-library/user-event

توجه کنید که @testing-library/dom در نسخه‌های جدید React Testing Library به‌صورت وابستگی جداگانه نصب می‌شود، و Matcherهای فصل ۱۹ مثل toBeInTheDocument از بسته @testing-library/jest-dom می‌آیند که یک‌بار باید در فایل Setup فعال شود (برای Vitest، ورودی @testing-library/jest-dom/vitest برای همین منظور وجود دارد).

یک تست کامپوننت، همان فلسفه فصل ۱۹

jsx
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Counter from "./Counter";

test("کلیک روی دکمه، شمارنده را افزایش می‌دهد", async () => {
  render(<Counter />);

  const button = screen.getByRole("button");
  await userEvent.click(button);

  expect(button.textContent).toBe("1");
});

ساختار آن با مثال درس ۱۹-۴ یکی است: رندر، پیدا کردن عنصر با getByRole، تعامل با user-event، و بررسی چیزی که کاربر می‌بیند.

Mock کردن شبکه با MSW

در درس ۱۹-۵، با جایگزین کردن global.fetch درخواست را Mock کردیم. این روش یک ضعف دارد: تست باید بداند کامپوننت از fetch استفاده می‌کند. اگر فردا کتابخانه عوض شود (مثلاً TanStack Query یا Axios)، تست‌ها می‌شکنند، بدون این‌که رفتار کاربر عوض شده باشد؛ دقیقاً همان وابستگی به جزئیات پیاده‌سازی که در درس ۱۹-۳ از آن پرهیز دادیم. MSW مشکل را یک لایه پایین‌تر حل می‌کند: درخواست‌های شبکه را رهگیری می‌کند، و کد کامپوننت دست‌نخورده می‌ماند.

jsx
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { beforeAll, afterEach, afterAll, test } from "vitest";
import { render, screen } from "@testing-library/react";
import UserProfile from "./UserProfile";

const server = setupServer(
  http.get("https://example.com/api/user", () => {
    return HttpResponse.json({ name: "سارا" });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test("نام کاربر پس از دریافت نمایش داده می‌شود", async () => {
  render(<UserProfile />);
  await screen.findByText("سارا");
});

findByText اگر عنصر پیدا نشود خطا می‌دهد، پس عبور از آن خط خودش یعنی نام روی صفحه آمده است (همان الگوی درس ۱۹-۶). برای تست حالت خطا، فقط پاسخ همان تست را عوض می‌کنیم:

jsx
import { http, HttpResponse } from "msw";

test("هنگام خطای سرور، پیام خطا نمایش داده می‌شود", async () => {
  server.use(
    http.get("https://example.com/api/user", () => {
      return new HttpResponse(null, { status: 500 });
    })
  );

  render(<UserProfile />);
  await screen.findByText(/خطا/);
});

این تست زمانی معنا دارد که کامپوننت، همان‌طور که در فصل ۱۴ دیدیم، res.ok را بررسی کند. آدرس Handler باید با آدرسی که کامپوننت واقعاً صدا می‌زند یکی باشد.

رندر با Providerها

کامپوننتی که از Context، Router یا TanStack Query استفاده می‌کند، بیرون از Provider آن‌ها کار نمی‌کند. الگوی رایج، یک تابع کمکی است که کامپوننت را داخل Providerهای لازم می‌پیچد. نکته مهم برای TanStack Query: برای هر تست یک QueryClient تازه بسازید، تا کش بین تست‌ها نشت نکند، و تلاش دوباره خودکار را خاموش کنید تا تست خطا کند نشود:

jsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";

function renderWithClient(ui) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  return render(
    <QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
  );
}

برای کامپوننت‌هایی که Link یا useParams دارند هم باید آن‌ها را داخل یک Router آزمایشی (مثلاً با createMemoryRouter در React Router) رندر کرد، همان‌طور که فصل ۱۵ را به‌صورت واقعی نمی‌توانیم بدون Router اجرا کنیم.

تست Hookها

برای تست یک Custom Hook (فصل ۱۰)، React Testing Library تابع renderHook دارد که Hook را داخل یک کامپوننت موقتی اجرا می‌کند. نکته React 19: اگر مستقیم به act نیاز داشتید، آن را از react import کنید؛ نسخه قدیمی react-dom/test-utils دیگر مسیر درست نیست. React Testing Library معمولاً خودش act را مدیریت می‌کند.

react-test-renderer دیگر پیشنهاد نمی‌شود

اگر در آموزش‌های قدیمی‌تر به react-test-renderer و Snapshot روی درخت آن برخوردید: این بسته در React 19 منسوخ (Deprecated) شده، و رویکرد توصیه‌شده، رندر با React Testing Library و تست از دید کاربر است. Snapshot Test هم، همان‌طور که در درس ۱۹-۳ درباره وابستگی به جزئیات دیدیم، اگر زیاد استفاده شود، با هر تغییر بی‌ضرر UI می‌شکند؛ آن را فقط جاهایی بگذارید که واقعاً ارزش دارد.

تست Server Components و Server Functionها

طبق مستندات رسمی Next.js در زمان نگارش، ابزارهای Unit Test مانند Vitest و Jest هنوز از Server Componentهای async پشتیبانی نمی‌کنند (درس ۲۴-۱)؛ Server Component هم‌زمان (غیر async) و Client Component را می‌توان Unit Test کرد، ولی برای async Server Componentها توصیه، تست E2E است. یک راهکار مکمل، همان اصل جداسازی منطق از رابط کاربری است (درس ۲۴-۶): منطق دریافت و پردازش داده را در توابع ساده بنویسید که مستقل از React قابل Unit Test هستند، و جریان کامل را با E2E بسنجید. این وضعیت به ابزارها و نسخه‌ها وابسته است و ممکن است تغییر کند؛ مستندات ابزار خودتان را بررسی کنید.

E2E با Playwright

javascript
import { test, expect } from "@playwright/test";

test("کاربر می‌تواند وارد شود", async ({ page }) => {
  await page.goto("http://localhost:3000/login");
  await page.getByLabel("ایمیل").fill("test@example.com");
  await page.getByRole("button", { name: "ورود" }).click();

  await expect(page.getByText("خوش آمدید")).toBeVisible();
});

به getByLabel و getByRole نگاه کنید: همان فلسفه React Testing Library و همان اتصال label و input فصل ۱۸. اگر کامپوننت‌ها از نظر معنایی درست نوشته شده باشند، تست‌های E2E هم راحت‌تر نوشته می‌شوند. Playwright (و Cypress) معمولاً پیش از هر عمل، منتظر آماده شدن عنصر می‌مانند، پس نیازی به تأخیر دستی نیست؛ تأخیرهای ثابت (مثل setTimeout) منبع رایج تست‌های ناپایدار (Flaky) هستند.

کدام تست را کجا بنویسیم

  • تابع خالص یا Reducer: Unit Test ساده، سریع‌ترین نوع.
  • Custom Hook: renderHook.
  • رفتار یک کامپوننت یا فرم: React Testing Library همراه MSW برای شبکه.
  • جریان حیاتی کامل (ورود، پرداخت) و Server Componentهای async: E2E، در تعداد کم.

چرا این درس مثال قابل اجرا ندارد

تست‌ها به یک Test Runner و محیط Node نیاز دارند که در ویرایشگر درون‌صفحه‌ای وجود ندارد. دستورهای بالا را در یک پروژه واقعی اجرا کنید.

جمع‌بندی

اکوسیستم تست React از چند لایه ساخته شده: Test Runner (Vitest یا Jest)، React Testing Library برای تست از دید کاربر، MSW برای Mock شبکه در سطح درخواست، و Playwright یا Cypress برای E2E. برای هر تست یک QueryClient تازه بسازید، react-test-renderer در React 19 منسوخ است، و Server Componentهای async را فعلاً با E2E بسنجید. در درس بعدی، به Build و Deployment می‌پردازیم.