Testing (frontend)¶
Статус: 80 Vitest-тестів на ERP-фронтенді (
frontend/erp/) — усі зелені. Покриття: Zustand-сторів, API-клієнт (JWT refresh), утиліт (ПДВ), ErrorBoundary, LoginPage, routing-хелпера.
📖 Навіщо і як воно влаштоване¶
Фронтенд-тести відповідають на питання:
- «Мій refresh-токен справді пере-логінює, чи мовчки ламається на 401?»
- «Zustand-стор правильно оновлює стан і не порушує persistence?»
- «ErrorBoundary справді ловить помилки, а не відкидає їх далі по дереву?»
- «Розрахунок ПДВ збігається з очікуваним для граничних випадків (ФОП без ПДВ, нульова кількість, ціле число vs string з форми)?»
На відміну від backend (де ми тестуємо SQL, проводки, серіалізатори), на фронті фокус інший: поведінка UI на подіях користувача та бізнес-логіка в чистих функціях.
Стек¶
| Пакет | Для чого |
|---|---|
| Vitest | Раннер (Vite-first — швидший за Jest, той самий API). Підтримує TS/JSX out-of-the-box. |
| @testing-library/react | Рендер компонентів + запити «як користувач» (getByRole, getByText). |
| @testing-library/jest-dom | Розширені матчери: toBeInTheDocument, toHaveTextContent, toBeDisabled. |
| @testing-library/user-event | Симуляція кліків/натисків як у реального користувача (async). |
| jsdom | Емулятор браузера в Node — дає document, window, localStorage. |
💡 Vitest vs Jest. У нас Vite, тому Vitest — найкращий вибір: той самий
vite.configпереносить alias'и, плагіни, ENV-змінні. Синтаксис тестів — 99% сумісний з Jest.
🗂️ Структура¶
frontend/erp/
├── vitest.config.ts # Vitest + jsdom + setup
├── src/
│ ├── test/
│ │ ├── setup.ts # глобальні моки (matchMedia, ResizeObserver), localStorage cleanup
│ │ └── renderWithProviders.tsx # хелпер для рендеру з усіма провайдерами
│ ├── api/
│ │ ├── client.test.ts # 4 тести: JWT interceptor, refresh, logout on 401
│ │ └── entities.test.ts # 7 тестів: toApiPath (camelCase → kebab-case)
│ ├── pages/
│ │ └── LoginPage.test.tsx # 6 тестів: form render, login flow, error, navigation
│ ├── store/
│ │ ├── authStore.test.ts # 7 тестів: login/logout/setTokens/setUser/persistence
│ │ ├── settingsStore.test.ts # 8 тестів: defaults, lock date, currency, chat toggles
│ │ ├── tabsStore.test.ts # 10 тестів: open/close/activate, closeAllTabs
│ │ └── uiStore.test.ts # 13 тестів: theme, language, server preferences sync
│ ├── utils/
│ │ └── vatUtils.test.ts # 18 тестів: calcVatLine, calcDocumentTotals, effectiveRate
│ └── components/shared/
│ └── ErrorBoundary.test.tsx # 7 тестів: catch error, fallback UI, onError, recovery
Правило: тестовий файл лежить поруч з кодом, який тестує (foo.ts → foo.test.ts). Не в окремому __tests__/.
⚙️ Конфігурація¶
vitest.config.ts¶
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
test: {
globals: true, // describe/it/expect без import
environment: 'jsdom', // емуляція браузера
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
css: false, // не обробляти .css — швидше + не падає на PostCSS
},
})
Розшифровка для новачка:
| Опція | Чому так |
|---|---|
globals: true |
describe, it, expect, vi доступні без import — коротші тести. |
environment: 'jsdom' |
Дає document, window, localStorage у Node. Обов'язково для React-тестів. |
setupFiles |
Виконується перед кожним тестом — ставить моки Mantine-залежностей. |
include |
Автовідкриття файлів *.test.ts(x). |
css: false |
Vitest не намагається обробити CSS — швидше, і не треба PostCSS у тестах. |
src/test/setup.ts — глобальні моки¶
import '@testing-library/jest-dom/vitest'
// 1. Мок ENV-змінної, яку читає api/client.ts
if (!import.meta.env.VITE_API_URL) {
import.meta.env.VITE_API_URL = '/api/v1'
}
// 2. Мок window.matchMedia — Mantine падає без нього
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false, media: query, onchange: null,
addListener: vi.fn(), removeListener: vi.fn(),
addEventListener: vi.fn(), removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
// 3. Мок ResizeObserver — Mantine Select/Modal його використовують
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn(),
}))
// 4. Чистка між тестами
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
})
Чому це треба:
- matchMedia —
Mantineперевіряє, чи користувач у dark-mode черезwindow.matchMedia('(prefers-color-scheme: dark)'). jsdom його не має, тому треба замокати. - ResizeObserver — використовується Mantine
<Select>,<Modal>,<Popover>для обчислення позиції. jsdom теж не має. - localStorage.clear() — інакше Zustand persist'-стор з одного тесту протікає в інший.
src/test/renderWithProviders.tsx¶
Боллерплейт, що обгортає компонент у потрібне дерево провайдерів (Mantine + Router + QueryClient + i18n). Без нього будь-який компонент з Mantine-кнопкою впаде з помилкою MantineProvider not found.
function AllProviders({ children, initialRoute = '/' }) {
const queryClient = createTestQueryClient() // retry: false, щоб не чекати
return (
<I18nextProvider i18n={testI18n}>
<QueryClientProvider client={queryClient}>
<MantineProvider>
<MemoryRouter initialEntries={[initialRoute]}>
{children}
</MemoryRouter>
</MantineProvider>
</QueryClientProvider>
</I18nextProvider>
)
}
export function renderWithProviders(ui, options) {
return render(ui, { wrapper: AllProviders, ...options })
}
Використання:
import { renderWithProviders } from '@/test/renderWithProviders'
it('shows login form', () => {
renderWithProviders(<LoginPage />, { initialRoute: '/login' })
expect(screen.getByLabelText(/username/i)).toBeInTheDocument()
})
▶️ Як запускати¶
cd c:/eswf/frontend/erp
npm install # одноразово
# Watch mode (інтерактивний, автозапуск при зміні)
npm run test
# Один раз (для CI або перевірки перед commit)
npm run test:run
# З покриттям
npm run test:coverage
# Один файл
npx vitest run src/store/authStore.test.ts
# Один тест по назві (substring)
npx vitest run -t "refresh"
# Verbose — список усіх тестів
npx vitest run --reporter=verbose
💡
vitestvsvitest run: безrun— watch-режим (ідеально під час написання коду). Зrun— один прогон і вихід (для CI).
✅ Що покривається зараз (80 тестів)¶
src/api/client.test.ts — 4 тести¶
Критично важливий: Axios interceptor, що додає Bearer <token> і обробляє refresh. Якщо зламається, весь ERP покаже білий екран.
attaches Bearer token when authenticated— коли єaccessTokenуauthStore, запит йде зAuthorization: Bearer ....does not attach token when not authenticated— анонімний запит без хедера.logs out when no refresh token on 401— 401 без refresh →authStore.logout()+ redirect на/login.passes through non-401 errors— 500 та інші не змінюються, повертаються як є.
src/api/entities.test.ts — 7 тестів¶
toApiPath — маленька функція-маппер camelCase ↔ kebab-case. Помилка тут = 404 на всі запити.
camelCase→kebab-case(expenseItems→expense-items).- Просте слово без змін (
tenant→tenant). - Багатослівні (
fuelConsumptionNorm→fuel-consumption-norm). - Edge case: порожній рядок.
src/store/authStore.test.ts — 7 тестів¶
- Початковий стан
isAuthenticated=false. login()ставить user + tokens +isAuthenticated=true.logout()очищає все + скидаєauthBootstrapped(гард проти race на старті).setTokens()оновлює лише токени.setUser()оновлює лише user.authBootstrappedне зберігається в localStorage (partializeвиключає його).
src/store/tabsStore.test.ts — 10 тестів¶
Multi-tab навігація (фіча DOP — до 10 вкладок, як у браузері).
openTabне дублює вкладку, просто активує.closeTabкоректно обирає попередню/наступну.closeAllTabsзберігаєclosable=false(дашборд).updateTabTitleзмінює title точкового табу.
src/store/uiStore.test.ts — 13 тестів¶
- Перемикачі theme / language / sidebar / chat.
applyServerPreferences— мерж серверних uprefs з локальними.- Ігнорування невалідних значень з сервера.
- Server preferences мають нижчий пріоритет: застосовуються лише якщо локально ще default.
src/store/settingsStore.test.ts — 8 тестів¶
Бух. налаштування: lock date, currency, inventory method (FIFO/average), defaults, chat toggles.
src/utils/vatUtils.test.ts — 18 тестів¶
Найбільший набір — бо помилка у ПДВ-розрахунку = неправильний рахунок-фактура клієнту.
calcVatLineдля 20%, 7%, 0% (exempt).- ФОП без ПДВ (
isVatPayer=false) — завжди 0. - String vs number з форми — обидва варіанти.
- Zero quantity/price edge cases.
- 4-decimal точність фінансового округлення.
calcDocumentTotals— сума по рядках, порожній масив, змішаний ПДВ/не-ПДВ.effectiveRate— правильна обробкаnull/undefined.
src/pages/LoginPage.test.tsx — 6 тестів¶
Full-flow HTTP-тест (моканий authApi.login).
- Рендер форми з username+password.
- Заголовок «DOP» на сторінці.
- Повідомлення помилки на невалідному логіні.
authApi.loginвикликається з правильними credentials.- Navigate на
/dashboardпісля успіху. - Поля
required(html5 атрибут).
src/components/shared/ErrorBoundary.test.tsx — 7 тестів¶
- Діти рендеряться, коли немає помилки.
- Якщо дочірній компонент кидає — показується fallback UI.
- Прокидається
scopelabel. - «Try again» скидає стан → діти рендеряться знову.
onErrorcallback викликається з (error, errorInfo).- Кастомний
fallbackзамість дефолтного. - Помилка в одному
<ErrorBoundary>не впливає на сусідній. - Лог у console містить scope.
🧪 Як писати новий тест (шаблон)¶
Чистий утиліт-тест¶
import { describe, it, expect } from 'vitest'
import { myFunction } from './myModule'
describe('myFunction', () => {
it('returns correct value for typical input', () => {
expect(myFunction(2, 3)).toBe(5)
})
it('handles edge case: zero', () => {
expect(myFunction(0, 0)).toBe(0)
})
})
Тест Zustand-стору¶
import { describe, it, expect, beforeEach } from 'vitest'
import { useAuthStore } from './authStore'
describe('authStore', () => {
beforeEach(() => {
// Ресет стору — щоб один тест не впливав на інший
useAuthStore.setState(useAuthStore.getInitialState(), true)
})
it('login updates state', () => {
useAuthStore.getState().login({
user: { id: 1, username: 'alice' },
accessToken: 'a',
refreshToken: 'r',
})
expect(useAuthStore.getState().isAuthenticated).toBe(true)
})
})
Тест React-компонента з подіями¶
import { describe, it, expect, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
import { screen } from '@testing-library/react'
import { renderWithProviders } from '@/test/renderWithProviders'
import MyButton from './MyButton'
describe('MyButton', () => {
it('calls onClick when clicked', async () => {
const user = userEvent.setup()
const handleClick = vi.fn()
renderWithProviders(<MyButton onClick={handleClick}>Save</MyButton>)
await user.click(screen.getByRole('button', { name: /save/i }))
expect(handleClick).toHaveBeenCalledOnce()
})
})
Тест з моканням API¶
import { describe, it, expect, vi } from 'vitest'
import { renderWithProviders } from '@/test/renderWithProviders'
import * as authApi from '@/api/auth'
import LoginPage from './LoginPage'
vi.mock('@/api/auth')
describe('LoginPage', () => {
it('logs in with correct credentials', async () => {
const loginMock = vi.mocked(authApi.login).mockResolvedValue({
user: { id: 1, username: 'alice' },
access: 'a', refresh: 'r',
})
// ...симуляція вводу й submit...
expect(loginMock).toHaveBeenCalledWith('alice', 'pa55word!')
})
})
⚠️ Gotchas (граблі)¶
1. «Unable to find an element with the text: 'DOP'»¶
Симптом: getByText('DOP') не знаходить, хоча візуально текст є.
Причина: getByText за замовчуванням шукає точний збіг повного textContent елемента. Якщо елемент містить [ DOP ] з пробілами/розділювачами навколо, точний збіг не спрацює.
Рішення: Використовуйте regex — getByText(/DOP/). Або { exact: false }.
2. Zustand persist протікає між тестами¶
Симптом: Тест logout clears everything падає, бо попередній тест лишив isAuthenticated: true у localStorage.
Рішення: у setup.ts уже є afterEach(() => localStorage.clear()). Але якщо у вас кастомний beforeEach, додайте ручний reset:
3. Mantine-компоненти падають «No MantineProvider found»¶
Рішення: завжди рендерити через renderWithProviders, не через сирий render з @testing-library/react.
4. «ReferenceError: ResizeObserver is not defined»¶
Симптом: Тест на компонент з Mantine <Select> падає в jsdom.
Рішення: уже замокано в setup.ts. Якщо додасте нову Mantine-залежність (наприклад, @mantine/charts), можливо потрібно буде довести ще глобальні моки (Canvas, URL.createObjectURL тощо).
5. user.click() треба await¶
Симптом: user.click(button) + наступне expect(handler).toHaveBeenCalled() падає — handler не викликався.
Причина: @testing-library/user-event v14+ повертає Promise — клік асинхронний (як у реального користувача, з делеями).
Рішення: завжди await user.click(...), await user.type(...), await user.keyboard(...).
6. jsdom не має window.scrollTo, window.alert, URL.createObjectURL¶
Рішення: додавайте моки у setup.ts в міру виникнення. Наприклад:
window.scrollTo = vi.fn()
window.alert = vi.fn()
global.URL.createObjectURL = vi.fn(() => 'blob:fake')
📊 Звіт про покриття¶
Термінальний режим:
Ціль: довести покриття stores, api/, utils/ до 90%; компоненти — 50%+ (UI важче тестувати).
🎯 Принципи¶
- Тестуйте поведінку, не реалізацію. Не перевіряйте «викликано
useState» — перевіряйте, що видимий текст змінився. - Запити — як користувач.
getByRole('button', { name: /save/i })краще заcontainer.querySelector('.save-btn'). Це стійкіше до рефакторингу. - Моки — лише на межі. Axios, Date.now, crypto.randomUUID — так. Власні хуки чи чисті утиліти — НІ (тестуйте їх окремо).
- Кожен тест має
arrange → act → assert. Блоки коду з пустими рядками між ними читаються як історія. - Одна поведінка на тест.
it('logs in and navigates and sets token and persists')— поганий. Розбийте на 4. - Async-сюрпризи. Якщо є
useEffect/fetch/setTimeout— використовуйтеawait waitFor(() => expect(...))замістьsetTimeoutу тесті.
🌐 Про інші фронтенди¶
| Фронтенд | Тести | Плани |
|---|---|---|
ERP (frontend/erp/) |
✅ 80 Vitest-тестів | розширити до хуків (useEntityList, useActivePlugins) |
Portal (frontend/portal/) |
❌ немає | Next.js static export — критичних сценаріїв мало; можна Playwright smoke на build |
News (frontend/news/) |
❌ немає | дрібний Vite-сайт з мок-даними; Vitest + RTL на ArticleCard |
Shop (frontend/shop/) |
❌ немає | Next.js SSR + Zustand cart/auth; перший пріоритет — cart-логіка + checkout flow |
Mobile Driver (mobile/) |
❌ немає | React Native + WatermelonDB; тестувати sync-логіку (pull/push) через mocked DB |
Mobile Sales (mobile-sales/) |
❌ немає | Аналогічно + photo/signature services |
Пріоритет на найближче: Shop (cart + checkout) та Mobile sync — вони впливають на гроші/дані клієнта.
🚧 Roadmap (frontend)¶
- Покриття хуків:
useEntityList,useEntityFields,useActivePlugins— вони центр гравітації для half of all components. - Integration-тести на ProcessPanel: найскладніші бізнес-процеси (StoreManager, SalesField) мають пройти full-flow render + submit.
- E2E (Playwright): golden path login → create invoice → post → see in register. Окремий набір, запускається у CI на staging.
- Mutation testing (Stryker): перевірити, що тести справді падають, якщо код зламати (виявляє «фальшиву впевненість»).
- Visual regression (Percy/Chromatic): для критичних сторінок — LoginPage, Dashboard, складні форми.
- CI gate: на PR →
npm run test:run && npm run test:coverage -- --reporter=text-summary; fail build якщо coverage впав.
🔗 Пов'язане¶
- testing.md — backend-тестування (parent-doc)
- dop-app.md — архітектура ERP-фронтенду, що ми тестуємо
- audit.md — аудит, з якого виросла Phase 1 тестування
- vitest.config.ts — реальна конфігурація
- setup.ts — глобальні моки
- renderWithProviders.tsx — хелпер для рендеру