- 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.
317 lines
19 KiB
Markdown
317 lines
19 KiB
Markdown
# راهاندازی تست و نوشتن تستسوئیت کامل برای 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):
|
||
```json
|
||
"scripts": {
|
||
"dev-server": "encore dev-server",
|
||
"dev": "encore dev",
|
||
"watch": "encore dev --watch",
|
||
"build": "encore production --progress"
|
||
}
|
||
```
|
||
|
||
`tsconfig.json` (نکات کلیدی):
|
||
```json
|
||
"compilerOptions": { "jsx": "react-jsx", "baseUrl": ".", "paths": { "@/*": ["assets/admin/*"] }, "strict": true },
|
||
"include": ["assets/admin/**/*"]
|
||
```
|
||
|
||
`lib/api.ts` — هستهی fetch wrapper (وضعیت واقعی):
|
||
```ts
|
||
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` — توابع خالص قابلتست (امضاها):
|
||
```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):
|
||
```bash
|
||
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` بساز:
|
||
```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`:
|
||
```json
|
||
"test": "vitest run",
|
||
"test:watch": "vitest",
|
||
"test:cov": "vitest run --coverage"
|
||
```
|
||
|
||
`clinicpro/assets/admin/test/setup.ts` بساز:
|
||
```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.defineProperty` mock کن (پایین).
|
||
|
||
### ۲. تست واحد `lib/utils.ts` (اول و مهمترین — منطق خالص، بدون mock)
|
||
|
||
`assets/admin/lib/utils.test.ts`:
|
||
```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:
|
||
```ts
|
||
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 مشترک:
|
||
```tsx
|
||
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.
|
||
|
||
نمونه:
|
||
```tsx
|
||
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 صفحه:
|
||
```tsx
|
||
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); } },
|
||
}));
|
||
```
|
||
|
||
### ۸. اجرا و سبزکردن
|
||
|
||
```bash
|
||
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 هستند).
|