← Back to DevBytes

Testing Nuxt Components: From Unit to E2E Tests

Testing Nuxt Components: From Unit to E2E Tests

Testing is one of the most overlooked aspects of modern web development, yet it is the backbone of any maintainable application. In the Nuxt ecosystem, where components, composables, pages, and server routes all interact in subtle ways, a solid testing strategy can mean the difference between shipping with confidence and firefighting regressions in production. This tutorial walks you through the full spectrum of testing Nuxt components, from isolated unit tests to full end-to-end (E2E) tests, with practical examples you can apply immediately.

Why Testing Matters in Nuxt Applications

Nuxt applications are inherently multi-layered. A single feature might involve a Vue component, a custom composable, a Pinia store, a server API route, and a dynamic page. Each of these layers introduces potential points of failure. Without tests, a small refactor in a shared composable can silently break multiple pages. Testing gives you a safety net that allows you to refactor, add features, and upgrade dependencies without fear.

Beyond catching bugs, tests serve as living documentation. When a new developer joins your team, a well-written test suite explains how components are expected to behave under various conditions. This is especially valuable in Nuxt, where conventions like auto-imports and file-based routing can make the flow of data less obvious to newcomers.

The Testing Pyramid for Nuxt

Before diving into code, it helps to understand the testing pyramid and how it applies to Nuxt:

A healthy test suite has many unit tests, fewer component tests, and a small number of E2E tests. E2E tests are powerful but slow and brittle, so they should focus on critical user flows rather than every edge case.

Setting Up the Testing Environment

Nuxt provides an official testing module called @nuxt/test-utils that integrates seamlessly with Vitest. Let's start by installing the necessary dependencies.

npm install -D vitest @vue/test-utils @nuxt/test-utils @testing-library/vue @testing-library/jest-dom jsdom

Next, create a vitest.config.ts file at the root of your project:

import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'nuxt',
    globals: true,
    setupFiles: ['./tests/setup.ts'],
  },
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./', import.meta.url)),
    },
  },
})

Create a setup file at tests/setup.ts to register custom matchers and configure the testing environment:

import '@testing-library/jest-dom/vitest'
import { config } from '@vue/test-utils'

// Globally stub NuxtLink to avoid rendering issues in tests
config.global.stubs = {
  NuxtLink: {
    template: '<a><slot /></a>',
  },
}

Finally, add a test script to your package.json:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:e2e": "playwright test"
  }
}

Unit Testing Composables

Composables are the building blocks of Nuxt logic. Because they are plain functions, they are straightforward to unit test. Let's say you have a composable that manages a counter with persistence to localStorage:

// composables/useCounter.ts
export const useCounter = (initialValue = 0) => {
  const count = ref(initialValue)

  const increment = () => {
    count.value++
    if (import.meta.client) {
      localStorage.setItem('count', String(count.value))
    }
  }

  const decrement = () => {
    count.value--
  }

  const reset = () => {
    count.value = initialValue
    if (import.meta.client) {
      localStorage.removeItem('count')
    }
  }

  return { count, increment, decrement, reset }
}

Here is the corresponding unit test:

// tests/unit/useCounter.spec.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { useCounter } from '~/composables/useCounter'

describe('useCounter', () => {
  beforeEach(() => {
    localStorage.clear()
  })

  it('initializes with the given value', () => {
    const { count } = useCounter(5)
    expect(count.value).toBe(5)
  })

  it('increments the count', () => {
    const { count, increment } = useCounter(0)
    increment()
    expect(count.value).toBe(1)
  })

  it('decrements the count', () => {
    const { count, decrement } = useCounter(3)
    decrement()
    expect(count.value).toBe(2)
  })

  it('resets to the initial value', () => {
    const { count, increment, reset } = useCounter(10)
    increment()
    increment()
    reset()
    expect(count.value).toBe(10)
  })
})

Notice how we test the composable in isolation without mounting any component. This keeps tests fast and focused on the logic itself.

Unit Testing Components with Vue Test Utils

Now let's test a Vue component. Consider a simple CounterButton component that uses the composable we just tested:

<!-- components/CounterButton.vue -->
<template>
  <div class="counter">
    <p data-testid="count-display">Count: {{ count }}</p>
    <button data-testid="increment-btn" @click="increment">
      Increment
    </button>
    <button data-testid="decrement-btn" @click="decrement">
      Decrement
    </button>
  </div>
</template>

<script setup>
const { count, increment, decrement } = useCounter(0)
</script>

Here is the component test using @vue/test-utils:

// tests/components/CounterButton.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import CounterButton from '~/components/CounterButton.vue'

describe('CounterButton', () => {
  it('renders the initial count', () => {
    const wrapper = mount(CounterButton)
    expect(wrapper.find('[data-testid="count-display"]').text()).toBe('Count: 0')
  })

  it('increments when the button is clicked', async () => {
    const wrapper = mount(CounterButton)
    await wrapper.find('[data-testid="increment-btn"]').trigger('click')
    expect(wrapper.find('[data-testid="count-display"]').text()).toBe('Count: 1')
  })

  it('decrements when the button is clicked', async () => {
    const wrapper = mount(CounterButton)
    await wrapper.find('[data-testid="decrement-btn"]').trigger('click')
    expect(wrapper.find('[data-testid="count-display"]').text()).toBe('Count: -1')
  })
})

Mocking Composables in Component Tests

Sometimes you want to test a component in isolation from its composables, especially when the composable makes network requests. You can mock composables using vi.mock:

// tests/components/CounterButton.mocked.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import CounterButton from '~/components/CounterButton.vue'

vi.mock('~/composables/useCounter', () => ({
  useCounter: () => ({
    count: ref(42),
    increment: vi.fn(),
    decrement: vi.fn(),
    reset: vi.fn(),
  }),
}))

describe('CounterButton with mocked composable', () => {
  it('renders the mocked count value', () => {
    const wrapper = mount(CounterButton)
    expect(wrapper.find('[data-testid="count-display"]').text()).toBe('Count: 42')
  })
})

Testing Components with Props and Events

Let's look at a more complex component that accepts props and emits events. Here is a TodoItem component:

<!-- components/TodoItem.vue -->
<template>
  <li class="todo-item" :class="{ completed: todo.completed }">
    <input
      type="checkbox"
      :checked="todo.completed"
      @change="$emit('toggle', todo.id)"
    />
    <span class="todo-text">{{ todo.text }}</span>
    <button class="delete-btn" @click="$emit('delete', todo.id)">
      Delete
    </button>
  </li>
</template>

<script setup lang="ts">
interface Todo {
  id: number
  text: string
  completed: boolean
}

defineProps<{ todo: Todo }>()
defineEmits<{
  toggle: [id: number]
  delete: [id: number]
}>()
</script>

The test for this component verifies both rendering and event emission:

// tests/components/TodoItem.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import TodoItem from '~/components/TodoItem.vue'

describe('TodoItem', () => {
  const todo = { id: 1, text: 'Learn Nuxt testing', completed: false }

  it('renders the todo text', () => {
    const wrapper = mount(TodoItem, { props: { todo } })
    expect(wrapper.find('.todo-text').text()).toBe('Learn Nuxt testing')
  })

  it('applies completed class when todo is completed', () => {
    const wrapper = mount(TodoItem, {
      props: { todo: { ...todo, completed: true } },
    })
    expect(wrapper.find('.todo-item').classes()).toContain('completed')
  })

  it('emits toggle event with the todo id when checkbox is changed', async () => {
    const wrapper = mount(TodoItem, { props: { todo } })
    await wrapper.find('input[type="checkbox"]').trigger('change')
    expect(wrapper.emitted('toggle')).toBeTruthy()
    expect(wrapper.emitted('toggle')![0]).toEqual([1])
  })

  it('emits delete event with the todo id when delete button is clicked', async () => {
    const wrapper = mount(TodoItem, { props: { todo } })
    await wrapper.find('.delete-btn').trigger('click')
    expect(wrapper.emitted('delete')).toBeTruthy()
    expect(wrapper.emitted('delete')![0]).toEqual([1])
  })
})

Testing Components with API Calls

Many Nuxt components fetch data using useFetch or useAsyncData. Testing these requires mocking the Nuxt data-fetching composables. Here is a component that displays a user profile:

<!-- components/UserProfile.vue -->
<template>
  <div class="user-profile">
    <div v-if="pending" data-testid="loading">Loading...</div>
    <div v-else-if="error" data-testid="error">
      Failed to load user
    </div>
    <div v-else data-testid="profile">
      <h2>{{ user.name }}</h2>
      <p>{{ user.email }}</p>
    </div>
  </div>
</template>

<script setup>
const { data: user, pending, error } = await useFetch('/api/users/1')
</script>

To test this component, mock useFetch using @nuxt/test-utils/runtime:

// tests/components/UserProfile.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import UserProfile from '~/components/UserProfile.vue'

vi.mock('#imports', () => ({
  useFetch: vi.fn(),
}))

import { useFetch } from '#imports'

describe('UserProfile', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('shows loading state while fetching', async () => {
    vi.mocked(useFetch).mockReturnValue({
      data: ref(null),
      pending: ref(true),
      error: ref(null),
      refresh: vi.fn(),
    })

    const wrapper = mount(UserProfile)
    expect(wrapper.find('[data-testid="loading"]').exists()).toBe(true)
  })

  it('displays user data when fetch succeeds', async () => {
    const mockUser = { name: 'Jane Doe', email: 'jane@example.com' }
    vi.mocked(useFetch).mockReturnValue({
      data: ref(mockUser),
      pending: ref(false),
      error: ref(null),
      refresh: vi.fn(),
    })

    const wrapper = mount(UserProfile)
    expect(wrapper.find('[data-testid="profile"]').exists()).toBe(true)
    expect(wrapper.find('h2').text()).toBe('Jane Doe')
    expect(wrapper.find('p').text()).toBe('jane@example.com')
  })

  it('shows error message when fetch fails', async () => {
    vi.mocked(useFetch).mockReturnValue({
      data: ref(null),
      pending: ref(false),
      error: ref(new Error('Network error')),
      refresh: vi.fn(),
    })

    const wrapper = mount(UserProfile)
    expect(wrapper.find('[data-testid="error"]').exists()).toBe(true)
    expect(wrapper.find('[data-testid="error"]').text()).toBe('Failed to load user')
  })
})

Testing Pinia Stores

If your Nuxt application uses Pinia for state management, you will want to test your stores thoroughly. Here is a simple auth store:

// stores/auth.ts
import { defineStore } from 'pinia'

export const useAuthStore = defineStore('auth', () => {
  const user = ref<null | { id: number; name: string }>(null)
  const isAuthenticated = computed(() => user.value !== null)

  const login = async (email: string, password: string) => {
    const response = await $fetch('/api/login', {
      method: 'POST',
      body: { email, password },
    })
    user.value = response.user
    return response
  }

  const logout = () => {
    user.value = null
  }

  return { user, isAuthenticated, login, logout }
})

The store test uses createPinia and setActivePinia to set up an isolated Pinia instance:

// tests/stores/auth.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useAuthStore } from '~/stores/auth'

describe('auth store', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
  })

  it('starts unauthenticated', () => {
    const store = useAuthStore()
    expect(store.isAuthenticated).toBe(false)
    expect(store.user).toBeNull()
  })

  it('sets user on successful login', async () => {
    global.$fetch = vi.fn().mockResolvedValue({
      user: { id: 1, name: 'Jane Doe' },
      token: 'abc123',
    })

    const store = useAuthStore()
    await store.login('jane@example.com', 'password')

    expect(store.isAuthenticated).toBe(true)
    expect(store.user).toEqual({ id: 1, name: 'Jane Doe' })
  })

  it('clears user on logout', () => {
    const store = useAuthStore()
    store.user = { id: 1, name: 'Jane Doe' }
    store.logout()
    expect(store.isAuthenticated).toBe(false)
    expect(store.user).toBeNull()
  })
})

Testing Nuxt Pages with the Nuxt Test Environment

For testing pages and layouts that rely on Nuxt's runtime context, you can use the @nuxt/test-utils/runtime module. This gives you access to a mounted Nuxt instance with auto-imports, plugins, and middleware active. Here is an example testing a page component:

// tests/pages/index.spec.ts
import { describe, it, expect } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import IndexPage from '~/pages/index.vue'

describe('Index page', () => {
  it('renders the welcome heading', async () => {
    const wrapper = await mountSuspended(IndexPage)
    expect(wrapper.find('h1').text()).toContain('Welcome')
  })

  it('renders navigation links', async () => {
    const wrapper = await mountSuspended(IndexPage)
    const links = wrapper.findAllComponents({ name: 'NuxtLink' })
    expect(links.length).toBeGreaterThan(0)
  })
})

The mountSuspended helper mounts the component within the Nuxt runtime, so auto-imported composables, plugins, and components all work as they would in production.

End-to-End Testing with Playwright

E2E tests simulate real user interactions in a browser. Playwright is the recommended tool for Nuxt E2E testing. Install it with:

npx playwright install
npm install -D @playwright/test

Create a playwright.config.ts file:

import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

Now let's write an E2E test for a todo application flow:

// tests/e2e/todo.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Todo application', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/')
  })

  test('user can add a new todo', async ({ page }) => {
    const input = page.locator('[data-testid="todo-input"]')
    const addButton = page.locator('[data-testid="add-todo"]')

    await input.fill('Buy groceries')
    await addButton.click()

    const todoItem = page.locator('.todo-item', { hasText: 'Buy groceries' })
    await expect(todoItem).toBeVisible()
  })

  test('user can complete a todo', async ({ page }) => {
    await page.locator('[data-testid="todo-input"]').fill('Walk the dog')
    await page.locator('[data-testid="add-todo"]').click()

    const checkbox = page.locator('.todo-item', { hasText: 'Walk the dog' }).locator('input[type="checkbox"]')
    await checkbox.check()

    const todoItem = page.locator('.todo-item', { hasText: 'Walk the dog' })
    await expect(todoItem).toHaveClass(/completed/)
  })

  test('user can delete a todo', async ({ page }) => {
    await page.locator('[data-testid="todo-input"]').fill('Temporary task')
    await page.locator('[data-testid="add-todo"]').click()

    const deleteButton = page.locator('.todo-item', { hasText: 'Temporary task' }).locator('.delete-btn')
    await deleteButton.click()

    await expect(page.locator('.todo-item', { hasText: 'Temporary task' })).toHaveCount(0)
  })

  test('todo list persists after page reload', async ({ page }) => {
    await page.locator('[data-testid="todo-input"]').fill('Persistent task')
    await page.locator('[data-testid="add-todo"]').click()

    await page.reload()

    await expect(page.locator('.todo-item', { hasText: 'Persistent task' })).toBeVisible()
  })
})

Testing Server API Routes

Nuxt server routes also need testing. You can use @nuxt/test-utils with the setup helper to test API endpoints against a running Nuxt server:

// tests/api/todos.spec.ts
import { describe, it, expect, afterAll } from 'vitest'
import { setup, $fetch } from '@nuxt/test-utils/e2e'

await setup({
  host: 'http://localhost:3000',
  server: true,
})

describe('GET /api/todos', () => {
  it('returns a list of todos', async () => {
    const todos = await $fetch('/api/todos')
    expect(Array.isArray(todos)).toBe(true)
    expect(todos.length).toBeGreaterThan(0)
  })
})

describe('POST /api/todos', () => {
  it('creates a new todo', async () => {
    const newTodo = await $fetch('/api/todos', {
      method: 'POST',
      body: { text: 'Test todo', completed: false },
    })
    expect(newTodo).toHaveProperty('id')
    expect(newTodo.text).toBe('Test todo')
    expect(newTodo.completed).toBe(false)
  })

  it('returns 400 when text is missing', async () => {
    await expect(
      $fetch('/api/todos', {
        method: 'POST',
        body: { completed: false },
      })
    ).rejects.toThrowError(/400/)
  })
})

Best Practices for Testing Nuxt Components

Use data-testid Attributes for Selectors

Avoid selecting elements by CSS classes or text content in your tests, as these are likely to change when the design evolves. Instead, use data-testid attributes that are stable and explicitly meant for testing:

<button data-testid="submit-button" @click="handleSubmit">
  Submit
</button>

Test Behavior, Not Implementation

Focus on what the component does from the user's perspective, not on how it achieves it internally. If your tests check internal state or private methods, they will break every time you refactor, even if the behavior remains the same. Prefer testing the rendered output and emitted events over testing internal reactive state.

Mock at the Boundaries

Mock external dependencies like API calls, browser APIs, and third-party services, but avoid mocking your own application code unless necessary. Over-mocking leads to tests that pass but do not catch real bugs. A good rule of thumb is to mock only at the boundaries of your system: network requests, localStorage, geolocation, and similar external interfaces.

Keep Tests Independent

Each test should set up its own state and clean up after itself. Use beforeEach and afterEach hooks to reset state between tests. Tests that depend on execution order or shared mutable state are fragile and hard to debug.

Write Meaningful Test Descriptions

Test descriptions should read like specifications. Use the pattern "it [does something] when [condition]" to make the intent clear. When a test fails, the description alone should tell you what behavior is broken:

it('displays an error message when the password is too short', async () => {
  // ...
})

Run Tests in CI

Always run your test suite in CI before deploying. Configure your CI pipeline to run unit and component tests on every pull request, and E2E tests on merges to the main branch. This ensures that regressions are caught before they reach production.

Coverage Targets

Aim for meaningful coverage rather than 100% coverage. Critical business logic should be thoroughly tested, while trivial getters or presentational components may not need dedicated tests. Use Vitest's coverage provider to identify untested code paths:

vitest run --coverage

Conclusion

Testing Nuxt components is not a single technique but a layered strategy that scales from isolated unit tests for composables to full E2E tests that simulate real user journeys. By combining Vitest for unit and component tests, @nuxt/test-utils for Nuxt-aware runtime testing, and Playwright for E2E scenarios, you can build a robust safety net that catches regressions early and gives you the confidence to refactor and ship features quickly. Start small by adding tests to your most critical components and composables, then gradually expand coverage as your application grows. The investment in testing pays off every time you deploy without fear, refactor without breaking things, and onboard new developers who can understand your codebase through its tests.

— Ad —

Google AdSense will appear here after approval

← Back to all articles