تست UIهای Async: Loading، موفقیت و خطا
در درس قبل، بهطور مختصر از findByText برای صبر کردن روی یک نتیجه غیرهمزمان استفاده کردیم. در این درس، تست کردن کل چرخه یک کامپوننت Async را، دقیقاً همان سهحالتی که در فصل چهاردهم ساختیم (Loading، Error، Success)، با جزئیات کامل بررسی میکنیم.
یادآوری کامپوننت موردنظر
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
setUser(null);
setError(null);
fetchUser(userId)
.then(setUser)
.catch((err) => setError(err.message));
}, [userId]);
if (error) return <p>خطا: {error}</p>;
if (!user) return <p>در حال بارگذاری...</p>;
return <p>نام: {user.name}</p>;
}تست حالت Loading: بررسی وضعیت اولیه، بدون صبر کردن
بلافاصله پس از render، پیش از اینکه Promise حل شود، کامپوننت باید در حالت Loading باشد. چون این بررسی نباید منتظر چیزی بماند، از getByText (نه findByText) استفاده میشود:
test("ابتدا پیام بارگذاری نمایش داده میشود", () => {
global.fetch = jest.fn(() => new Promise(() => {})); // هرگز حل نمیشود
render(<UserProfile userId="1" />);
expect(screen.getByText("در حال بارگذاری...")).toBeInTheDocument();
});در این تست، Promise عمداً هرگز حل نمیشود؛ چون هدف فقط بررسی وضعیت بلافاصله پس از رندر اول است، نه نتیجه نهایی.
تست حالت موفقیت: صبر کردن تا رسیدن داده
test("پس از دریافت موفق، نام کاربر نمایش داده میشود", async () => {
global.fetch = jest.fn(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ name: "سارا" }) })
);
render(<UserProfile userId="1" />);
expect(await screen.findByText("نام: سارا")).toBeInTheDocument();
});
findByText، همانطور که در درس قبل دیدیم، منتظر میماند تا این متن ظاهر شود؛ این یعنی تست، خودش بهطور خودکار، زمان لازم برای حل شدن Promise را در نظر میگیرد، بدون نیاز به یک تأخیر دستی و حدسی.
تست حالت خطا
test("هنگام شکست درخواست، پیام خطا نمایش داده میشود", async () => {
global.fetch = jest.fn(() => Promise.reject(new Error("سرور در دسترس نیست")));
render(<UserProfile userId="1" />);
expect(await screen.findByText(/سرور در دسترس نیست/)).toBeInTheDocument();
});یک اشتباه رایج: استفاده نادرست از await بهتنهایی
اگر مستقیم بعد از render و بدون findByText یا ابزار مشابه، بخواهیم نتیجه نهایی را با getByText بررسی کنیم، تست شکست میخورد؛ چون در آن لحظه، Promise هنوز حل نشده است:
// اشتباه: Promise هنوز حل نشده
render(<UserProfile userId="1" />);
expect(screen.getByText("نام: سارا")).toBeInTheDocument(); // شکست میخورد
این دقیقاً همان دلیلی است که همیشه باید از ابزارهای «منتظر بمان» (مثل findBy) برای هر محتوایی که پس از یک عملیات غیرهمزمان ظاهر میشود استفاده کرد.
ارتباط با Race Condition که در فصل چهاردهم دیدیم
اگر کامپوننت واقعی، محافظت در برابر Race Condition (مثل پرچم ignore که در فصل چهاردهم ساختیم) نداشته باشد، میتوان این مشکل را هم با تغییر سریع userId در میانه یک تست و بررسی نتیجه نهایی، آشکار کرد؛ یعنی تست، نهتنها رفتار ظاهری، بلکه درستی منطق Async داخلی را هم تا حدی اعتبارسنجی میکند.
مثال قابل اجرا
import { useState, useEffect } from "react";
function fakeFetchUser(shouldFail) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldFail) reject(new Error("سرور در دسترس نیست"));
else resolve({ name: "سارا" });
}, 1000);
});
}
function UserProfile({ shouldFail }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
setUser(null);
setError(null);
fakeFetchUser(shouldFail).then(setUser).catch((err) => setError(err.message));
}, [shouldFail]);
if (error) return <p>خطا: {error}</p>;
if (!user) return <p>در حال بارگذاری...</p>;
return <p>نام: {user.name}</p>;
}
function App() {
const [shouldFail, setShouldFail] = useState(false);
return (
<div>
<button onClick={() => setShouldFail(!shouldFail)}>تغییر حالت خطا</button>
<UserProfile shouldFail={shouldFail} />
</div>
);
}جمعبندی
تست یک کامپوننت Async باید هر سه حالت را پوشش دهد: Loading (با بررسی فوری و بدون صبر)، موفقیت و خطا (هر دو با findBy برای صبر کردن تا تغییر واقعی رخ دهد). استفاده از getByText برای محتوایی که هنوز نرسیده، یک اشتباه رایج و منبع شکست کاذب تست است. در درس بعدی، به تست کاملتر کامپوننتها و تعاملهای چندمرحلهای کاربر میپردازیم.
