Модуль 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.tsopen, close, toggle
Тостыfeatures/toasts/model.tsadd, remove, toast.success/error/info
Автокомплитfeatures/mention-autocomplete/model.tschange, fetch success, fetch error, short query
Сообщенияentities/message/model.tsfetch, optimistic add, error
Офлайн-очередьfeatures/offline-queue/model.tsqueue, 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()
  })
})

Чек-лист компонентов к тестированию

КомпонентКейсовЧто проверять
Box2рендер, direction/gap/padding
Text3variant → тег, color → класс, weight
Button6variants (4), sizes (3), loading, onClick, disabled, focus
Icon2рендер, aria-hidden по умолчанию
Divider2orientation, aria-hidden
Input4error + aria, hint, ref, user input
Textarea2рендер, error
Avatar3src, fallback (initials), status dot
MessageBubble2own vs other, status indicator
Modal3не рендерится когда закрыт, Esc, role=dialog
Tooltip1role=tooltip
Skeleton1aria-hidden
Tabs2role=tablist, arrow navigation
Reaction2selected 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,
  },
},
bash
npm 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([])
  })
})

Установка:

bash
npm 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 без timeoutvi.waitFor(() => ..., { timeout: 5000 })
Тесты с живым fetchMSW-перехватчик всегда
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 Практика

  1. Расширьте тесты компонентов до всех из чек-листа §13.3.
  2. Напишите контрактные тесты для /api/mentions (§13.5).
  3. Напишите e2e на автокомплит и отправку (§13.6).
  4. Установите @axe-core/playwright, напишите a11y-тесты (§13.7).
  5. Поднимите coverage пороги до 70% (§13.4).
bash
npm run test:coverage
npm run test:e2e

13.11 Артефакты сдачи М13

АртефактКритерий
Coverage-отчётLines ≥ 70%, Functions ≥ 70%
Контракт-тесты3 кейса: валидный, нет name, null вместо array
E2eAutocomplete + send message — оба проходят
A11y axe-отчёт0 violations на UI Gallery и /offline

Вперёд → Модуль 14: сборка, публикация и CI/CD.


🧩 Проверь себя

1. Тест-стратегия как слой в FSD — что это значит?
2. Coverage (line/branch) — единственный показатель качества?
3. Visual regression (Chromatic/Percy) — для какого уровня?
4. Что замокать (mock) на unit-уровне?
5. E2E (Playwright/Cypress) — на каком уровне?

🔗 Мостик к Модулю 14

Тесты зелёные, coverage ≥ 70%. Теперь опубликуем UI Kit как npm-пакет: exports map, size-limit, changesets и CI/CD.