برای شروع، اجازه دهید یک دید کلی به شما بدهم. در پروژههای React و Next.js، ما تستنویسی را به سه لایه اصلی تقسیم میکنیم. هر لایه هدف مشخصی دارد و جایگزین لایه دیگر نمیشود. به این سه لایه مثل یک هرم نگاه کنید که پهنای آن در پایین بیشتر است.
تست واحد، کوچکترین بخش تستنویسی است. در اینجا ما یک تابع، یک هوک سفارشی، یا یک کامپوننت کوچک را به صورت کاملا ایزوله تست میکنیم. یعنی هیچ وابستگی به شبکه، دیتابیس، یا کامپوننتهای دیگر نداریم.
من در پروژههای جدید از Vitest استفاده میکنم چون سرعت بیشتری دارد و با Vite هماهنگ است. اما مفهوم هر دو یکی است.
مثال عملی: فرض کنید یک تابع داریم که قیمت را با تخفیف محاسبه میکند. من برای این تابع سه سناریو مینویسم: حالت عادی، حالت با تخفیف صفر، و حالت با اعداد منفی که باید خطا بدهد.
javascript
// مثال ساده از تست واحد test('should calculate discount price correctly', () => { expect(calculateDiscount(100, 10)).toBe(90); expect(calculateDiscount(100, 0)).toBe(100); expect(() => calculateDiscount(-10, 10)).toThrow(); });
نکته مهم این است که در تست واحد، من هیچ وقت رندر واقعی انجام نمیدهم. فقط منطق خالص را تست میکنم. این تستها باید خیلی سریع اجرا شوند، معمولا کمتر از چند میلی ثانیه برای هر تست.
اینجا جایی است که من به عنوان یک توسعهدهنده ارشد، بیشترین زمان را صرف میکنم. تست یکپارچهسازی به معنای تست کردن تعامل چند بخش با هم است. مثلا یک کامپوننت فرم که با یک هوک سفارشی و یک سرویس API کار میکند.
با React Testing Library، ما از دید کاربر تست میکنیم. یعنی دنبال متنها، دکمهها، و المانهای قابل مشاهده میگردیم و کلیک کردن و تایپ کردن را شبیهسازی میکنیم.
مثال عملی: یک فرم ورود کاربر را در نظر بگیرید. من در تست این فرم، سناریوی موفقیت و خطا را هر دو تست میکنم.
javascript
// مثال از تست یکپارچهسازی test('should show error message when login fails', async () => { // شبیهسازی درخواست ناموفق server.use( rest.post('/api/login', (req, res, ctx) => { return res(ctx.status(401), ctx.json({ message: 'Invalid credentials' })); }) ); render(<LoginForm />); // کاربر ایمیل و پسورد را تایپ میکند await userEvent.type(screen.getByLabelText(/email/i), '[email protected]'); await userEvent.type(screen.getByLabelText(/password/i), 'wrongpass'); // روی دکمه ورود کلیک میکند await userEvent.click(screen.getByRole('button', { name: /login/i })); // پیام خطا را میبیند expect(await screen.findByText(/invalid credentials/i)).toBeInTheDocument(); });
نکته کلیدی اینجا این است که ما هیچ وقت کامپوننت را به صورت مجزا تست نمیکنیم. بلکه کل رفتار کاربر را شبیهسازی میکنیم. این تستها کمی کندتر از تست واحد هستند اما بسیار ارزشمندترند چون اعتماد ما را به عملکرد صحیح برنامه بالا میبرند.
تست E2E مانند یک ربات است که مرورگر واقعی را باز میکند و دقیقا مثل یک کاربر واقعی با برنامه تعامل میکند. این تستها در محیطی مشابه محیط تولید اجرا میشوند.
با Playwright، من میتوانم همزمان در کروم، فایرفاکس و وبکیت تست کنم. این تستها گرانترین و کندترین تستها هستند، بنابراین تعداد آنها را محدود نگه میدارم.
معمولا سناریوهای اصلی کسبوکار را با E2E پوشش میدهم. مثلا:
ثبتنام کاربر جدید
ورود به سیستم
خرید یک محصول
خروج از سیستم
javascript
// مثال از تست E2E با Playwright test('user can complete purchase flow', async ({ page }) => { // باز کردن صفحه اصلی await page.goto('/'); // انتخاب محصول await page.click('text=Product X'); await page.click('button:has-text("Add to Cart")'); // رفتن به سبد خرید await page.click('a:has-text("Cart")'); await page.click('button:has-text("Checkout")'); // پر کردن فرم await page.fill('input[name="email"]', '[email protected]'); await page.fill('input[name="address"]', '123 Main St'); await page.click('button:has-text("Confirm Purchase")'); // تایید موفقیت await expect(page.locator('text=Order Confirmed')).toBeVisible(); });
من به عنوان یک توسعهدهنده ارشد، از قانون هرم تست پیروی میکنم. نسبت تستها به این شکل است:
تست واحد: حدود 70 درصد تستها
تست یکپارچهسازی: حدود 20 درصد تستها
تست E2E: حدود 10 درصد تستها
چرا؟ چون تستهای واحد سریع و ارزان هستند و هر تغییری را سریع نشان میدهند. تستهای یکپارچهسازی ارزش بالایی دارند اما کندترند. و تستهای E2E گرانترین هستند و فقط برای مسیرهای اصلی استفاده میشوند.
اگر میخواهید در مصاحبه بدرخشید، این نکات را به خاطر داشته باشید:
۱. من همیشه تستها را همزمان با توسعه مینویسم، نه بعد از آن. این کار باعث میشود کد من از ابتدا تستپذیر طراحی شود.
۲. برای کامپوننتهایی که به Next.js متکی هستند (مثل useRouter یا usePathname)، من از Mock کردن استفاده میکنم. در Next.js 13 و بالاتر، از NextRouter مخصوص تست استفاده میکنم.
۳. من هرگز از snapshot testing به عنوان تست اصلی استفاده نمیکنم. اسنپشاتها فقط برای بررسی تغییرات ناخواسته در UI مفیدند، نه برای تایید منطق.
۴. در پروژههای بزرگ، من تستها را بر اساس پوشش کد (code coverage) ارزیابی نمیکنم. به جای آن، کیفیت سناریوهای تست شده را بررسی میکنم. ۱۰۰ درصد پوشش کد به معنای ۱۰۰ درصد کیفیت نیست.
۵. من از فیکسچرها (fixtures) و فکتوریها برای ساخت دادههای تست استفاده میکنم تا تستها خواناتر و قابل نگهداریتر باشند.
فرض کنید یک کامپوننت لیست کاربران داریم که دادهها را از API میگیرد و با Zustand مدیریت state میکند. من برای این کامپوننت، سه نوع تست مینویسم:
۱. تست واحد برای خود تابع fetchUsers
۲. تست یکپارچهسازی برای کامپوننت UserList که نشان میدهد در حالت loading، حالت error، و حالت موفقیت چه رفتاری دارد
۳. تست E2E برای کل فرآیند لاگین و مشاهده لیست کاربران