Testing Nuxt Apps: Vitest, @nuxt/test-utils, and Playwright (2026)

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-dom

Configure 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 report

The 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 run

What 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
  • useFetch polling 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 | bash

A 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 visible

Set 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.

Read more