Перейти до змісту

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.tsfoo.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()
})

Чому це треба:

  • matchMediaMantine перевіряє, чи користувач у 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

💡 vitest vs vitest 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 на всі запити.

  • camelCasekebab-case (expenseItemsexpense-items).
  • Просте слово без змін (tenanttenant).
  • Багатослівні (fuelConsumptionNormfuel-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.
  • Прокидається scope label.
  • «Try again» скидає стан → діти рендеряться знову.
  • onError callback викликається з (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:

beforeEach(() => {
  useAuthStore.setState(useAuthStore.getInitialState(), true)
})

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')

📊 Звіт про покриття

npm run test:coverage
# Звіт у coverage/index.html — відкрити в браузері

Термінальний режим:

npx vitest run --coverage --coverage.reporter=text-summary

Ціль: довести покриття stores, api/, utils/ до 90%; компоненти — 50%+ (UI важче тестувати).


🎯 Принципи

  1. Тестуйте поведінку, не реалізацію. Не перевіряйте «викликано useState» — перевіряйте, що видимий текст змінився.
  2. Запити — як користувач. getByRole('button', { name: /save/i }) краще за container.querySelector('.save-btn'). Це стійкіше до рефакторингу.
  3. Моки — лише на межі. Axios, Date.now, crypto.randomUUID — так. Власні хуки чи чисті утиліти — НІ (тестуйте їх окремо).
  4. Кожен тест має arrange → act → assert. Блоки коду з пустими рядками між ними читаються як історія.
  5. Одна поведінка на тест. it('logs in and navigates and sets token and persists') — поганий. Розбийте на 4.
  6. 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 — хелпер для рендеру