← Back to DevBytes

Testing Vue.js Components: From Unit to E2E Tests

Testing Vue.js Components: From Unit to E2E Tests

Testing is one of the most overlooked pillars of modern frontend development. When you build Vue.js applications, components quickly become complex: they hold state, emit events, call APIs, and interact with each other through props and provide/inject. Without a solid testing strategy, refactoring becomes a gamble and bugs slip into production. This tutorial walks you through the full spectrum of testing Vue.js components — from isolated unit tests to full end-to-end (E2E) tests — with practical, copy-paste-ready examples.

What Is Component Testing?

Component testing in Vue.js is the practice of verifying that a single component behaves as expected in isolation. It sits between pure unit tests (which test individual functions) and E2E tests (which test entire user journeys through a real browser). The Vue ecosystem offers dedicated tools for this layered approach:

Why Testing Matters

A well-tested Vue application gives you confidence to refactor, upgrade dependencies, and ship features faster. Tests serve as living documentation: when a new developer joins the team, the test suite explains how each component should behave. They also catch regressions early — before a broken button reaches your users. In a component-driven framework like Vue, testing each component in isolation makes it far easier to pinpoint the source of a failure than debugging a monolithic application.

Setting Up the Testing Environment

Assume you have a Vue 3 project created with Vite. The easiest way to add testing is to install Vitest and Vue Test Utils. Vitest is built by the Vite team and integrates seamlessly with your existing Vite config.

npm install -D vitest @vue/test-utils jsdom @vitest/coverage-v8

Add a test script to your package.json:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

Configure Vitest in your vite.config.js file. The key is to set the test environment to jsdom so that DOM APIs are available during tests:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html']
    }
  }
})

With globals: true, you can use describe, it, and expect without importing them in every file.

Writing Your First Unit Test

Let us start with a simple component. Create a file named Counter.vue:

<template>
  <div class="counter">
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}

defineExpose({ count, increment })
</script>

Now write a test file named Counter.spec.js in the same directory (or in a __tests__ folder). Vue Test Utils provides the mount function to render the component in a virtual DOM:

import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import Counter from './Counter.vue'

describe('Counter.vue', () => {
  it('renders the initial count', () => {
    const wrapper = mount(Counter)
    expect(wrapper.text()).toContain('Count: 0')
  })

  it('increments the count when the button is clicked', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('button').trigger('click')
    expect(wrapper.text()).toContain('Count: 1')
  })

  it('can call the increment method directly', () => {
    const wrapper = mount(Counter)
    wrapper.vm.increment()
    expect(wrapper.vm.count).toBe(1)
  })
})

Run the tests with npm run test:run. The await keyword is important because Vue updates the DOM asynchronously after a state change. Vue Test Utils returns a promise from trigger, and awaiting it ensures the DOM has been patched before you assert.

Testing Props, Events, and Slots

Real components rarely live in isolation. They receive props, emit events, and accept slot content. Here is a more realistic component, TodoItem.vue:

<template>
  <li class="todo-item">
    <span :class="{ done: todo.done }">{{ todo.text }}</span>
    <button @click="$emit('toggle', todo.id)">Toggle</button>
    <button @click="$emit('remove', todo.id)">Remove</button>
    <slot name="extra" />
  </li>
</template>

<script setup>
defineProps({
  todo: {
    type: Object,
    required: true
  }
})

defineEmits(['toggle', 'remove'])
</script>

The corresponding test verifies props rendering, emitted events, and slot content:

import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import TodoItem from './TodoItem.vue'

describe('TodoItem.vue', () => {
  const todo = { id: 1, text: 'Learn Vue testing', done: false }

  it('renders the todo text', () => {
    const wrapper = mount(TodoItem, {
      props: { todo }
    })
    expect(wrapper.text()).toContain('Learn Vue testing')
  })

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

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

  it('emits remove event with the todo id', async () => {
    const wrapper = mount(TodoItem, {
      props: { todo }
    })
    const buttons = wrapper.findAll('button')
    await buttons[1].trigger('click')
    expect(wrapper.emitted('remove')).toBeTruthy()
    expect(wrapper.emitted('remove')[0]).toEqual([1])
  })

  it('renders named slot content', () => {
    const wrapper = mount(TodoItem, {
      props: { todo },
      slots: {
        extra: '<span class="badge">Priority</span>'
      }
    })
    expect(wrapper.find('.badge').exists()).toBe(true)
  })
})

Notice how wrapper.emitted() returns an object keyed by event name, where each value is an array of emitted payloads. This makes it easy to assert both that an event was fired and what data it carried.

Mocking API Calls and Composables

Components often fetch data from APIs. In tests, you do not want to hit a real server — it is slow, flaky, and dependent on network conditions. Instead, mock the HTTP calls. Suppose you have a composable useUsers.js:

import { ref, onMounted } from 'vue'
import axios from 'axios'

export function useUsers() {
  const users = ref([])
  const loading = ref(false)
  const error = ref(null)

  async function fetchUsers() {
    loading.value = true
    error.value = null
    try {
      const response = await axios.get('/api/users')
      users.value = response.data
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }

  onMounted(fetchUsers)

  return { users, loading, error, fetchUsers }
}

To test a component that uses this composable, mock axios using vi.mock:

import { mount, flushPromises } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import axios from 'axios'
import UserList from './UserList.vue'

vi.mock('axios')

describe('UserList.vue', () => {
  it('displays users after fetching', async () => {
    const mockUsers = [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' }
    ]
    axios.get.mockResolvedValue({ data: mockUsers })

    const wrapper = mount(UserList)
    await flushPromises()

    const items = wrapper.findAll('.user-item')
    expect(items).toHaveLength(2)
    expect(items[0].text()).toContain('Alice')
  })

  it('displays an error message when the request fails', async () => {
    axios.get.mockRejectedValue(new Error('Network error'))

    const wrapper = mount(UserList)
    await flushPromises()

    expect(wrapper.find('.error').text()).toContain('Network error')
  })
})

flushPromises is a helper from Vue Test Utils that resolves all pending promises. Without it, the test would run assertions before the async fetch completed. For more advanced HTTP mocking, consider using msw (Mock Service Worker), which intercepts requests at the network layer and works identically in both component and E2E tests.

Testing with Pinia Stores

If your component depends on a Pinia store, you need to install Pinia in your test setup. Create a setup file tests/setup.js:

import { setActivePinia, createPinia } from 'pinia'

beforeEach(() => {
  setActivePinia(createPinia())
})

Reference it in your Vite config:

test: {
  environment: 'jsdom',
  globals: true,
  setupFiles: ['./tests/setup.js']
}

Now you can test a component that reads from or writes to a store. Suppose you have a useCartStore:

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCartStore = defineStore('cart', () => {
  const items = ref([])
  const total = computed(() => items.value.length)

  function addItem(item) {
    items.value.push(item)
  }

  return { items, total, addItem }
})

Test a component that uses it:

import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import { useCartStore } from '../stores/cart'
import CartButton from './CartButton.vue'

describe('CartButton.vue', () => {
  it('shows the current item count', () => {
    const store = useCartStore()
    store.addItem({ id: 1, name: 'Widget' })

    const wrapper = mount(CartButton)
    expect(wrapper.text()).toContain('Cart (1)')
  })
})

Because Pinia is reset in the beforeEach hook, each test starts with a clean store state, preventing cross-test contamination.

Testing Vue Router

Components that use router-link or useRoute need a router instance during tests. The simplest approach is to install a real router with a stubbed route configuration:

import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import { createRouter, createMemoryHistory } from 'vue-router'
import Navigation from './Navigation.vue'

describe('Navigation.vue', () => {
  it('renders links to each route', async () => {
    const router = createRouter({
      history: createMemoryHistory(),
      routes: [
        { path: '/', component: { template: '<div>Home</div>' } },
        { path: '/about', component: { template: '<div>About</div>' } }
      ]
    })

    await router.push('/')
    await router.isReady()

    const wrapper = mount(Navigation, {
      global: {
        plugins: [router]
      }
    })

    const links = wrapper.findAllComponents({ name: 'RouterLink' })
    expect(links).toHaveLength(2)
  })
})

Using createMemoryHistory avoids polluting the browser URL bar during tests. If you only need to stub router-link without a full router, you can pass a global stub:

const wrapper = mount(Navigation, {
  global: {
    stubs: {
      RouterLink: {
        template: '<a><slot /></a>'
      }
    }
  }
})

End-to-End Testing with Cypress

Unit and component tests verify pieces in isolation, but they cannot tell you whether the whole application works together. That is the job of E2E tests. Cypress is a popular choice for Vue projects. Install it with:

npm install -D cypress @testing-library/cypress

Open Cypress once to scaffold the configuration:

npx cypress open

Assume you have a login flow. Write an E2E test in cypress/e2e/login.cy.js:

describe('Login flow', () => {
  beforeEach(() => {
    cy.visit('/login')
  })

  it('logs in a valid user', () => {
    cy.get('[data-testid="email-input"]').type('user@example.com')
    cy.get('[data-testid="password-input"]').type('secret123')
    cy.get('[data-testid="submit-button"]').click()

    cy.url().should('include', '/dashboard')
    cy.contains('Welcome, user@example.com')
  })

  it('shows an error for invalid credentials', () => {
    cy.intercept('POST', '/api/login', {
      statusCode: 401,
      body: { message: 'Invalid credentials' }
    })

    cy.get('[data-testid="email-input"]').type('user@example.com')
    cy.get('[data-testid="password-input"]').type('wrongpass')
    cy.get('[data-testid="submit-button"]').click()

    cy.contains('Invalid credentials').should('be.visible')
  })
})

The cy.intercept command stubs the network request so the test does not depend on a real backend. Using data-testid attributes keeps your tests resilient to styling and copy changes.

End-to-End Testing with Playwright

Playwright is a powerful alternative that supports multiple browsers out of the box. Install it with:

npm install -D @playwright/test
npx playwright install

Create a config file playwright.config.js:

import { defineConfig } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  use: {
    baseURL: 'http://localhost:5173',
    headless: true
  },
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI
  }
})

Write a test in e2e/todo.spec.js:

import { test, expect } from '@playwright/test'

test('user can add and complete a todo', async ({ page }) => {
  await page.goto('/')

  await page.fill('[data-testid="todo-input"]', 'Write E2E tests')
  await page.press('[data-testid="todo-input"]', 'Enter')

  await expect(page.locator('.todo-item')).toHaveText('Write E2E tests')

  await page.click('.todo-item button:has-text("Toggle")')
  await expect(page.locator('.todo-item span')).toHaveClass(/done/)
})

Playwright automatically waits for elements to appear, which reduces flakiness. The webServer config starts your dev server before tests run and tears it down afterward, making the suite self-contained.

Best Practices

Conclusion

Testing Vue.js components is not a luxury reserved for large teams — it is a practical discipline that pays off from the first component you write. By layering unit tests with Vue Test Utils, mocking external dependencies, integrating Pinia and Vue Router, and topping it off with E2E tests in Cypress or Playwright, you build a safety net that lets you ship features with confidence. Start small: write a test for one component today, add another tomorrow, and before long your suite will become an indispensable part of your development workflow. The investment you make in testing now will save you countless hours of debugging and regression hunting in the future.

— Ad —

Google AdSense will appear here after approval

← Back to all articles