Модуль 13. Тест-стратегия как слой
Цель модуля: сделать тестирование явным, измеримым слоем. Не «упомянуть Vitest», а покрыть 5 уровней: unit-сторы, unit-компоненты, контрактные, e2e, a11y — с порогом coverage ≥ 70%.
К концу модуля у вас:
- Unit-тесты всех сторов Effector через
effector/testing. - Unit-тесты всех компонентов UI Kit (Vitest + RTL) — coverage ≥ 70%.
- Контрактные тесты: OpenAPI → Zod → MSW → runtime-проверка.
- E2e (Playwright): отправка сообщения + автокомплит.
- A11y-аудит: axe в Storybook + Playwright — 0 violations.
Перед началом: Модули 1-12. Инструменты: Vitest, Playwright, MSW. Время: ~6 ч.
🔥 Инцидент. Релиз сломал автокомплит. QA не проверил сценарий — ручное тестирование не покрывает все комбинации. Unit-тесты есть, но они проверяют «функция возвращает строку», а не «пользователь печатает и видит результат». Нужна пирамида: unit → контрактные → e2e → a11y.
📖 Теория
13.1 Пирамида тестов курса
┌──────────┐
│ E2E │ Playwright: полный сценарий в браузере (2-3 теста)
│ (М13.6) │
├──────────┤
│ Контракт │ MSW + Zod: API-контракт не расходится (М13.5)
│ (М13.5) │
├──────────┤
│ A11y │ axe-core: 0 violations (М13.7)
│ (М13.7) │
├──────────┤
│ Unit │ Vitest + RTL + effector/testing (массовые, быстрые)
│(М13.2-4) │
└──────────┘
Принцип: 80% — unit, 15% — контрактные, 5% — e2e. E2e медленные и хрупкие — их мало, но они проверяют критические сценарии.
13.2 Unit-тесты сторов Effector
Уже есть тесты модалки (Модуль 3) и тостов (Модуль 4). Расширим до всех сторов.
Паттерн тестирования
ts// app/features/mention-autocomplete/model.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest' import { fork, allSettled } from 'effector' import { $mentionValue, $suggestions, $status, mentionValueChanged } from './model' // Мокаем API-клиент — стор не делает реальный fetch vi.mock('@/shared/api/client', () => ({ fetchMentions: vi.fn().mockResolvedValue({ suggestions: [ { id: '1', name: 'Alice', avatar: null }, { id: '2', name: 'Bob', avatar: null }, ], }), })) describe('mention autocomplete', () => { it('updates value on change', async () => { const scope = fork() await allSettled(mentionValueChanged, { scope, params: 'Al' }) expect(scope.getState($mentionValue)).toBe('Al') }) it('fetches after debounce and sets done', async () => { const scope = fork() await allSettled(mentionValueChanged, { scope, params: 'Alice' }) await vi.waitFor(() => expect(scope.getState($status)).toBe('done')) expect(scope.getState($suggestions)).toHaveLength(2) }) it('clears on short query', async () => { const scope = fork() await allSettled(mentionValueChanged, { scope, params: 'A' }) await vi.waitFor(() => expect(scope.getState($status)).toBe('idle')) expect(scope.getState($suggestions)).toHaveLength(0) }) it('sets error on API failure', async () => { vi.mocked(fetchMentions).mockRejectedValueOnce(new Error('Network')) const scope = fork() await allSettled(mentionValueChanged, { scope, params: 'Alice' }) await vi.waitFor(() => expect(scope.getState($status)).toBe('error')) }) })
Чек-лив: какие сторы тестировать
| Стор | Файл | Сценарии |
|---|---|---|
| Модалка | features/modal/model.ts | open, close, toggle |
| Тосты | features/toasts/model.ts | add, remove, toast.success/error/info |
| Автокомплит | features/mention-autocomplete/model.ts | change, fetch success, fetch error, short query |
| Сообщения | entities/message/model.ts | fetch, optimistic add, error |
| Офлайн-очередь | features/offline-queue/model.ts | queue, send on reconnect, dedup |
13.3 Unit-тесты компонентов UI Kit
Паттерн: table-driven tests для variants/states.
tsx// ui-kit/src/components/Button/Button.test.tsx (уже в boilerplate, расширяем) import { describe, it, expect } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Button } from './Button' describe('Button', () => { // Рендер it.each(['primary', 'secondary', 'ghost', 'danger'] as const)('renders variant %s', (variant) => { const { container } = render(<Button variant={variant}>Test</Button>) expect(container.firstChild).toHaveClass(new RegExp(variant)) }) it.each(['sm', 'md', 'lg'] as const)('renders size %s', (size) => { const { container } = render(<Button size={size}>Test</Button>) expect(container.firstChild).toHaveClass(new RegExp(size)) }) // Состояния it('is disabled when loading', () => { render(<Button loading>Saving</Button>) expect(screen.getByRole('button')).toBeDisabled() expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true') }) // Взаимодействие it('calls onClick', async () => { const user = userEvent.setup() const onClick = vi.fn() render(<Button onClick={onClick}>Click</Button>) await user.click(screen.getByRole('button')) expect(onClick).toHaveBeenCalledOnce() }) // A11y it('has focus-visible outline', () => { render(<Button>Focus</Button>) // CSS :focus-visible проверить в jsdom нельзя, но можно проверить что кнопка фокусируема screen.getByRole('button').focus() expect(screen.getByRole('button')).toHaveFocus() }) })
Чек-лист компонентов к тестированию
| Компонент | Кейсов | Что проверять |
|---|---|---|
| Box | 2 | рендер, direction/gap/padding |
| Text | 3 | variant → тег, color → класс, weight |
| Button | 6 | variants (4), sizes (3), loading, onClick, disabled, focus |
| Icon | 2 | рендер, aria-hidden по умолчанию |
| Divider | 2 | orientation, aria-hidden |
| Input | 4 | error + aria, hint, ref, user input |
| Textarea | 2 | рендер, error |
| Avatar | 3 | src, fallback (initials), status dot |
| MessageBubble | 2 | own vs other, status indicator |
| Modal | 3 | не рендерится когда закрыт, Esc, role=dialog |
| Tooltip | 1 | role=tooltip |
| Skeleton | 1 | aria-hidden |
| Tabs | 2 | role=tablist, arrow navigation |
| Reaction | 2 | selected state, onClick |
13.4 Coverage-порогы
Обновляем vitest.config.ts:
ts// vitest.config.ts — поднимаем пороги coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov'], include: ['ui-kit/src/**/*.{ts,tsx}', 'app/{shared,entities,features,widgets}/**/*.{ts,tsx}'], exclude: ['**/*.test.*', '**/*.stories.*', '**/.storybook/**', '**/dist/**', '**/.next/**', '**/dev/**'], thresholds: { lines: 70, functions: 70, branches: 60, statements: 70, }, },
bashnpm run test:coverage # Должно быть: Lines ≥ 70%, Functions ≥ 70%, Branches ≥ 60% # CI падает, если порог не достигнут
13.5 Контрактные тесты: OpenAPI → Zod → MSW → runtime
Цель: если контракт изменился (например, сервер добавил обязательное поле), тест падает на CI, а не в продакшене.
ts// app/shared/api/contract.test.ts import { describe, it, expect } from 'vitest' import { setupServer } from 'msw/node' import { http, HttpResponse } from 'msw' import { MentionsResponseSchema } from './schemas' import { fetchMentions } from './client' // Мок с валидными данными (по Zod-схеме) const validResponse = { suggestions: [ { id: '1', name: 'Alice', avatar: null }, ], } const server = setupServer( http.get('/api/mentions', () => HttpResponse.json(validResponse)), ) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) describe('API contract: /api/mentions', () => { it('response matches Zod schema', async () => { const data = await fetchMentions('Alice') // Если данные не соответствуют схеме — ZodError в runtime expect(data.suggestions).toHaveLength(1) expect(data.suggestions[0].name).toBe('Alice') }) it('rejects invalid response (extra required field)', async () => { // Мокаем НЕВАЛИДНЫЙ ответ — сервер забыл поле name server.use( http.get('/api/mentions', () => HttpResponse.json({ suggestions: [{ id: '1', avatar: null }] }), // нет name ), ) // fetchMentions вызовет MentionsResponseSchema.parse() → ZodError await expect(fetchMentions('test')).rejects.toThrow() }) it('rejects null instead of array', async () => { server.use( http.get('/api/mentions', () => HttpResponse.json({ suggestions: null }), // null вместо массива ), ) await expect(fetchMentions('test')).rejects.toThrow() }) })
Ключевая идея: контракт-тест проверяет не «что сервер возвращает», а «что фронт не падает, если сервер возвращает ожидаемое» и «что фронт падает контролируемо, если сервер возвращает мусор».
13.6 E2E (Playwright): критические сценарии
Уже есть smoke.spec.ts (Модуль 1) и offline.spec.ts (Модуль 11). Добавляем сценарии автокомплита и отправки:
ts// e2e/autocomplete.spec.ts import { test, expect } from '@playwright/test' test('autocomplete shows suggestions and selects on click', async ({ page }) => { await page.goto('/ui-gallery') // Вводим текст const input = page.getByPlaceholder(/mention/i) await input.fill('Al') // Ждём дропдаун await expect(page.getByRole('option').first()).toBeVisible({ timeout: 5000 }) // Клик по первому варианту await page.getByRole('option').first().click() // Значение вставилось await expect(input).toHaveValue(/Alice/i) }) // e2e/send-message.spec.ts test('send message and see it in list', async ({ page }) => { await page.goto('/ui-gallery') const textarea = page.getByRole('textbox') await textarea.fill('Test message from e2e') await page.getByRole('button', { name: /отправить|send/i }).click() // Toast об успехе await expect(page.getByText(/отправлено|sent/i)).toBeVisible({ timeout: 5000 }) })
13.7 A11y-тесты: axe-core
В Storybook (уже настроено)
Addon @storybook/addon-a11y проверяет каждую story. Критерий: 0 violations.
В Playwright
ts// e2e/a11y.spec.ts import { test, expect } from '@playwright/test' import AxeBuilder from '@axe-core/playwright' test.describe('Accessibility audit', () => { test('UI Gallery — no axe violations', async ({ page }) => { await page.goto('/ui-gallery') const results = await new AxeBuilder({ page }).analyze() expect(results.violations).toEqual([]) }) test('Offline page — no axe violations', async ({ page }) => { await page.goto('/offline') const results = await new AxeBuilder({ page }).analyze() expect(results.violations).toEqual([]) }) })
Установка:
bashnpm install -D @axe-core/playwright
13.8 Итоговая команда тестирования
bash# Полная проверка npm run test:coverage # unit + контрактные, coverage ≥ 70% npm run test:e2e # Playwright: smoke, autocomplete, send, offline, a11y
13.9 Антипаттерны
| Антипаттерн | Как правильно |
|---|---|
| Тестирование реализации, а не поведения | expect(store.getState(...)), не expect(internalVariable) |
| Snapshot-тесты для нестабильного вывода | Snapshot только для статичного контента (токены) |
waitFor без timeout | vi.waitFor(() => ..., { timeout: 5000 }) |
Тесты с живым fetch | MSW-перехватчик всегда |
| E2e на каждый чих | E2e только критические сценарии (3–5 тестов) |
| 0 assertions в тесте | Каждый it должен иметь хотя бы 1 expect |
Компромиссы (Trade-offs)
| Подход | Плюсы | Минусы |
|---|---|---|
| Unit-тесты (Vitest + RTL) | Быстрые, массовые, высокий coverage | Не ловят интеграционные баги |
| Контрактные (MSW + Zod) | Ловят расхождения API в CI | Нужно поддерживать схемы |
| E2E (Playwright) | Реальный пользовательский сценарий | Медленные, хрупкие, дорогие |
| Coverage ≥ 70% | Гарантирует базовое покрытие | Не гарантирует корректность логики |
| Snapshot-тесты | Ловят визуальный дрейф | Хрупкие, часто «обновить снапшот» |
💻 13.10 Практика
- Расширьте тесты компонентов до всех из чек-листа §13.3.
- Напишите контрактные тесты для
/api/mentions(§13.5). - Напишите e2e на автокомплит и отправку (§13.6).
- Установите
@axe-core/playwright, напишите a11y-тесты (§13.7). - Поднимите coverage пороги до 70% (§13.4).
bashnpm run test:coverage npm run test:e2e
13.11 Артефакты сдачи М13
| Артефакт | Критерий |
|---|---|
| Coverage-отчёт | Lines ≥ 70%, Functions ≥ 70% |
| Контракт-тесты | 3 кейса: валидный, нет name, null вместо array |
| E2e | Autocomplete + send message — оба проходят |
| A11y axe-отчёт | 0 violations на UI Gallery и /offline |
Вперёд → Модуль 14: сборка, публикация и CI/CD.
🧩 Проверь себя
🔗 Мостик к Модулю 14
Тесты зелёные, coverage ≥ 70%. Теперь опубликуем UI Kit как npm-пакет: exports map, size-limit, changesets и CI/CD.