Nuxt 3 Unit Testing with Vitest and @nuxt/test-utils

Nuxt 3 Unit Testing with Vitest and @nuxt/test-utils

Nuxt 3's auto-imports, composables, and server routes require a different testing approach than standard Vue 3. Vitest combined with @nuxt/test-utils provides first-class support for testing in the Nuxt runtime environment — giving you access to auto-imported functions, useNuxtApp(), and SSR utilities without elaborate mocking.

This guide covers unit testing composables, components, server routes, and middleware in Nuxt 3.

Installation

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

Update nuxt.config.ts to register the test module:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: [
    '@nuxt/test-utils/module'
  ]
})

Create vitest.config.ts:

import { defineVitestConfig } from '@nuxt/test-utils/config';

export default defineVitestConfig({
  test: {
    environment: 'nuxt',
    environmentOptions: {
      nuxt: {
        rootDir: '.',
        domEnvironment: 'happy-dom',
      }
    },
    globals: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
    },
  },
});

The environment: 'nuxt' setting boots a minimal Nuxt application context for each test file, giving you access to auto-imports and Nuxt internals.

Testing Composables

Composables are the most common unit in Nuxt 3 applications. Test them with mountSuspended or directly within a Nuxt environment context:

// composables/useCounter.ts
export const useCounter = () => {
  const count = useState('counter', () => 0);
  
  const increment = () => count.value++;
  const decrement = () => count.value--;
  const reset = () => (count.value = 0);
  
  return { count, increment, decrement, reset };
};
// tests/composables/useCounter.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useCounter } from '~/composables/useCounter';

describe('useCounter', () => {
  it('starts at zero', () => {
    const { count } = useCounter();
    expect(count.value).toBe(0);
  });

  it('increments correctly', () => {
    const { count, increment } = useCounter();
    increment();
    increment();
    expect(count.value).toBe(2);
  });

  it('decrements correctly', () => {
    const { count, increment, decrement } = useCounter();
    increment();
    increment();
    decrement();
    expect(count.value).toBe(1);
  });

  it('resets to zero', () => {
    const { count, increment, reset } = useCounter();
    increment();
    increment();
    reset();
    expect(count.value).toBe(0);
  });
});

Testing Composables that Use useFetch

useFetch is auto-imported in Nuxt 3. Mock it with mockNuxtImport from @nuxt/test-utils:

// composables/useProducts.ts
export const useProducts = () => {
  const { data: products, pending, error, refresh } = useFetch('/api/products');
  
  const totalCount = computed(() => products.value?.length ?? 0);
  
  return { products, pending, error, refresh, totalCount };
};
// tests/composables/useProducts.test.ts
import { describe, it, expect, vi } from 'vitest';
import { mockNuxtImport } from '@nuxt/test-utils/runtime';

// Mock useFetch before importing the composable
mockNuxtImport('useFetch', () => {
  return vi.fn().mockReturnValue({
    data: ref([
      { id: 1, name: 'Product A' },
      { id: 2, name: 'Product B' },
    ]),
    pending: ref(false),
    error: ref(null),
    refresh: vi.fn(),
  });
});

describe('useProducts', () => {
  it('returns products from API', async () => {
    const { products, totalCount } = useProducts();
    
    expect(products.value).toHaveLength(2);
    expect(totalCount.value).toBe(2);
  });

  it('calculates total count correctly', async () => {
    const { totalCount } = useProducts();
    expect(totalCount.value).toBe(2);
  });
});

Testing Nuxt Components with mountSuspended

Nuxt components use <script setup> with auto-imports and can include async setup (via useAsyncData). mountSuspended handles the async lifecycle:

<!-- components/ProductCard.vue -->
<script setup lang="ts">
const props = defineProps<{
  productId: number;
}>();

const { data: product } = await useAsyncData(
  `product-${props.productId}`,
  () => $fetch(`/api/products/${props.productId}`)
);
</script>

<template>
  <div class="product-card">
    <h2 data-testid="product-name">{{ product?.name }}</h2>
    <p data-testid="product-price">{{ product?.price }}</p>
  </div>
</template>
// tests/components/ProductCard.test.ts
import { describe, it, expect, vi } from 'vitest';
import { mountSuspended, mockNuxtImport } from '@nuxt/test-utils/runtime';
import ProductCard from '~/components/ProductCard.vue';

mockNuxtImport('useAsyncData', () => {
  return vi.fn().mockImplementation((key: string, fetcher: Function) => {
    return {
      data: ref({ id: 1, name: 'Widget Pro', price: 49.99 }),
      pending: ref(false),
      error: ref(null),
    };
  });
});

describe('ProductCard', () => {
  it('renders product name and price', async () => {
    const wrapper = await mountSuspended(ProductCard, {
      props: { productId: 1 },
    });
    
    expect(wrapper.find('[data-testid="product-name"]').text()).toBe('Widget Pro');
    expect(wrapper.find('[data-testid="product-price"]').text()).toBe('49.99');
  });

  it('renders with correct structure', async () => {
    const wrapper = await mountSuspended(ProductCard, {
      props: { productId: 1 },
    });
    
    expect(wrapper.find('.product-card').exists()).toBe(true);
  });
});

Testing Server Routes

Nuxt 3 server routes in /server/api/ are Node.js handlers. Test them with setup and $fetch from @nuxt/test-utils:

// server/api/products/index.get.ts
import { defineEventHandler, getQuery } from 'h3';

const products = [
  { id: 1, name: 'Widget', price: 29.99 },
  { id: 2, name: 'Gadget', price: 49.99 },
  { id: 3, name: 'Doohickey', price: 19.99 },
];

export default defineEventHandler((event) => {
  const query = getQuery(event);
  const minPrice = parseFloat(query.minPrice as string) || 0;
  
  return products.filter((p) => p.price >= minPrice);
});
// tests/server/products.test.ts
import { describe, it, expect } from 'vitest';
import { setup, $fetch } from '@nuxt/test-utils/e2e';

await setup({
  server: true,
  browser: false,
});

describe('GET /api/products', () => {
  it('returns all products', async () => {
    const products = await $fetch('/api/products');
    
    expect(products).toHaveLength(3);
    expect(products[0]).toMatchObject({
      id: expect.any(Number),
      name: expect.any(String),
      price: expect.any(Number),
    });
  });

  it('filters by minPrice', async () => {
    const products = await $fetch('/api/products?minPrice=30');
    
    expect(products).toHaveLength(1);
    expect(products[0].name).toBe('Gadget');
  });

  it('returns empty array when no products match filter', async () => {
    const products = await $fetch('/api/products?minPrice=100');
    expect(products).toHaveLength(0);
  });
});

Testing Route Middleware

// middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
  const user = useSupabaseUser();
  
  if (!user.value && to.path !== '/login') {
    return navigateTo('/login');
  }
});
// tests/middleware/auth.test.ts
import { describe, it, expect, vi } from 'vitest';
import { mockNuxtImport } from '@nuxt/test-utils/runtime';

// Mock useSupabaseUser
const mockUser = ref<{ id: string } | null>(null);
mockNuxtImport('useSupabaseUser', () => () => mockUser);

// Mock navigateTo
const navigateToMock = vi.fn();
mockNuxtImport('navigateTo', () => navigateToMock);

describe('auth middleware', () => {
  beforeEach(() => {
    mockUser.value = null;
    navigateToMock.mockReset();
  });

  it('redirects unauthenticated users to login', async () => {
    const { default: middleware } = await import('~/middleware/auth');
    
    await middleware({ path: '/dashboard' } as any, {} as any);
    
    expect(navigateToMock).toHaveBeenCalledWith('/login');
  });

  it('allows authenticated users to access protected routes', async () => {
    mockUser.value = { id: 'user-123' };
    
    const { default: middleware } = await import('~/middleware/auth');
    
    await middleware({ path: '/dashboard' } as any, {} as any);
    
    expect(navigateToMock).not.toHaveBeenCalled();
  });

  it('does not redirect when already on login page', async () => {
    const { default: middleware } = await import('~/middleware/auth');
    
    await middleware({ path: '/login' } as any, {} as any);
    
    expect(navigateToMock).not.toHaveBeenCalled();
  });
});

Testing useState

Nuxt's useState creates shared reactive state. Test state initialization and mutation:

// composables/useCart.ts
export const useCart = () => {
  const items = useState<CartItem[]>('cart-items', () => []);
  
  const addItem = (item: CartItem) => {
    const existing = items.value.find((i) => i.id === item.id);
    if (existing) {
      existing.quantity += item.quantity;
    } else {
      items.value.push(item);
    }
  };
  
  const removeItem = (id: number) => {
    items.value = items.value.filter((i) => i.id !== id);
  };
  
  const total = computed(() =>
    items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  );
  
  return { items, addItem, removeItem, total };
};
// tests/composables/useCart.test.ts
import { describe, it, expect, beforeEach } from 'vitest';

describe('useCart', () => {
  beforeEach(() => {
    // Reset Nuxt state between tests
    const nuxtApp = useNuxtApp();
    nuxtApp.payload.state['cart-items'] = [];
  });

  it('starts with empty cart', () => {
    const { items } = useCart();
    expect(items.value).toHaveLength(0);
  });

  it('adds new item to cart', () => {
    const { items, addItem } = useCart();
    addItem({ id: 1, name: 'Widget', price: 10, quantity: 1 });
    expect(items.value).toHaveLength(1);
  });

  it('increases quantity when adding existing item', () => {
    const { items, addItem } = useCart();
    addItem({ id: 1, name: 'Widget', price: 10, quantity: 1 });
    addItem({ id: 1, name: 'Widget', price: 10, quantity: 2 });
    expect(items.value).toHaveLength(1);
    expect(items.value[0].quantity).toBe(3);
  });

  it('calculates total correctly', () => {
    const { addItem, total } = useCart();
    addItem({ id: 1, name: 'Widget', price: 10, quantity: 2 });
    addItem({ id: 2, name: 'Gadget', price: 5, quantity: 3 });
    expect(total.value).toBe(35);
  });

  it('removes item from cart', () => {
    const { items, addItem, removeItem } = useCart();
    addItem({ id: 1, name: 'Widget', price: 10, quantity: 1 });
    addItem({ id: 2, name: 'Gadget', price: 5, quantity: 1 });
    removeItem(1);
    expect(items.value).toHaveLength(1);
    expect(items.value[0].id).toBe(2);
  });
});

Running Tests and Coverage

# Run all tests
npx vitest

<span class="hljs-comment"># Watch mode during development
npx vitest --watch

<span class="hljs-comment"># Coverage report
npx vitest --coverage

<span class="hljs-comment"># Run specific test file
npx vitest tests/composables/useCart.test.ts

Integration with HelpMeTest

Unit tests verify isolated logic. But Nuxt 3 apps fail in ways that only appear at the integration level — hydration errors, middleware bypasses, useFetch race conditions. HelpMeTest runs continuous E2E monitoring against your production Nuxt app, catching issues that Vitest unit tests miss.

Combine Vitest for fast feedback during development with HelpMeTest for production assurance.

Summary

Nuxt 3 unit testing with Vitest and @nuxt/test-utils provides:

  • mockNuxtImport: Mock auto-imported composables (useFetch, useAsyncData, useState) in tests
  • mountSuspended: Properly mount components that have async setup
  • $fetch + setup: Test server routes in a real Nuxt server context
  • Direct composable testing: Test business logic without component overhead

The Nuxt environment in Vitest gives you a real application context — auto-imports work, useNuxtApp() returns the actual app instance, and useState shares state as it does in production. This makes tests realistic without requiring a full browser.

Read more