Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 089 / 100

Testing Nuxt apps: Vue & Nuxt best practices explained

Unit, component and end-to-end tests.

Expona Admin9 min read

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:

typescript
npm install -D @nuxt/test-utils vitest @vue/test-utils happy-dom

Set up a vitest.config.ts:

typescript
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:

typescript
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:

typescript
// 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:

typescript
// components/ProductList.vue

  <div>
    <p>Loading...</p>
    <ul>
      <li>
        {{ product.name }} - ${{ product.price }}
      </li>
    </ul>
  </div>


Testing with mountSuspended:

typescript
// 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.

typescript
// 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:

typescript
// 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:

typescript
npm install -D @playwright/test

Create a test that exercises a real user journey—sign up, add to cart, checkout:

typescript
// 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:

typescript
npm run build
npm run preview &
npx playwright test

Store test credentials and API keys in environment files, never in version control. Use page.route() to intercept and mock API calls if needed:

typescript
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 pending changed 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. mountSuspended waits for async setup, but wrapper.vm.$nextTick() doesn't wait for promises in data fetching. Use await 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() or page.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-utils gives you the Nuxt environment in tests: auto-imports, plugins, middleware, and router work as they do in production.
  • mountSuspended handles async component setup and SSR boundaries; use it for all Nuxt component tests.
  • Mock server endpoints with registerEndpoint for 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles