Стандарты

Тестирование

Требования к тестированию fullstack-проектов и модулей.

Требования к тестированию fullstack-проектов и модулей.

Обязательный стек

ИнструментНазначениеУровень
VitestUnit-тестыПроект + Модули
StorybookДокументация компонентовПроект + UI layer
ChromaticVisual regressionПроект + UI layer
CypressE2E тестыПроект
ArtilleryНагрузочные тестыПроект
UnlighthousePerformance regressionПроект
SentryError monitoringПроект

Vitest (Unit)

Что тестировать

ОбязательноЖелательно
ComposablesPage components
Utils/helpersComplex components
Store actionsEdge cases
Чистые функции

Структура тестов

app/
├── composables/
│   ├── useOrders.ts
│   └── useOrders.test.ts      # Рядом с файлом
├── utils/
│   ├── formatCurrency.ts
│   └── formatCurrency.test.ts
└── stores/
    ├── user.ts
    └── user.test.ts

Пример теста

// useOrders.test.ts
import { describe, it, expect, vi } from 'vitest'
import { useOrders } from './useOrders'

describe('useOrders', () => {
  it('fetches orders list', async () => {
    const { orders, fetchOrders } = useOrders()

    await fetchOrders()

    expect(orders.value).toHaveLength(10)
  })

  it('handles error', async () => {
    vi.mocked(useAuthFetch).mockRejectedValue(new Error('Network error'))

    const { error, fetchOrders } = useOrders()
    await fetchOrders()

    expect(error.value).toBe('Network error')
  })
})

Конфигурация

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'happy-dom',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      exclude: ['node_modules', 'tests'],
    },
    include: ['app/**/*.test.ts'],
  },
})

Coverage пороги

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      thresholds: {
        statements: 60,
        branches: 60,
        functions: 60,
        lines: 60,
      },
    },
  },
})

Storybook + Chromatic (Component)

Что документировать

ОбязательноЖелательно
UI layer компонентыDomain components
Shared компонентыLayout components
Компоненты с состояниями

Структура stories

app/components/
├── OrderCard/
│   ├── OrderCard.vue
│   └── OrderCard.stories.ts
└── ui/
    ├── Button/
    │   ├── Button.vue
    │   └── Button.stories.ts

Пример story

// OrderCard.stories.ts
import type { Meta, StoryObj } from '@storybook/vue3'
import OrderCard from './OrderCard.vue'

const meta: Meta<typeof OrderCard> = {
  title: 'Domain/OrderCard',
  component: OrderCard,
  tags: ['autodocs'],
  argTypes: {
    status: {
      control: 'select',
      options: ['pending', 'processing', 'completed', 'cancelled'],
    },
  },
}

export default meta
type Story = StoryObj<typeof OrderCard>

export const Default: Story = {
  args: {
    order: {
      id: '123',
      status: 'pending',
      total: 15000,
      createdAt: '2025-01-23',
    },
  },
}

export const Processing: Story = {
  args: {
    order: {
      id: '124',
      status: 'processing',
      total: 25000,
      createdAt: '2025-01-22',
    },
  },
}

export const Completed: Story = {
  args: {
    order: {
      id: '125',
      status: 'completed',
      total: 30000,
      createdAt: '2025-01-21',
    },
  },
}

Chromatic интеграция

# Установка
pnpm add -D chromatic

# Запуск
pnpm chromatic --project-token=<token>

CI интеграция:

chromatic:
  script:
    - pnpm storybook:build
    - pnpm chromatic --project-token=$CHROMATIC_TOKEN --exit-zero-on-changes

Cypress (E2E)

Что тестировать

ОбязательноЖелательно
АвторизацияНавигация
Критические формыФильтрация
Основные CRUD операцииPagination
Оплата (если есть)

Структура тестов

tests/
└── e2e/
    ├── auth/
    │   ├── login.cy.ts
    │   └── logout.cy.ts
    ├── orders/
    │   ├── create.cy.ts
    │   ├── list.cy.ts
    │   └── details.cy.ts
    └── support/
        ├── commands.ts
        └── e2e.ts

Пример теста

// auth/login.cy.ts
describe('Login', () => {
  beforeEach(() => {
    cy.visit('/login')
  })

  it('logs in with valid credentials', () => {
    cy.get('[data-testid="email"]').type('admin@example.com')
    cy.get('[data-testid="password"]').type('password123')
    cy.get('[data-testid="submit"]').click()

    cy.url().should('eq', Cypress.config('baseUrl') + '/')
    cy.get('[data-testid="user-menu"]').should('be.visible')
  })

  it('shows error for invalid credentials', () => {
    cy.get('[data-testid="email"]').type('wrong@example.com')
    cy.get('[data-testid="password"]').type('wrongpassword')
    cy.get('[data-testid="submit"]').click()

    cy.get('[data-testid="error-message"]').should('contain', 'Invalid credentials')
  })
})

Cypress Studio

Использовать Cypress Studio для записи тестов:

// cypress.config.ts
export default defineConfig({
  e2e: {
    experimentalStudio: true,
  },
})

Custom Commands

// support/commands.ts
Cypress.Commands.add('login', (email: string, password: string) => {
  cy.session([email, password], () => {
    cy.visit('/login')
    cy.get('[data-testid="email"]').type(email)
    cy.get('[data-testid="password"]').type(password)
    cy.get('[data-testid="submit"]').click()
    cy.url().should('not.include', '/login')
  })
})

Artillery (Load)

Когда использовать

  • Перед релизом major features
  • После оптимизаций
  • Регулярно (nightly builds)

Конфигурация

# tests/load/api-load.yml
config:
  target: "https://api.example.com"
  phases:
    - duration: 60
      arrivalRate: 10
      name: "Warm up"
    - duration: 120
      arrivalRate: 50
      name: "Sustained load"
    - duration: 60
      arrivalRate: 100
      name: "Peak load"

scenarios:
  - name: "Browse orders"
    flow:
      - get:
          url: "/api/orders"
          headers:
            Authorization: "Bearer $processEnvironment.TEST_TOKEN"
      - think: 2
      - get:
          url: "/api/orders/$randomNumber(1, 1000)"

Пороги

config:
  ensure:
    p95: 500        # 95th percentile < 500ms
    maxErrorRate: 1 # Error rate < 1%

Unlighthouse (Performance)

Конфигурация

// unlighthouse.config.ts
export default {
  site: 'https://app.example.com',
  scanner: {
    device: 'desktop',
    throttle: true,
  },
  ci: {
    budget: {
      performance: 80,
      accessibility: 90,
      'best-practices': 80,
      seo: 80,
    },
  },
}

CI интеграция

performance:
  script:
    - pnpm unlighthouse-ci --site https://staging.example.com
  artifacts:
    paths:
      - .unlighthouse/

Sentry (Errors)

Настройка

pnpm add @sentry/nuxt
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@sentry/nuxt/module'],

  sentry: {
    dsn: process.env.SENTRY_DSN,
  },

  sourcemap: {
    client: true,
  },
})

Ручной отлов ошибок

// В composable или component
import * as Sentry from '@sentry/nuxt'

try {
  await riskyOperation()
} catch (error) {
  Sentry.captureException(error, {
    tags: {
      module: 'orders',
      action: 'create',
    },
    extra: {
      orderId: order.id,
    },
  })
}
Sentry.addBreadcrumb({
  category: 'navigation',
  message: `User navigated to ${route.path}`,
  level: 'info',
})

User Context

// При авторизации
Sentry.setUser({
  id: user.id,
  email: user.email,
  username: user.name,
})

// При выходе
Sentry.setUser(null)

Scripts в package.json

{
  "scripts": {
    "test": "vitest",
    "test:unit": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "cypress run",
    "test:e2e:open": "cypress open",
    "test:load": "artillery run tests/load/api-load.yml",
    "storybook": "storybook dev -p 6006",
    "storybook:build": "storybook build",
    "chromatic": "chromatic --exit-zero-on-changes",
    "unlighthouse": "unlighthouse-ci"
  }
}

Чек-лист

При создании проекта

  • Настроен Vitest
  • Настроен Storybook
  • Настроен Cypress
  • Настроен Sentry
  • CI pipeline включает тесты

При создании компонента

  • Unit-тест для логики (если есть)
  • Story в Storybook (для shared)
  • data-testid для E2E

При создании composable

  • Unit-тесты для основных сценариев
  • Тест для error handling

Code Review

  • Новая логика покрыта тестами
  • Stories обновлены (если UI изменился)
  • data-testid добавлены (для новых элементов)

Связанные документы