Testing Nuxt Applications With Vitest: A Practical Setup
A complete testing setup for Nuxt 3 and 4 — unit tests for composables and stores, component testing with Vue Test Library, and E2E tests with Playwright.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
Testing Nuxt applications has a reputation for being complicated. You have SSR, auto-imports, Pinia stores, Nitro server routes, and Vue components all in the same codebase, and each one has different testing requirements. The good news is that the tooling has matured considerably, and with the right setup you can have comprehensive test coverage without fighting your framework constantly.
This guide covers the complete testing stack I use on production Nuxt applications: unit tests with Vitest, component tests with Vue Testing Library, and E2E tests with Playwright.
The Testing Stack
I use three layers of tests:
Unit tests for composables, stores, and pure utility functions. Fast, no browser needed, runs in Node.js.
Component tests for Vue components. Validates rendering, user interactions, and prop/emit behavior. Uses jsdom or happy-dom to simulate a browser environment.
E2E tests for critical user flows. Runs a real browser against a running application. Slower but catches integration bugs that unit tests miss.
The ratio I aim for: many unit tests, reasonable component tests for complex components, and a focused set of E2E tests for critical paths.
Installing @nuxt/test-utils
npm install --save-dev @nuxt/test-utils vitest @vue/test-utils happy-dom playwright-core
Configure Vitest in vitest.config.ts:
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
environment: 'nuxt',
environmentOptions: {
nuxt: {
rootDir: '.',
domEnvironment: 'happy-dom',
},
},
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['composables/**', 'stores/**', 'utils/**', 'components/**'],
exclude: ['node_modules', '.nuxt', 'server/**'],
},
},
})
Add test scripts to package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test"
}
}
Testing Composables
Composables are the easiest things to test because they are just functions. The @nuxt/test-utils environment sets up the Nuxt context so auto-imports work:
// composables/useCounter.ts
export function useCounter(initial = 0) {
const count = ref(initial)
const doubled = computed(() => count.value * 2)
function increment() { count.value++ }
function decrement() { count.value-- }
function reset() { count.value = initial }
return { count, doubled, increment, decrement, reset }
}
// composables/__tests__/useCounter.test.ts
import { describe, it, expect } from 'vitest'
describe('useCounter', () => {
it('initializes with default value', () => {
const { count } = useCounter()
expect(count.value).toBe(0)
})
it('initializes with provided value', () => {
const { count } = useCounter(5)
expect(count.value).toBe(5)
})
it('increments count', () => {
const { count, increment } = useCounter()
increment()
expect(count.value).toBe(1)
})
it('computes doubled value', () => {
const { count, doubled, increment } = useCounter(3)
expect(doubled.value).toBe(6)
increment()
expect(doubled.value).toBe(8)
})
it('resets to initial value', () => {
const { count, increment, reset } = useCounter(5)
increment()
increment()
reset()
expect(count.value).toBe(5)
})
})
For composables that make API calls, mock the fetch calls with vi.fn() or use MSW (Mock Service Worker):
// composables/__tests__/usePosts.test.ts
import { vi, describe, it, expect, beforeEach } from 'vitest'
vi.mock('#app', () => ({
useFetch: vi.fn(),
}))
describe('usePosts', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns posts from API', async () => {
const mockPosts = [
{ id: '1', title: 'Test Post', slug: 'test-post' },
]
vi.mocked(useFetch).mockResolvedValue({
data: ref(mockPosts),
pending: ref(false),
error: ref(null),
refresh: vi.fn(),
})
const { posts, loading } = await usePosts()
expect(posts.value).toEqual(mockPosts)
expect(loading.value).toBe(false)
})
})
Testing Pinia Stores
Store tests are straightforward with createPinia from the test utilities:
// stores/__tests__/cart.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCartStore } from '../cart'
describe('CartStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('starts with empty cart', () => {
const cart = useCartStore()
expect(cart.items).toHaveLength(0)
expect(cart.total).toBe(0)
})
it('adds an item to cart', () => {
const cart = useCartStore()
cart.addItem({ productId: 'p1', name: 'Widget', price: 29.99, quantity: 1 })
expect(cart.items).toHaveLength(1)
expect(cart.total).toBe(29.99)
})
it('increments quantity for duplicate items', () => {
const cart = useCartStore()
cart.addItem({ productId: 'p1', name: 'Widget', price: 10, quantity: 1 })
cart.addItem({ productId: 'p1', name: 'Widget', price: 10, quantity: 2 })
expect(cart.items).toHaveLength(1)
expect(cart.items[0].quantity).toBe(3)
expect(cart.total).toBe(30)
})
it('removes an item', () => {
const cart = useCartStore()
cart.addItem({ productId: 'p1', name: 'Widget', price: 10, quantity: 1 })
cart.removeItem('p1')
expect(cart.items).toHaveLength(0)
})
})
Component Testing
Component tests verify rendering and user interactions:
// components/__tests__/AppButton.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import AppButton from '../AppButton.vue'
describe('AppButton', () => {
it('renders slot content', () => {
const wrapper = mount(AppButton, {
slots: { default: 'Click me' },
})
expect(wrapper.text()).toBe('Click me')
})
it('emits click event', async () => {
const wrapper = mount(AppButton)
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toBeTruthy()
})
it('is disabled when disabled prop is true', () => {
const wrapper = mount(AppButton, {
props: { disabled: true },
})
expect(wrapper.attributes('disabled')).toBeDefined()
})
it('shows loading spinner when loading', () => {
const wrapper = mount(AppButton, {
props: { loading: true },
})
expect(wrapper.find('[data-testid="spinner"]').exists()).toBe(true)
})
it('applies correct variant classes', () => {
const wrapper = mount(AppButton, {
props: { variant: 'danger' },
})
expect(wrapper.classes()).toContain('bg-red-600')
})
})
For components that use Pinia, Nuxt composables, or routing, use the mountSuspense helper from @nuxt/test-utils:
import { mountSuspense } from '@nuxt/test-utils/runtime'
it('shows user name from store', async () => {
const wrapper = await mountSuspense(UserProfile, {
global: {
plugins: [createTestingPinia({
initialState: {
user: { user: { id: '1', name: 'James Ross' } },
},
})],
},
})
expect(wrapper.text()).toContain('James Ross')
})
Testing Server Routes
Test your Nitro API routes with the test server utilities:
// server/api/__tests__/users.test.ts
import { describe, it, expect } from 'vitest'
import { setup, $fetch, createError } from '@nuxt/test-utils'
describe('Users API', async () => {
await setup({ server: true })
it('GET /api/users returns paginated list', async () => {
const result = await $fetch('/api/users')
expect(result).toHaveProperty('data')
expect(result).toHaveProperty('pagination')
expect(Array.isArray(result.data)).toBe(true)
})
it('POST /api/users validates input', async () => {
await expect(
$fetch('/api/users', {
method: 'POST',
body: { email: 'invalid' },
})
).rejects.toMatchObject({ status: 422 })
})
})
E2E Testing With Playwright
Playwright tests run against a real browser and a real running application:
// playwright.config.ts
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
webServer: {
command: 'npm run dev',
port: 3000,
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://localhost:3000',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
],
})
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test('user can log in', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('test@example.com')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Log in' }).click()
await expect(page).toHaveURL('/dashboard')
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
})
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('wrong@example.com')
await page.getByLabel('Password').fill('wrongpassword')
await page.getByRole('button', { name: 'Log in' }).click()
await expect(page.getByRole('alert')).toContainText('Invalid credentials')
})
CI Integration
Add tests to your CI pipeline (GitHub Actions):
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm run test:coverage
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e
Testing is not optional for applications that matter. The setup investment pays back every time you refactor a composable confidently, every time CI catches a regression before it reaches production, and every time you hand off a codebase to another developer who can read tests to understand intent.
Want help setting up a complete testing strategy for your Nuxt application, or a code review of your existing test coverage? Book a call: calendly.com/jamesrossjr.