21. Тестирование
🧪 Тестирование Nuxt 3
Заголовок раздела «🧪 Тестирование Nuxt 3»Тестирование — неотъемлемая часть надёжного приложения. @nuxt/test-utils предоставляет специальные утилиты для тестирования компонентов, composables и серверных маршрутов в контексте Nuxt.
🚀 Установка
Заголовок раздела «🚀 Установка»npm install -D @nuxt/test-utils vitest @vue/test-utils happy-dom playwrightimport { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({ test: { environment: 'nuxt', environmentOptions: { nuxt: { rootDir: fileURLToPath(new URL('./', import.meta.url)), domEnvironment: 'happy-dom' } } }}){ "scripts": { "test": "vitest", "test:unit": "vitest run", "test:e2e": "playwright test", "test:coverage": "vitest run --coverage" }}🔧 mountSuspended
Заголовок раздела «🔧 mountSuspended»mountSuspended — аналог mount из Vue Test Utils, но с поддержкой async setup и Nuxt-контекста:
import { describe, it, expect } from 'vitest'import { mountSuspended } from '@nuxt/test-utils/runtime'import MyButton from '~/components/MyButton.vue'
describe('MyButton', () => { it('renders correctly', async () => { const wrapper = await mountSuspended(MyButton, { props: { label: 'Нажми меня' } }) expect(wrapper.text()).toBe('Нажми меня') })
it('emits click event', async () => { const wrapper = await mountSuspended(MyButton, { props: { label: 'Кнопка' } }) await wrapper.trigger('click') expect(wrapper.emitted('click')).toBeTruthy() })
it('applies disabled state', async () => { const wrapper = await mountSuspended(MyButton, { props: { label: 'Кнопка', disabled: true } }) expect(wrapper.attributes('disabled')).toBeDefined() })})📄 renderSuspended
Заголовок раздела «📄 renderSuspended»renderSuspended — рендеринг в строку HTML для snapshot-тестов:
import { describe, it, expect } from 'vitest'import { renderSuspended } from '@nuxt/test-utils/runtime'import ArticleCard from '~/components/ArticleCard.vue'
const mockArticle = { title: 'Тестовая статья', description: 'Описание статьи', date: '2024-01-15', author: 'Александр'}
describe('ArticleCard', () => { it('renders article data', async () => { const html = await renderSuspended(ArticleCard, { props: { article: mockArticle } }) expect(html).toContain('Тестовая статья') expect(html).toContain('Александр') })
it('matches snapshot', async () => { const html = await renderSuspended(ArticleCard, { props: { article: mockArticle } }) expect(html).toMatchSnapshot() })})🪝 Тестирование composables
Заголовок раздела «🪝 Тестирование composables»export function useCounter(initial = 0) { const count = ref(initial) const increment = () => count.value++ const decrement = () => count.value-- const reset = () => count.value = initial return { count, increment, decrement, reset }}import { describe, it, expect } from 'vitest'import { useCounter } from '~/composables/useCounter'
describe('useCounter', () => { it('starts at 0 by default', () => { const { count } = useCounter() expect(count.value).toBe(0) })
it('increments correctly', () => { const { count, increment } = useCounter() increment() increment() expect(count.value).toBe(2) })
it('decrements correctly', () => { const { count, decrement } = useCounter(5) decrement() expect(count.value).toBe(4) })
it('resets to initial value', () => { const { count, increment, reset } = useCounter(10) increment() increment() reset() expect(count.value).toBe(10) })})🌐 Тестирование composables с Nuxt-зависимостями
Заголовок раздела «🌐 Тестирование composables с Nuxt-зависимостями»export function useUser() { const user = useState<User | null>('user', () => null)
async function fetchUser(id: string) { user.value = await $fetch(`/api/users/${id}`) }
return { user, fetchUser }}import { describe, it, expect, vi } from 'vitest'import { mockNuxtImport } from '@nuxt/test-utils/runtime'import { useUser } from '~/composables/useUser'
const mockFetch = vi.fn()mockNuxtImport('$fetch', () => mockFetch)
describe('useUser', () => { it('fetches user data', async () => { mockFetch.mockResolvedValue(mockUser)
const { user, fetchUser } = useUser() await fetchUser('1')
expect(user.value).toEqual(mockUser) expect(mockFetch).toHaveBeenCalledWith('/api/users/1') })})🔌 Тестирование серверных маршрутов
Заголовок раздела «🔌 Тестирование серверных маршрутов»export default defineEventHandler(async (event) => { const id = getRouterParam(event, 'id')
if (!id || isNaN(Number(id))) { throw createError({ statusCode: 400, message: 'Некорректный ID' }) }
})import { describe, it, expect } from 'vitest'import { setup, $fetch } from '@nuxt/test-utils/e2e'
describe('users API', async () => { // Запуск Nuxt сервера для тестов await setup({ rootDir: fileURLToPath(new URL('../..', import.meta.url)), server: true })
it('returns user data', async () => { const user = await $fetch('/api/users/1') expect(user).toMatchObject({ id: '1', name: expect.any(String) }) })
it('returns 400 for invalid id', async () => { await expect($fetch('/api/users/abc')).rejects.toMatchObject({ statusCode: 400 }) })
it('returns 404 for missing user', async () => { await expect($fetch('/api/users/99999')).rejects.toMatchObject({ statusCode: 404 }) })})🎭 E2E тестирование с Playwright
Заголовок раздела «🎭 E2E тестирование с Playwright»import { defineConfig, devices } from '@playwright/test'
export default defineConfig({ testDir: './e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry' }, webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'Mobile Safari', use: { ...devices['iPhone 12'] } } ]})import { test, expect } from '@playwright/test'
test.describe('Аутентификация', () => { test('показывает форму входа', async ({ page }) => { await page.goto('/login') await expect(page.getByRole('heading', { name: /войти/i })).toBeVisible() await expect(page.getByPlaceholder('Email')).toBeVisible() await expect(page.getByPlaceholder('Пароль')).toBeVisible() })
test('успешный вход', async ({ page }) => { await page.goto('/login') await page.fill('[name="password"]', 'password123') await page.click('button[type="submit"]') await expect(page).toHaveURL('/dashboard') })
test('ошибка при неверных данных', async ({ page }) => { await page.goto('/login') await page.fill('[name="password"]', 'wrongpassword') await page.click('button[type="submit"]') await expect(page.getByText(/неверный/i)).toBeVisible() })})🗂️ Структура тестов
Заголовок раздела «🗂️ Структура тестов»tests/├── unit/│ ├── components/│ │ ├── Button.test.ts│ │ └── Modal.test.ts│ ├── composables/│ │ ├── useCounter.test.ts│ │ └── useUser.test.ts│ └── utils/│ └── format.test.ts├── integration/│ └── server/│ └── api/│ └── users.test.ts└── e2e/ ├── auth.spec.ts ├── dashboard.spec.ts └── navigation.spec.ts📊 Покрытие кода
Заголовок раздела «📊 Покрытие кода»export default defineVitestConfig({ test: { coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], exclude: ['node_modules', '.nuxt', 'dist'], thresholds: { lines: 80, functions: 80, branches: 70, statements: 80 } } }})