Перейти к содержимому

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

Тестирование — неотъемлемая часть надёжного приложения. @nuxt/test-utils предоставляет специальные утилиты для тестирования компонентов, composables и серверных маршрутов в контексте Nuxt.


Окно терминала
npm install -D @nuxt/test-utils vitest @vue/test-utils happy-dom playwright
vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
environment: 'nuxt',
environmentOptions: {
nuxt: {
rootDir: fileURLToPath(new URL('./', import.meta.url)),
domEnvironment: 'happy-dom'
}
}
}
})
package.json
{
"scripts": {
"test": "vitest",
"test:unit": "vitest run",
"test:e2e": "playwright test",
"test:coverage": "vitest run --coverage"
}
}

mountSuspended — аналог mount из Vue Test Utils, но с поддержкой async setup и Nuxt-контекста:

tests/components/MyButton.test.ts
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 — рендеринг в строку HTML для snapshot-тестов:

tests/components/ArticleCard.test.ts
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/useCounter.ts
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 }
}
tests/composables/useCounter.test.ts
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-зависимостями»
composables/useUser.ts
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 }
}
tests/composables/useUser.test.ts
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 () => {
const mockUser = { id: '1', name: 'Александр', email: '[email protected]' }
mockFetch.mockResolvedValue(mockUser)
const { user, fetchUser } = useUser()
await fetchUser('1')
expect(user.value).toEqual(mockUser)
expect(mockFetch).toHaveBeenCalledWith('/api/users/1')
})
})

server/api/users/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
if (!id || isNaN(Number(id))) {
throw createError({ statusCode: 400, message: 'Некорректный ID' })
}
return { id, name: 'Тестовый пользователь', email: '[email protected]' }
})
tests/server/api/users.test.ts
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
})
})
})

playwright.config.ts
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'] } }
]
})
e2e/auth.spec.ts
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="email"]', '[email protected]')
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="email"]', '[email protected]')
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

vitest.config.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
}
}
}
})