Vitest с Nuxt, @nuxt/test-utils, тестирование страниц, server routes и composables в Nuxt-окружении
Тестирование Nuxt отличается от обычного Vue: авто-импорты, server routes, middleware, SSR-специфичный код — всё это требует специальной настройки. @nuxt/test-utils решает большинство этих проблем.
Четыре уровня для Nuxt приложения:
Unit-тесты — composables, утилиты, Pinia stores. Не требуют Nuxt-контекста, самые быстрые. Запускаются в jsdom.
Component-тесты — Vue компоненты с Nuxt-специфичными зависимостями (useRoute, useState, useFetch). Требуют Nuxt среды для авто-импортов.
Integration-тесты server routes — тестирование API эндпоинтов: запросы, ответы, авторизация. Запускают реальный Nitro сервер.
E2E-тесты — полный сценарий в реальном браузере. Playwright + @nuxt/test-utils/e2e. Медленные, тестируют финальный продукт.
npm install -D @nuxt/test-utils vitest @vue/test-utils happy-dom playwright-coreКонфигурация vitest.config.ts — обязательно используйте defineVitestConfig:
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
environment: 'nuxt', // Nuxt-окружение для авто-импортов
environmentOptions: {
nuxt: {
rootDir: '.' // Корень проекта
}
},
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['**/*.config.*', 'tests/**']
}
}
})Разница с обычным Vitest: environment: 'nuxt' разрешает авто-импорты и создаёт Nuxt-контекст. Без этого useRoute(), useState(), useFetch() бросят ошибки.
Компоненты с async setup (использующие await useFetch()) нельзя монтировать обычным mount. Используйте mountSuspense:
<!-- components/UserCard.vue -->
<script setup lang="ts">
const props = defineProps<{ userId: number }>()
// async setup — требует Suspense обёртки
const { data: user } = await useFetch(`/api/users/${props.userId}`)
</script>
<template>
<div class="card" v-if="user">
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</div>
</template>// components/UserCard.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mountSuspense } from '@nuxt/test-utils/runtime'
import UserCard from './UserCard.vue'
// Мокируем useFetch
vi.mock('#imports', async () => {
const actual = await vi.importActual('#imports')
return {
...actual,
useFetch: vi.fn()
}
})
describe('UserCard', () => {
it('отображает данные пользователя', async () => {
vi.mocked(useFetch).mockResolvedValueOnce({
data: ref({ name: 'Анна Иванова', email: 'anna@example.com' }),
pending: ref(false),
error: ref(null)
} as any)
const wrapper = await mountSuspense(UserCard, {
props: { userId: 1 }
})
expect(wrapper.find('h2').text()).toBe('Анна Иванова')
expect(wrapper.find('p').text()).toBe('anna@example.com')
})
})mockNuxtImport — специальный helper для подмены авто-импортируемых composables:
import { mockNuxtImport } from '@nuxt/test-utils/runtime'
import { mountSuspense } from '@nuxt/test-utils/runtime'
// Мокируем useUserSession из nuxt-auth-utils
const { mockSession, restoreSession } = mockNuxtImport('useUserSession', () => {
const user = ref<{ name: string; role: string } | null>(null)
const loggedIn = computed(() => user.value !== null)
function setUser(userData: { name: string; role: string } | null) {
user.value = userData
}
return { user, loggedIn, setUser }
})
describe('Navbar', () => {
afterEach(() => {
restoreSession()
})
it('показывает кнопку входа для гостей', async () => {
const wrapper = await mountSuspense(Navbar)
expect(wrapper.find('[data-test="login-btn"]').exists()).toBe(true)
})
it('показывает имя для авторизованных', async () => {
mockSession({ name: 'Иван', role: 'user' })
const wrapper = await mountSuspense(Navbar)
expect(wrapper.text()).toContain('Иван')
})
})Composables, использующие Nuxt-специфичные функции, нужно тестировать в Nuxt-окружении:
// composables/useCart.ts
export function useCart() {
// useState — Nuxt composable для SSR-совместимого состояния
const cartItems = useState<CartItem[]>('cart', () => [])
function addItem(item: CartItem) {
const existing = cartItems.value.find(i => i.id === item.id)
if (existing) {
existing.quantity++
} else {
cartItems.value.push({ ...item, quantity: 1 })
}
}
const totalPrice = computed(() =>
cartItems.value.reduce((sum, i) => sum + i.price * i.quantity, 0)
)
return { cartItems, addItem, totalPrice }
}// composables/useCart.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
describe('useCart', () => {
beforeEach(() => {
// Сбрасываем useState между тестами
const nuxtApp = useNuxtApp()
nuxtApp.payload.state['cart'] = []
})
it('добавляет новый товар', () => {
const { cartItems, addItem } = useCart()
addItem({ id: 1, name: 'Книга', price: 500 })
expect(cartItems.value).toHaveLength(1)
expect(cartItems.value[0].quantity).toBe(1)
})
it('увеличивает количество существующего товара', () => {
const { cartItems, addItem } = useCart()
addItem({ id: 1, name: 'Книга', price: 500 })
addItem({ id: 1, name: 'Книга', price: 500 })
expect(cartItems.value).toHaveLength(1)
expect(cartItems.value[0].quantity).toBe(2)
})
it('считает общую стоимость', () => {
const { addItem, totalPrice } = useCart()
addItem({ id: 1, name: 'Книга', price: 500 })
addItem({ id: 2, name: 'Курс', price: 2000 })
expect(totalPrice.value).toBe(2500)
})
})Server routes тестируются через $fetch в integration тестах. setup() запускает Nuxt приложение:
// server/api/posts.get.ts
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const page = Number(query.page) || 1
const limit = Number(query.limit) || 10
const posts = await db.posts.findMany({
skip: (page - 1) * limit,
take: limit
})
return { posts, page, limit, total: await db.posts.count() }
})// tests/api/posts.test.ts
import { describe, it, expect } from 'vitest'
import { setup, $fetch } from '@nuxt/test-utils/e2e'
// setup запускает Nuxt сервер (медленно — только для integration тестов)
await setup({
rootDir: '.',
server: true
})
describe('GET /api/posts', () => {
it('возвращает список постов', async () => {
const response = await $fetch('/api/posts')
expect(response.posts).toBeInstanceOf(Array)
expect(response.page).toBe(1)
expect(response.limit).toBe(10)
})
it('поддерживает пагинацию', async () => {
const response = await $fetch('/api/posts?page=2&limit=5')
expect(response.page).toBe(2)
expect(response.limit).toBe(5)
expect(response.posts).toHaveLength(5)
})
it('возвращает 401 для защищённых маршрутов', async () => {
// Нет авторизации → 401
await expect($fetch('/api/admin/stats')).rejects.toMatchObject({
status: 401
})
})
})В Nuxt среде Pinia инициализируется автоматически. Важно очищать состояние между тестами:
import { describe, it, expect, beforeEach } from 'vitest'
import { useAuthStore } from '@/stores/auth'
describe('useAuthStore', () => {
let authStore: ReturnType<typeof useAuthStore>
beforeEach(() => {
// Создаём свежий инстанс Pinia
const pinia = createPinia()
setActivePinia(pinia)
authStore = useAuthStore()
})
it('начинает с неавторизованного состояния', () => {
expect(authStore.isLoggedIn).toBe(false)
expect(authStore.user).toBeNull()
})
it('устанавливает пользователя после логина', async () => {
// Мокируем API
vi.mocked($fetch).mockResolvedValueOnce({
user: { id: 1, name: 'Тест', role: 'user' }
})
await authStore.login('test@example.com', 'password')
expect(authStore.isLoggedIn).toBe(true)
expect(authStore.user?.name).toBe('Тест')
})
})Для E2E нужен запущенный Nuxt сервер. @nuxt/test-utils/e2e запускает его автоматически:
// tests/e2e/auth.spec.ts
import { describe, it, expect, beforeAll } from 'vitest'
import { setup, createPage, url } from '@nuxt/test-utils/e2e'
// Запустить Nuxt сервер
await setup({
rootDir: '.',
browser: true // Включает поддержку браузера через Playwright
})
describe('Авторизация', () => {
it('пользователь может войти', async () => {
const page = await createPage()
await page.goto(url('/login'))
await page.fill('[data-test="email"]', 'user@example.com')
await page.fill('[data-test="password"]', 'password123')
await page.click('[data-test="submit"]')
// Ждём редиректа
await page.waitForURL(url('/dashboard'))
const userName = await page.locator('[data-test="user-name"]').textContent()
expect(userName).toContain('Иван')
})
it('блокирует доступ к защищённым страницам', async () => {
const page = await createPage()
await page.goto(url('/dashboard'))
// Должен редиректнуть на логин
await page.waitForURL(/\/login/)
expect(page.url()).toContain('/login')
})
})Важный класс тестов — убедиться что SSR-рендеринг работает корректно:
import { $fetch } from '@nuxt/test-utils/e2e'
it('страница рендерится на сервере с контентом', async () => {
// $fetch без javascript — получаем чистый HTML от сервера
const html = await $fetch('/', { responseType: 'text' })
// Проверяем что ключевой контент присутствует в HTML (не добавлен JS)
expect(html).toContain('<title>')
expect(html).toContain('data-server-rendered="true"')
// Проверяем что мета-теги сгенерированы
expect(html).toContain('meta name="description"')
})Настройка автоматического запуска тестов в GitHub Actions:
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run test:run
- run: npm run test:coverage
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/Ошибка 1: window is not defined в server-side коде. Nuxt выполняет код и на сервере, где нет window. Оборачивайте browser-only код в if (import.meta.client) или onMounted.
Ошибка 2: Nuxt context outside setup. Composables, использующие useNuxtApp(), нельзя вызывать вне компонента или setup функции. В тестах убедитесь что вы в правильном контексте.
Ошибка 3: авто-импорты не работают. Если тесты падают с "useRoute is not defined" — вы не используете environment: 'nuxt' в vitest.config.ts.
Ошибка 4: тестирование деталей реализации вместо поведения. Не проверяйте внутренние переменные компонента. Проверяйте что пользователь видит и что происходит после его действий.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.