- Implement tests for Pagination, StatusBadge, ConfirmDialog, and MobileInput components. - Add tests for useSubscription, usePaymentConfig, and usePwaInstall hooks. - Create tests for API requests in the api module, including success and error handling. - Add utility function tests for formatting and validating Iranian mobile numbers. - Implement tests for BlogFormPage and BlogsPage to validate form submissions and data fetching. - Add tests for LoginPage to ensure proper validation and state management. - Create tests for authStore and uiStore to validate state management and functionality. - Set up Vitest configuration and testing utilities for consistent testing environment.
19 KiB
راهاندازی تست و نوشتن تستسوئیت کامل برای Admin SPA
پروژه
clinicpro — پنل ادمین React 19 + TypeScript زیر assets/admin/ (build با Webpack Encore داخل ddev). فقط frontend ادمین؛ backend دست نمیخورد.
زمینه
پنل ادمین هیچ زیرساخت تستی ندارد — نه test runner، نه تست. package.json هیچ vitest/jest/@testing-library و هیچ script test ندارد. کد شامل ۴۶ صفحه، ۱۹ کامپوننت UI، ۲ store (zustand)، ۳ hook، یک fetch wrapper و یک ماژول utility پر از منطق خالص (فرمت تاریخ شمسی، نرمالسازی رقم فارسی، اعتبارسنجی موبایل ایرانی) است که همگی قابلتست و در حال حاضر بدون پوششاند.
مشکل / هدف
۱. یک test runner مدرن و سازگار با استک (Vitest + Testing Library + jsdom) از صفر راهاندازی کن — مستقل از Webpack (Vitest با esbuild اجرا میشود و به pipeline Encore کاری ندارد). ۲. تستها را لایهبهلایه و بهترتیب اولویت بنویس: utils خالص → api wrapper → stores → hooks → کامپوننتهای UI → چند صفحه نماینده. برای هر لایه یک الگوی تکرارپذیر مستقر کن تا بقیهی صفحات بعداً با همان الگو پوشش داده شوند. ۳. هدف پوشش معنادار منطق و رفتار است، نه عدد پوشش صوری. هر تست باید رفتار واقعی را تأیید کند (نه snapshot بیمعنا).
فایلهای مرتبط
| فایل | نقش | تست هدف |
|---|---|---|
clinicpro/package.json |
scripts + devDeps | افزودن deps تست + script test |
clinicpro/tsconfig.json |
alias @/* → assets/admin/*، jsx: react-jsx |
باید در vitest منعکس شود |
clinicpro/vitest.config.ts |
ساخته شود | کانفیگ runner |
clinicpro/assets/admin/test/setup.ts |
ساخته شود | setup سراسری (jest-dom، mockها) |
clinicpro/assets/admin/test/utils.tsx |
ساخته شود | renderWithProviders (QueryClient + Router) |
assets/admin/lib/utils.ts |
فرمترها، رقم فارسی، موبایل، zod | تست واحد کامل |
assets/admin/lib/api.ts |
fetch wrapper، ApiError، ۴۰۱ refresh+retry |
تست با mock fetch/localStorage/store/location |
assets/admin/stores/authStore.ts |
login/logout/refresh/fetchMe/switchContext | تست action |
assets/admin/stores/uiStore.ts |
toggleها + applyTheme (DOM) |
تست action + DOM |
assets/admin/hooks/*.ts |
useSubscription, usePaymentConfig, usePwaInstall | تست با QueryClient + mock |
assets/admin/components/ui/* |
Pagination, StatusBadge, ConfirmDialog, Modal, MobileInput, … | render + رفتار |
assets/admin/pages/LoginPage.tsx, BlogFormPage.tsx, یک list page |
جریان واقعی | تست صفحه با providers |
وضعیت فعلی
package.json scripts (بدون test):
"scripts": {
"dev-server": "encore dev-server",
"dev": "encore dev",
"watch": "encore dev --watch",
"build": "encore production --progress"
}
tsconfig.json (نکات کلیدی):
"compilerOptions": { "jsx": "react-jsx", "baseUrl": ".", "paths": { "@/*": ["assets/admin/*"] }, "strict": true },
"include": ["assets/admin/**/*"]
lib/api.ts — هستهی fetch wrapper (وضعیت واقعی):
function getToken(): string | null {
const raw = localStorage.getItem('clinicpro-auth');
if (!raw) return null;
try { return JSON.parse(raw)?.state?.token ?? null; } catch { return null; }
}
// request<T>(path, options, retry=true):
// - ستکردن Content-Type: application/json + Authorization: Bearer <token> در صورت وجود
// - 401 + retry → refreshOnce() (de-dup با refreshPromise ماژولی) → اگر توکن جدید: retry با retry=false
// وگرنه logout() + window.location.replace('/admin/login') + throw ApiError(401,'ERR_UNAUTHORIZED')
// - سایر خطاها → throw ApiError(status, errors[0]?.code ?? 'ERR_UNKNOWN', errors[0]?.message ?? 'خطای ناشناخته')
// - موفق → res.json() as T
// api = { get, post, patch, put, delete } // writeها body را JSON.stringify میکنند
// ApiError { status, code, message }
// ApiResponse<T> = { success, data: T, errors[] } // single: data?.data
// PaginatedResponse<T> = { success, data: T[], meta: { totalRecords, totalPages, currentPage } } // list: items=data?.data ، total=data?.meta?.totalRecords
lib/utils.ts — توابع خالص قابلتست (امضاها):
formatRial(amount: number): string // Intl fa-IR + ' تومان'
formatNumber(n: number): string // Intl fa-IR
toDate(val: string|number|null|undefined): Date|null // number → ms*1000 ؛ 'Y-m-d' → ظهر محلی
formatDate(val): string // تقویم fa-IR-u-ca-persian ؛ نامعتبر → '—'
formatDateTime(val): string
toGregorianDate(d: Date): string // YYYY-MM-DD
maskMobile(mobile: string): string // 0912***678 اگر len>=7
cn(...classes): string
toEnglishDigits(input: string): string // نرمالسازی رقم فارسی/عربی
sanitizeMobileInput(input: string): string // فقط رقم، max 11
IRAN_MOBILE_RE = /^09\d{9}$/ ؛ isValidIranMobile(input): boolean
iranMobileSchema / iranMobileOptionalSchema // zod transform+refine
index.tsx — QueryClient سراسری: { queries: { retry: 1, staleTime: 30_000 } }، mount در #admin-root با StrictMode → QueryClientProvider → BrowserRouter → App. در تست index.tsx را import نکن (service worker register دارد).
وظایف
۱. نصب ابزار و کانفیگ runner
deps را اضافه کن (داخل ddev):
ddev exec yarn add -D vitest@^2 jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event @vitejs/plugin-react vite-tsconfig-paths
@vitejs/plugin-reactبرای transform JSX،vite-tsconfig-pathsتا alias@/*از همانtsconfig.jsonخوانده شود (بدون تکرار دستی).
clinicpro/vitest.config.ts بساز:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./assets/admin/test/setup.ts'],
include: ['assets/admin/**/*.{test,spec}.{ts,tsx}'],
css: false, // import .css در کامپوننتها را no-op کن
clearMocks: true,
restoreMocks: true,
coverage: { provider: 'v8', include: ['assets/admin/**/*.{ts,tsx}'], exclude: ['assets/admin/**/*.{test,spec}.*', 'assets/admin/test/**', 'assets/admin/index.tsx'] },
},
});
script در package.json:
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage"
clinicpro/assets/admin/test/setup.ts بساز:
import '@testing-library/jest-dom/vitest';
import { afterEach, vi } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => cleanup());
// matchMedia (uiStore.applyTheme و کامپوننتها ممکن است صدا بزنند)
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (q: string) => ({ matches: false, media: q, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn() }),
});
نکته: jsdom به
window.location.replaceاجازه navigation نمیدهد؛ در تست api.ts آن را باvi.stubGlobal/Object.definePropertymock کن (پایین).
۲. تست واحد lib/utils.ts (اول و مهمترین — منطق خالص، بدون mock)
assets/admin/lib/utils.test.ts:
import { describe, it, expect } from 'vitest';
import { toEnglishDigits, sanitizeMobileInput, isValidIranMobile, maskMobile, formatDate, toDate, iranMobileSchema, cn } from '@/lib/utils';
describe('toEnglishDigits', () => {
it('رقم فارسی و عربی را به انگلیسی تبدیل میکند', () => {
expect(toEnglishDigits('۰۹۱۲')).toBe('0912');
expect(toEnglishDigits('٠٩١٢')).toBe('0912');
});
});
describe('sanitizeMobileInput', () => {
it('غیررقم را حذف و به ۱۱ رقم محدود میکند', () => {
expect(sanitizeMobileInput('0912-345 6789012')).toBe('09123456789');
});
});
describe('isValidIranMobile', () => {
it.each([['09123456789', true], ['9123456789', false], ['0812345678', false]])('%s → %s', (input, ok) => {
expect(isValidIranMobile(input as string)).toBe(ok);
});
});
describe('maskMobile', () => {
it('وسط شماره را ماسک میکند', () => { expect(maskMobile('09123456789')).toContain('***'); });
});
describe('formatDate', () => {
it('ورودی نامعتبر → "—"', () => { expect(formatDate(null)).toBe('—'); expect(formatDate('xxx')).toBe('—'); });
it('timestamp ثانیهای را شمسی برمیگرداند', () => { expect(formatDate(1700000000)).not.toBe('—'); });
});
describe('iranMobileSchema (zod)', () => {
it('رقم فارسی را پذیرفته و نرمال میکند', () => {
expect(iranMobileSchema.parse('۰۹۱۲۳۴۵۶۷۸۹')).toBe('09123456789');
});
it('شماره نامعتبر را رد میکند', () => {
expect(iranMobileSchema.safeParse('123').success).toBe(false);
});
});
همهی توابع export شدهی utils را پوشش بده (formatRial, formatNumber, formatDateTime, toGregorianDate, toDate حالتهای مختلف، cn، iranMobileOptionalSchema).
۳. تست lib/api.ts (mock fetch/localStorage/store/location)
assets/admin/lib/api.test.ts. الگوی mock:
import { describe, it, expect, vi, beforeEach } from 'vitest';
// store را mock کن تا refresh/logout قابلکنترل شود
vi.mock('@/stores/authStore', () => ({
useAuthStore: { getState: () => ({ refresh: vi.fn().mockResolvedValue('new-token'), logout: vi.fn() }) },
}));
beforeEach(() => {
localStorage.clear();
vi.stubGlobal('fetch', vi.fn());
Object.defineProperty(window, 'location', { value: { replace: vi.fn() }, writable: true });
});
function jsonRes(body: unknown, ok = true, status = 200) {
return { ok, status, json: () => Promise.resolve(body) } as Response;
}
سناریوهای لازم:
- موفق:
fetchیکبار صدا،Authorization: Bearer <token>فقط وقتی توکن در localStorage هست (envelope{state:{token}}). - خطای ۴۲۲/۴۰۰:
ApiErrorباstatus/code/messageدرست ازerrors[0]پرتاب شود. - ۴۰۱ + توکن جدید از refresh: درخواست یکبار retry شود و در نهایت موفق.
- ۴۰۱ + refresh ناموفق (
null):logoutصدا زده شود،window.location.replace('/admin/login')، وApiError(401,'ERR_UNAUTHORIZED'). - de-dup: دو درخواست همزمان که ۴۰۱ میگیرند فقط یکبار refresh را صدا بزنند (تست
refreshPromise). api.postبدنه راJSON.stringifyکند و method درست بفرستد.
۴. تست stores
assets/admin/stores/authStore.test.ts — با mock کردن fetch (store مستقیم fetch میزند):
login(token)→ state ست شود (isAuthenticated=true,token) وfetchMeفراخوانی شود.logout()→ POST به/oauth/logoutو پاکشدن state.refresh()→ POST/oauth/token/refresh؛ موفق → توکن جدید برگردد و در state بنشیند؛ ناموفق →null.fetchMe()→ GET/oauth/userinfoو ستشدنprimaryRole,userName,availableContexts.- بین تستها state را reset کن:
useAuthStore.setState(useAuthStore.getInitialState?.() ?? initial)یاlocalStorage.clear()+ ست دستی.
assets/admin/stores/uiStore.test.ts:
toggleDarkMode/setBrandHue/setDensity→ state تغییر کند وapplyThemeکلاس/attribute رویdocument.documentElementبگذارد (باexpect(document.documentElement.classList.contains('dark'))یا attribute مربوطه).toggleSidebar/setSidebarOpenرفتار درست.
۵. تست hooks (با QueryClient wrapper + mock api)
assets/admin/test/utils.tsx — helper مشترک:
import { ReactElement, ReactNode } from 'react';
import { render } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { renderHook } from '@testing-library/react';
export function makeClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
}
export function Providers({ children, client = makeClient(), route = '/admin/dashboard' }: { children: ReactNode; client?: QueryClient; route?: string }) {
return (
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
</QueryClientProvider>
);
}
export function renderWithProviders(ui: ReactElement, opts?: { route?: string }) {
const client = makeClient();
return { client, ...render(ui, { wrapper: ({ children }) => <Providers client={client} route={opts?.route}>{children}</Providers>) };
}
export function renderHookWithClient<T>(cb: () => T) {
const client = makeClient();
return renderHook(cb, { wrapper: ({ children }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> });
}
hooks/useSubscription.test.tsx و usePaymentConfig.test.tsx: vi.mock('@/lib/api', ...) تا api.get پاسخ ثابت بدهد؛ با waitFor خروجی hook (hasFeature, isTestMode, …) را assert کن.
usePwaInstall.test.tsx: رویداد beforeinstallprompt را dispatch کن، localStorage['pwa-dismissed'] را تست کن، dismiss()/install() را بررسی کن.
۶. تست کامپوننتهای UI (render + رفتار)
با renderWithProviders/render ساده (آنها که provider نمیخواهند). حداقل اینها:
Pagination— تعداد صفحه درست، کلیک «بعدی»/«قبلی» callback را با صفحهی درست صدا بزند، دکمههای مرزی disable شوند.StatusBadge/AppointmentStatusDropdown— مپ وضعیت → برچسب/رنگ فارسی.ConfirmDialog— تأیید/لغو callbackها، بستهشدن.Modal— باز/بسته، بستن با Esc/کلیک backdrop، focus.MobileInput/PriceInput— نرمالسازی ورودی (رقم فارسی → انگلیسی، فقط عدد) باuserEvent.type.DataTable— رندر ستونها/ردیفها، حالت خالی، حالت loading.
نمونه:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Pagination } from '@/components/ui/Pagination';
it('کلیک «بعدی» صفحهی بعد را میفرستد', async () => {
const onPage = vi.fn();
render(<Pagination currentPage={1} totalPages={5} onPageChange={onPage} />);
await userEvent.click(screen.getByRole('button', { name: /بعدی/ }));
expect(onPage).toHaveBeenCalledWith(2);
});
props واقعی هر کامپوننت را از فایل خودش بخوان (نام props ممکن است فرق کند) — حدس نزن.
۷. تست صفحه (page) با mock لایهی api
سه صفحهی نماینده که سه الگو را پوشش میدهند:
LoginPage— فرم RHF+Zod: ورودی نامعتبر موبایل → پیام خطای فارسی؛ submit موفق →authStore.login/api.postصدا زده شود (mock).BlogFormPage— اعتبارسنجی zod (عنوان <۳ کاراکتر → «عنوان الزامی است»، body <۱۰ → «محتوا الزامی است»)، submit با مقادیر معتبر → فراخوانی mutation.- یک list page (مثلاً
BlogsPageیاRatingsPage) — قراردادPaginatedResponse:api.getرا mock کن که{ data: [...], meta: { totalRecords } }بدهد؛ تأیید کن ردیفها ازdata?.dataو total/تعداد صفحه ازmeta?.totalRecordsرندر میشوند.
الگوی mock صفحه:
vi.mock('@/lib/api', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
ApiError: class extends Error { constructor(public status: number, public code: string, message: string){ super(message); } },
}));
۸. اجرا و سبزکردن
ddev exec yarn test # کل سوئیت
ddev exec yarn test:cov # با پوشش
همهی تستها باید سبز شوند. اگر تستی بهخاطر رفتار واقعی کد شکست خورد، کد را تغییر نده مگر باگ واقعی باشد — تست را با رفتار واقعی همراستا کن (و باگ واقعی را جدا گزارش بده).
نکات مهم
- هیچ فرضی دربارهی props/نام تابع نزن — هر فایل هدف را قبل از تست بخوان و امضاها/نام exportها را از کد واقعی بردار. مثالهای بالا الگو هستند، نه قرارداد قطعی.
- Vitest مستقل از Webpack Encore است؛ خطای شناختهی
lightningcssدر build ddev ربطی به تست ندارد. CSS importها باcss: falseبیاثر میشوند. index.tsxرا در تست import نکن (service worker register + mount واقعی). فقطApp/صفحات/ماژولها را import کن.- jsdom navigation ندارد: هرجا کد
window.location.replaceمیزند (api.ts و App guardها) آن را mock کن. - storeها
persistدارند؛ بین تستهاlocalStorage.clear()و reset state تا نشت حالت بین تستها رخ ندهد (clearMocks/restoreMocksدر کانفیگ ست شده). formatDate/formatDateTimeبه تقویمfa-IR-u-ca-persianو timezone وابستهاند؛ بهجای assert رشتهی دقیق، روی «نامعتبر → '—'» و «معتبر → غیر '—' و شامل رقم فارسی» assert کن تا تست شکننده نشود.- اولویت پوشش: utils (۱۰۰٪) → api.ts → stores → hooks → UI components → pages. اگر زمان محدود شد، لایههای پایینتر (page) را با همان الگوی مستقرشده برای بقیهی صفحات قابلگسترش بگذار و در گزارش، صفحاتِ پوششدادهنشده را فهرست کن (silent truncation ممنوع).
- این تغییر فقط frontend است؛ backend، migration و
docs/api/دست نمیخورد. - بعد از پایان:
ddev exec npx tsc --noEmit --project tsconfig.jsonتا تستها type-error وارد نکرده باشند (فایلهای تست هم زیرassets/admin/**و در دامنهی tsconfig هستند).