Testing Nuxt Apps: Vitest, @nuxt/test-utils, and Playwright (2026)
Nuxt 3 ships with Nitro for the server layer, auto-imported composables, and a file-based routing system. Each of those layers can break independently, and when they do it often looks like silence: a composable that returns stale data, a Nitro route that drops requests under load, a component that renders on the server but throws in the browser.
This guide covers the full testing stack for a Nuxt 3 app: setting up Vitest, testing composables, testing components with @nuxt/test-utils, testing Nitro API routes, end-to-end tests with Playwright, and what your test suite still can't catch once the app is live.
Setting Up Vitest for Nuxt
The official testing module for Nuxt is @nuxt/test-utils. It sets up Vitest, handles Nuxt's auto-import system, and provides utilities for mounting components with the Nuxt runtime context.
npm install -D @nuxt/test-utils vitest @vue/test-utils happy-domConfigure Nuxt to use the testing module:
// nuxt.config.ts
export default defineNuxtConfig({
modules: [
'@nuxt/test-utils/module',
],
})Create a Vitest config:
// vitest.config.ts
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
environment: 'nuxt',
environmentOptions: {
nuxt: {
rootDir: '.',
domEnvironment: 'happy-dom',
},
},
},
})Run tests:
npx vitest run # run once
npx vitest <span class="hljs-comment"># watch mode
npx vitest --coverage <span class="hljs-comment"># with coverage reportThe environment: 'nuxt' setting is what makes Nuxt's auto-imports, composables, and useNuxtApp() available inside tests without manual imports. Without it, every test that touches a Nuxt composable fails with "X is not defined".
Testing Composables
Composables are where a lot of Nuxt application logic lives. They're the right layer to unit test — they're functions, they have clear inputs and outputs, and they don't require a browser to run.
// composables/useProductSearch.ts
export function useProductSearch() {
const query = ref('')
const results = ref<Product[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
async function search(term: string) {
if (!term.trim()) {
results.value = []
return
}
isLoading.value = true
error.value = null
try {
const data = await $fetch<{ products: Product[] }>('/api/products', {
params: { q: term.trim() },
})
results.value = data.products
} catch (e) {
error.value = 'Search failed. Please try again.'
results.value = []
} finally {
isLoading.value = false
}
}
return { query, results, isLoading, error, search }
}// composables/useProductSearch.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useProductSearch } from './useProductSearch'
// Mock $fetch — Nuxt's auto-imported fetch utility
const mockFetch = vi.fn()
vi.stubGlobal('$fetch', mockFetch)
describe('useProductSearch', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns empty results for empty query without calling the API', async () => {
const { results, search } = useProductSearch()
await search(' ')
expect(mockFetch).not.toHaveBeenCalled()
expect(results.value).toEqual([])
})
it('sets results on successful search', async () => {
mockFetch.mockResolvedValue({
products: [
{ id: '1', name: 'Widget A', price: 9.99 },
{ id: '2', name: 'Widget B', price: 14.99 },
],
})
const { results, isLoading, search } = useProductSearch()
await search('widget')
expect(mockFetch).toHaveBeenCalledWith('/api/products', {
params: { q: 'widget' },
})
expect(results.value).toHaveLength(2)
expect(results.value[0].name).toBe('Widget A')
expect(isLoading.value).toBe(false)
})
it('sets error message and clears results on fetch failure', async () => {
mockFetch.mockRejectedValue(new Error('Network error'))
const { results, error, isLoading, search } = useProductSearch()
await search('widget')
expect(error.value).toBe('Search failed. Please try again.')
expect(results.value).toEqual([])
expect(isLoading.value).toBe(false)
})
it('trims whitespace from search query before calling API', async () => {
mockFetch.mockResolvedValue({ products: [] })
const { search } = useProductSearch()
await search(' next.js ')
expect(mockFetch).toHaveBeenCalledWith('/api/products', {
params: { q: 'next.js' },
})
})
})The pattern: mock $fetch globally at the top of the test file, then call the composable directly and assert on the reactive state. Vitest's vi.stubGlobal works cleanly here because Nuxt's $fetch is a global in the Nuxt environment.
Testing Components with @nuxt/test-utils
For components, @nuxt/test-utils wraps Vue Test Utils and provides a mountSuspended helper that handles async setup(), useFetch, and other Nuxt-specific async patterns that plain mount can't deal with.
<!-- components/ProductCard.vue -->
<script setup lang="ts">
interface Props {
product: {
id: string
name: string
price: number
inStock: boolean
}
}
const props = defineProps<Props>()
const { addToCart } = useCart()
const isAdding = ref(false)
async function handleAddToCart() {
isAdding.value = true
await addToCart(props.product.id)
isAdding.value = false
}
</script>
<template>
<div class="product-card">
<h3>{{ product.name }}</h3>
<p class="price">${{ product.price.toFixed(2) }}</p>
<button
:disabled="!product.inStock || isAdding"
@click="handleAddToCart"
>
{{ isAdding ? 'Adding...' : 'Add to Cart' }}
</button>
<span v-if="!product.inStock" class="out-of-stock">Out of stock</span>
</div>
</template>// components/ProductCard.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import ProductCard from './ProductCard.vue'
// Mock the useCart composable
const mockAddToCart = vi.fn().mockResolvedValue(undefined)
vi.mock('~/composables/useCart', () => ({
useCart: () => ({ addToCart: mockAddToCart }),
}))
const mockProduct = {
id: 'prod-1',
name: 'Test Widget',
price: 9.99,
inStock: true,
}
describe('ProductCard', () => {
it('renders product name and price', async () => {
const wrapper = await mountSuspended(ProductCard, {
props: { product: mockProduct },
})
expect(wrapper.find('h3').text()).toBe('Test Widget')
expect(wrapper.find('.price').text()).toBe('$9.99')
})
it('calls addToCart when the button is clicked', async () => {
const wrapper = await mountSuspended(ProductCard, {
props: { product: mockProduct },
})
await wrapper.find('button').trigger('click')
await nextTick()
expect(mockAddToCart).toHaveBeenCalledWith('prod-1')
})
it('disables the button for out-of-stock products', async () => {
const wrapper = await mountSuspended(ProductCard, {
props: { product: { ...mockProduct, inStock: false } },
})
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
expect(wrapper.find('.out-of-stock').exists()).toBe(true)
})
it('shows loading state while adding to cart', async () => {
let resolveAdd!: () => void
mockAddToCart.mockImplementationOnce(
() => new Promise((resolve) => { resolveAdd = resolve })
)
const wrapper = await mountSuspended(ProductCard, {
props: { product: mockProduct },
})
await wrapper.find('button').trigger('click')
await nextTick()
expect(wrapper.find('button').text()).toBe('Adding...')
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
resolveAdd()
})
})mountSuspended is the key difference from plain Vue Test Utils. It wraps the component in a <Suspense> boundary and awaits it — which means components with await useFetch() or await useAsyncData() in setup() actually resolve their data before your assertions run.
Testing Nitro API Routes
Nitro routes live in the server/api/ directory. @nuxt/test-utils provides a $fetch helper that routes requests through the Nuxt server without starting an actual HTTP server — useful for integration tests that need to verify auth, middleware, and database logic together.
// server/api/products/index.get.ts
import { defineEventHandler, getQuery, createError } from 'h3'
import { useDatabase } from '~/server/utils/db'
export default defineEventHandler(async (event) => {
const { q, category } = getQuery(event)
if (!q && !category) {
throw createError({
statusCode: 400,
message: 'Either q or category is required',
})
}
const db = useDatabase()
const products = await db.products.search({
query: q as string,
category: category as string,
})
return { products }
})// server/api/products/index.test.ts
import { describe, it, expect } from 'vitest'
import { setup, $fetch, createPage } from '@nuxt/test-utils/e2e'
describe('GET /api/products', async () => {
await setup({
server: true,
browser: false,
})
it('returns 400 when no query params are provided', async () => {
const response = await $fetch('/api/products', {
method: 'GET',
ignoreResponseError: true,
})
// $fetch throws on 4xx by default — use ignoreResponseError + check status
expect(response).toBeDefined()
})
it('returns products array for a valid query', async () => {
const data = await $fetch('/api/products?q=widget')
expect(data).toHaveProperty('products')
expect(Array.isArray(data.products)).toBe(true)
})
})For simpler route logic — input validation, response shaping — testing the handler function directly (without the setup() overhead) is faster:
// server/api/products/index.unit.test.ts
import { describe, it, expect, vi } from 'vitest'
import handler from './index.get'
// Mock h3 utilities
vi.mock('h3', async () => {
const actual = await vi.importActual<typeof import('h3')>('h3')
return {
...actual,
getQuery: vi.fn(),
createError: vi.fn((opts) => Object.assign(new Error(opts.message), opts)),
}
})
import { getQuery } from 'h3'
const mockGetQuery = vi.mocked(getQuery)
describe('products handler — unit', () => {
it('throws 400 when both q and category are absent', async () => {
mockGetQuery.mockReturnValue({})
await expect(handler({} as any)).rejects.toMatchObject({
statusCode: 400,
})
})
})End-to-End Tests with Playwright
Playwright handles the full browser stack — navigation, JavaScript, network requests, authentication state. Use it for critical user journeys that unit tests can't cover.
npm install -D @playwright/test
npx playwright install// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})// e2e/product-search.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Product search flow', () => {
test('user can search and view product details', async ({ page }) => {
await page.goto('/products')
// Search for a product
await page.getByRole('textbox', { name: /search/i }).fill('widget')
await page.getByRole('button', { name: /search/i }).click()
// Wait for results
await expect(page.getByTestId('product-card').first()).toBeVisible()
// Click the first result
await page.getByTestId('product-card').first().click()
// Verify product detail page loaded
await expect(page).toHaveURL(/\/products\//)
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
})
test('add to cart persists through page reload', async ({ page }) => {
await page.goto('/products/widget-a')
await page.getByRole('button', { name: /add to cart/i }).click()
await expect(page.getByTestId('cart-count')).toHaveText('1')
await page.reload()
// Cart count should survive reload (checks localStorage/cookie persistence)
await expect(page.getByTestId('cart-count')).toHaveText('1')
})
})Run Playwright tests:
npx playwright test <span class="hljs-comment"># all tests
npx playwright <span class="hljs-built_in">test --ui <span class="hljs-comment"># interactive mode with timeline
npx playwright show-report <span class="hljs-comment"># HTML report from last runWhat Code Tests Miss
Your Vitest suite is green. Your Playwright E2E tests pass. You deploy.
Here is what still breaks:
- Nitro server errors under real load — a handler that passes tests fails with a DB pool exhaustion error when 50 users hit it simultaneously
- Nuxt hydration mismatches — server renders correctly, but client-side JavaScript throws a hydration error for a specific viewport size or browser version your tests don't cover
useFetchpolling loops — a component polls an endpoint every 30 seconds; after three days in production, a leaked interval reference causes memory pressure and the app becomes unresponsive- Third-party script failures — an analytics or payment SDK changes its API; the page loads but checkout silently breaks
- Environment-specific config — an environment variable is set in your CI build but missing in your production deployment; a feature that worked in staging is completely absent in production
These failures are invisible to your test suite because your test suite runs against a controlled environment. Production is not controlled.
Adding Production Monitoring with HelpMeTest
HelpMeTest runs plain-English test scripts against your live Nuxt app on a schedule. No browser automation code to write — just describe what a real user does.
Install the CLI:
curl -fsSL https://helpmetest.com/install | bashA production smoke test for your product search:
Go to https://your-nuxt-app.com/products
Type "widget" in the search input
Click the Search button
Verify at least one product card is visible
Click the first product card
Verify the product name heading is visibleSet this to run every 5 minutes. If the search breaks — if the Nitro route returns a 500, if the Vue component fails to hydrate, if the useProductSearch composable loses its data — you get an alert within minutes. Not when a user files a ticket.
The free tier includes 10 tests and unlimited health checks. Pro is $100/month flat — no per-seat pricing, no per-run fees.
Test Coverage That Actually Matters
A realistic baseline for a production Nuxt app:
| Layer | Tool | What to cover |
|---|---|---|
| Composables | Vitest | State transitions, API call logic, error handling |
| Components | Vitest + @nuxt/test-utils | User interactions, conditional rendering, loading states |
| Nitro API routes | Vitest (unit) | Input validation, response shape, error codes |
| Full user flows | Playwright | Auth, cart, checkout, critical journeys |
| Production | HelpMeTest | Smoke tests, health checks, 3rd-party dependencies |
The goal is not complete coverage. The goal is knowing when the things users rely on break — before users tell you.
Monitor your Nuxt app in production. Free tier: 10 tests, unlimited health checks. Pro: $100/month flat.
Install the CLI: curl -fsSL https://helpmetest.com/install | bash — then visit helpmetest.com to connect your app.