Testing is where theory meets reality in production applications. When you're shipping a Nuxt app that thousands of users depend on, unit tests alone won't catch the race conditions, timing issues, and environment-specific bugs that only surface in integration. This guide walks you through testing Nuxt apps: Vue & Nuxt best practices explained, covering the tools and patterns we've learned from building e-commerce platforms, CRM dashboards, and real-time trading systems.
Why testing matters in Nuxt
Nuxt abstracts away a lot of setup—server routes, middleware, auto-imports, the file-based router. That abstraction is powerful for velocity, but it also means gaps between local development and production are easier to miss. A component that works in dev might fail in SSR. A server endpoint that works with mocked data might timeout against the real database. End-to-end tests catch these issues; unit tests alone cannot.
The testing pyramid for Nuxt looks like this: lots of unit tests for business logic, fewer component tests that verify UI behavior, and a lean but critical set of E2E tests that exercise the full stack. We'll cover each layer and show how to wire them together.
@nuxt/test-utils — Run tests in a Nuxt environment
The foundation is @nuxt/test-utils, which gives you access to Nuxt internals, auto-imports, plugins, middleware, and the router—all in your test runner. Without it, you're testing Vue components in a vacuum.
Install it alongside your test framework:
npm install -D @nuxt/test-utils vitest @vue/test-utils happy-domSet up a vitest.config.ts:
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
},
resolve: {
alias: {
'#app': fileURLToPath(new URL('./.nuxt', import.meta.url)),
},
},
})Now, in your test file, you can import from Nuxt as if you were inside the app:
import { describe, it, expect } from 'vitest'
import { defineNuxtConfig } from 'nuxt/config'
describe('Nuxt environment', () => {
it('has access to Nuxt plugins and composables', async () => {
// Your test here
})
})This sets you up to test composables, middleware, and utilities that depend on Nuxt's context. For a real-world example, imagine testing a composable that uses useRouter and useFetch:
// composables/useCart.ts
export const useCart = () => {
const router = useRouter()
const { data: cartItems } = useFetch('/api/cart')
const checkout = async () => {
await $fetch('/api/checkout', { method: 'POST' })
router.push('/thank-you')
}
return { cartItems, checkout }
}
// tests/composables/useCart.test.ts
import { describe, it, expect, vi } from 'vitest'
import { useCart } from '~/composables/useCart'
import { useRouter } from 'vue-router'
vi.mock('vue-router', () => ({
useRouter: vi.fn(),
}))
vi.mock('#app', () => ({
useFetch: vi.fn(() => ({
data: ref([{ id: 1, price: 100 }]),
})),
$fetch: vi.fn(),
}))
describe('useCart', () => {
it('navigates to thank-you after checkout', async () => {
const mockPush = vi.fn()
vi.mocked(useRouter).mockReturnValue({ push: mockPush })
const { checkout } = useCart()
await checkout()
expect(mockPush).toHaveBeenCalledWith('/thank-you')
})
})mountSuspended — Mount components with Nuxt context
The key function for component testing in Nuxt is mountSuspended. Unlike Vue's standard mount, it respects Nuxt's async setup, SSR boundaries, and awaits any async setup before your assertions run. This is essential when testing components that use useAsyncData or useFetch.
Let's test a product listing component that fetches data on mount:
// components/ProductList.vue
<div>
<p>Loading...</p>
<ul>
<li>
{{ product.name }} - ${{ product.price }}
</li>
</ul>
</div>
Testing with mountSuspended:
// tests/components/ProductList.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils'
import ProductList from '~/components/ProductList.vue'
describe('ProductList', () => {
beforeEach(() => {
vi.mock('#app', () => ({
useFetch: vi.fn(() => ({
data: ref([
{ id: 1, name: 'Widget', price: 29.99 },
{ id: 2, name: 'Gadget', price: 49.99 },
]),
pending: ref(false),
})),
}))
})
it('renders products after async setup', async () => {
const wrapper = await mountSuspended(ProductList)
expect(wrapper.find('li').exists()).toBe(true)
expect(wrapper.findAll('li')).toHaveLength(2)
expect(wrapper.text()).toContain('Widget')
})
it('shows loading state before data arrives', async () => {
// Mock pending state
const wrapper = await mountSuspended(ProductList)
expect(wrapper.find('p').text()).toBe('Loading...')
})
})The critical difference: mountSuspended waits for the component's setup to resolve. Without it, your assertions fire before the data arrives and your tests flake. We've seen this countless times in production codebases—tests pass locally but fail in CI because the async timing is different.
Mock endpoints — registerEndpoint for server routes
Server routes (handlers in server/api/) can't be tested in isolation without a running server. @nuxt/test-utils provides $fetch within tests, but you need to mock the endpoints themselves. Use registerEndpoint to stub server routes during testing.
// server/api/cart.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// Process checkout
return { orderId: 'ORD-123', status: 'success' }
})
// tests/api/cart.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { registerEndpoint } from '@nuxt/test-utils'
describe('POST /api/cart', () => {
beforeEach(() => {
registerEndpoint('/api/cart', {
method: 'POST',
handler: () => ({
orderId: 'ORD-123',
status: 'success',
}),
})
})
it('returns order confirmation', async () => {
const response = await $fetch('/api/cart', {
method: 'POST',
body: { items: [{ id: 1, qty: 2 }] },
})
expect(response.orderId).toBe('ORD-123')
expect(response.status).toBe('success')
})
})For more complex scenarios—error handling, database interactions, rate limiting—mock at the service layer instead. Register endpoints for happy paths; test error logic separately:
// server/services/paymentService.ts
export const processPayment = async (amount: number, cardToken: string) => {
if (!cardToken) throw new Error('Invalid token')
// Call payment provider
return { transactionId: 'TXN-' + Date.now() }
}
// tests/api/checkout.test.ts
import { describe, it, expect, vi } from 'vitest'
import { processPayment } from '~/server/services/paymentService'
describe('Payment processing', () => {
it('throws on missing card token', async () => {
await expect(processPayment(100, '')).rejects.toThrow('Invalid token')
})
it('returns transaction ID on success', async () => {
const result = await processPayment(100, 'tok_valid')
expect(result.transactionId).toMatch(/^TXN-/)
})
})E2E — Playwright against a built app
Unit and component tests are fast and isolated. E2E tests are slow and fragile—but they're the only tests that catch real user flows. After SSR hydration mismatches, middleware bugs, and cross-origin issues. Use Playwright to test a fully built Nuxt app.
Install Playwright:
npm install -D @playwright/testCreate a test that exercises a real user journey—sign up, add to cart, checkout:
// tests/e2e/checkout.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Checkout flow', () => {
test.beforeEach(async ({ page }) => {
// Run against built app
await page.goto('http://localhost:3000')
})
test('user can complete purchase', async ({ page }) => {
// Browse products
await page.click('a:has-text("Products")')
await expect(page).toHaveURL(/\/products/)
// Add to cart
await page.click('button:has-text("Add to Cart")')
await expect(page.locator('[data-testid="cart-count"]')).toContainText('1')
// Checkout
await page.click('a:has-text("Cart")')
await page.fill('input[name="email"]', 'test@example.com')
await page.click('button:has-text("Proceed to Payment")')
// Verify order confirmation
await expect(page).toHaveURL(/\/thank-you/)
await expect(page.locator('h1')).toContainText('Order Confirmed')
})
test('handles network errors gracefully', async ({ page }) => {
// Simulate offline
await page.context().setOffline(true)
await page.click('a:has-text("Products")')
// Should show error or fallback UI
await expect(page.locator('[role="alert"]')).toContainText('Could not load')
await page.context().setOffline(false)
})
})Run E2E tests against a production build to catch hydration issues and runtime errors that unit tests miss:
npm run build
npm run preview &
npx playwright testStore test credentials and API keys in environment files, never in version control. Use page.route() to intercept and mock API calls if needed:
test('handles slow API', async ({ page }) => {
// Intercept and delay all API calls
await page.route('**/api/**', async (route) => {
await new Promise(r => setTimeout(r, 2000))
await route.continue()
})
await page.goto('http://localhost:3000')
// Verify loading states, timeouts, etc.
})Common mistakes
- Testing implementation, not behavior. Don't assert on internal component state or mocked functions; assert on what the user sees. Test that the loading spinner disappears when data arrives, not that
pendingchanged from true to false. - Skipping server route tests. Integration happens on the server. A composable that calls an endpoint needs both unit tests (for the composable logic) and endpoint tests (for the API contract). Test both.
- Not awaiting async operations.
mountSuspendedwaits for async setup, butwrapper.vm.$nextTick()doesn't wait for promises in data fetching. Useawait wrapper.vm.$nextTick()after triggering state changes. - E2E tests that are too brittle. Avoid hard-coding delays or waiting for exact timestamps. Use
page.waitForSelector()orpage.locator().waitFor()instead. Your tests should be deterministic, not timing-dependent. - Testing the wrong layer. If a test is slow or flaky, move it down the pyramid. Test business logic in unit tests, UI interactions in component tests, full flows in E2E tests. Don't E2E-test every edge case.
Key takeaways
@nuxt/test-utilsgives you the Nuxt environment in tests: auto-imports, plugins, middleware, and router work as they do in production.mountSuspendedhandles async component setup and SSR boundaries; use it for all Nuxt component tests.- Mock server endpoints with
registerEndpointfor integration tests; mock services at the business logic layer for unit tests. - E2E tests with Playwright validate user flows against a fully built app; they catch SSR, hydration, and environment issues that unit tests cannot.
- Test the behavior users see, not internal implementation. Avoid hard-coding delays. Keep the testing pyramid lean at the top.






Comments
Loading comments…