Mocking: جایگزین کردن بخش‌هایی از برنامه برای تست‌پذیرتر شدن

در درس‌های قبل، کامپوننت‌هایی را تست کردیم که کاملاً مستقل و بدون وابستگی بیرونی بودند. اما همان‌طور که در فصل چهاردهم دیدیم، بسیاری از کامپوننت‌های واقعی به سرور وابسته‌اند. این درس به Mocking می‌پردازد: چگونه این وابستگی‌های بیرونی را در تست، با نسخه‌ای قابل‌کنترل جایگزین کنیم.

مشکل: تست نباید به یک سرور واقعی وابسته باشد

فرض کنید کامپوننتی داریم که با useEffect (مثل فصل چهاردهم) داده کاربر را از سرور می‌گیرد. اگر تست این کامپوننت، واقعاً یک درخواست شبکه بفرستد، چند مشکل پیش می‌آید: تست کند می‌شود، به اتصال اینترنت و در دسترس بودن سرور وابسته می‌شود، و نتیجه آن ممکن است هر بار متفاوت باشد (مثلاً اگر داده واقعی روی سرور عوض شود).

راه‌حل: Mock کردن تابع fetch

Mocking یعنی جایگزین کردن یک تابع یا ماژول واقعی، با یک نسخه ساختگی که ما کنترلش می‌کنیم و مقدار بازگشتی‌اش را از پیش مشخص کرده‌ایم:

javascript
test("نام کاربر پس از دریافت موفق نمایش داده می‌شود", async () => {
  global.fetch = jest.fn(() =>
    Promise.resolve({
      ok: true,
      json: () => Promise.resolve({ name: "سارا" }),
    })
  );

  render(<UserProfile userId="1" />);

  const name = await screen.findByText("سارا");
  expect(name).toBeInTheDocument();
});

در این تست، fetch واقعی هرگز فراخوانی نمی‌شود؛ به‌جای آن، نسخه Mock شده، بلافاصله یک پاسخ از پیش تعیین‌شده برمی‌گرداند. این یعنی تست، سریع، پایدار، و کاملاً مستقل از اتصال اینترنت واقعی اجرا می‌شود.

چرا از findByText به‌جای getByText استفاده شد

چون دریافت داده، همان‌طور که در فصل چهاردهم دیدیم، غیرهمزمان است، نام کاربر بلافاصله روی صفحه ظاهر نمی‌شود. findByText، برخلاف getByText، منتظر می‌ماند و چند بار تلاش می‌کند تا عنصر موردنظر ظاهر شود؛ دقیقاً مناسب برای محتوایی که پس از یک Promise تغییر می‌کند.

Mock کردن حالت خطا

همان‌طور که در فصل چهاردهم دیدیم، یک کامپوننت کامل باید حالت خطا را هم پوشش دهد؛ پس باید بتوانیم آن را هم جداگانه تست کنیم:

javascript
test("پیام خطا هنگام شکست درخواست نمایش داده می‌شود", async () => {
  global.fetch = jest.fn(() => Promise.reject(new Error("خطای شبکه")));

  render(<UserProfile userId="1" />);

  const errorMessage = await screen.findByText(/خطا/);
  expect(errorMessage).toBeInTheDocument();
});

Mock کردن یک ماژول کامل

گاهی به‌جای Mock کردن fetch خام، یک ماژول کامل (مثلاً یک Custom Hook مثل useFetch که در فصل دهم ساختیم) Mock می‌شود؛ این کار با jest.mock انجام می‌شود و اجازه می‌دهد خروجی آن Hook، مستقیماً برای هر تست خاص، کنترل شود:

javascript
jest.mock("./useFetch");

useFetch.mockReturnValue({ data: { name: "سارا" }, loading: false, error: null });

مرز Mocking: تا کجا باید جایگزین کرد

یک اصل مهم این است که فقط چیزی باید Mock شود که واقعاً بیرون از کنترل تست است (سرور، زمان سیستم، یک سرویس بیرونی)؛ خود کامپوننت و منطق داخلی React نباید Mock شوند. اگر همه‌چیز Mock شود، تست دیگر چیز واقعی را بررسی نمی‌کند و فقط تأیید می‌کند که Mockها همان‌طور که نوشته شده‌اند کار می‌کنند؛ نه این‌که کامپوننت واقعی درست رفتار می‌کند.

مثال قابل اجرا

در این مثال، به‌جای یک fetch واقعی، یک تابع ساختگی با تأخیر کنترل‌شده استفاده شده تا همان ایده Mock کردن یک وابستگی بیرونی دیده شود.

شبیه‌سازی یک Mock برای دریافت داده
import { useState, useEffect } from "react";

// این تابع، نقش یک fetch Mock‌شده را بازی می‌کند
function mockFetchUser() {
  return Promise.resolve({ name: "سارا" });
}

function UserProfile() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    mockFetchUser().then(setUser);
  }, []);

  if (!user) return <p>در حال بارگذاری...</p>;
  return <p>نام کاربر: {user.name}</p>;
}

function App() {
  return <UserProfile />;
}
برای دیدن پیش‌نمایش کامپوننت، روی Run بزنید.

جمع‌بندی

Mocking یعنی جایگزین کردن یک وابستگی بیرونی، مثل fetch یا یک ماژول کامل، با نسخه‌ای ساختگی و قابل‌کنترل؛ این کار تست را سریع، پایدار و مستقل از سرویس‌های واقعی می‌کند. باید فقط چیزی که واقعاً بیرون از کنترل است Mock شود، نه خود منطق React. در درس بعدی، به تست دقیق‌تر UIهای غیرهمزمان می‌پردازیم.