Back
Vue 3

Testing Vue 3 Components with Vitest

Learn to test Vue 3 components with Vitest and Vue Test Utils. Setup, testing props, emits, slots, composables, and mocking.

Francisco ZapataWritten by Francisco Zapata
February 5, 202611 min read
Testing Vue 3 Components with Vitest

Testing is essential for maintaining robust applications. Vitest, the native testing framework for the Vite ecosystem, has become the preferred choice for testing Vue 3 applications thanks to its speed, Jest API compatibility, and minimal configuration.

Initial Setup

Installation

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

Vitest Configuration

In your vite.config.ts:

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

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'happy-dom',
    globals: true,
    include: ['src/**/*.{test,spec}.{js,ts}']
  }
})

Add scripts to package.json:

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

Basic Component Testing

Given a simple component:

<!-- src/components/Counter.vue -->
<script setup>
import { ref } from 'vue'

const props = defineProps({
  initial: { type: Number, default: 0 }
})

const count = ref(props.initial)
const increment = () => count.value++
const decrement = () => count.value--
</script>

<template>
  <div class="counter">
    <button data-testid="decrement" @click="decrement">-</button>
    <span data-testid="count">{{ count }}</span>
    <button data-testid="increment" @click="increment">+</button>
  </div>
</template>

The corresponding test:

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

describe('Counter', () => {
  it('renders default initial value', () => {
    const wrapper = mount(Counter)
    expect(wrapper.find('[data-testid="count"]').text()).toBe('0')
  })

  it('renders provided initial value', () => {
    const wrapper = mount(Counter, {
      props: { initial: 10 }
    })
    expect(wrapper.find('[data-testid="count"]').text()).toBe('10')
  })

  it('increments when clicking +', async () => {
    const wrapper = mount(Counter)
    await wrapper.find('[data-testid="increment"]').trigger('click')
    expect(wrapper.find('[data-testid="count"]').text()).toBe('1')
  })

  it('decrements when clicking -', async () => {
    const wrapper = mount(Counter, { props: { initial: 5 } })
    await wrapper.find('[data-testid="decrement"]').trigger('click')
    expect(wrapper.find('[data-testid="count"]').text()).toBe('4')
  })
})

mount vs shallowMount

  • mount: Renders the complete component with all children. Ideal for integration tests.
  • shallowMount: Renders only the component, replacing children with stubs. Ideal for isolated unit tests.
import { mount, shallowMount } from '@vue/test-utils'

const full = mount(ParentComponent)
const shallow = shallowMount(ParentComponent)
When to use each? Use mount by default. Only use shallowMount when child components are expensive to render or when you want strict test isolation.

Testing Props

describe('UserCard', () => {
  it('displays user name', () => {
    const wrapper = mount(UserCard, {
      props: {
        name: 'Francisco Zapata',
        email: 'francisco@example.com',
        role: 'admin'
      }
    })

    expect(wrapper.text()).toContain('Francisco Zapata')
    expect(wrapper.text()).toContain('francisco@example.com')
  })

  it('applies special class for admin', () => {
    const wrapper = mount(UserCard, {
      props: { name: 'Test', email: 't@t.com', role: 'admin' }
    })

    expect(wrapper.find('.badge').classes()).toContain('badge-admin')
  })
})

Testing Emits

<!-- SearchInput.vue -->
<script setup>
const emit = defineEmits(['search', 'clear'])
const query = ref('')

function handleSearch() {
  emit('search', query.value)
}
</script>
describe('SearchInput', () => {
  it('emits search event with query', async () => {
    const wrapper = mount(SearchInput)

    await wrapper.find('input').setValue('vue 3')
    await wrapper.find('form').trigger('submit')

    expect(wrapper.emitted('search')).toBeTruthy()
    expect(wrapper.emitted('search')[0]).toEqual(['vue 3'])
  })

  it('emits clear event', async () => {
    const wrapper = mount(SearchInput)
    await wrapper.find('[data-testid="clear"]').trigger('click')

    expect(wrapper.emitted()).toHaveProperty('clear')
  })
})

Testing Slots

describe('Card', () => {
  it('renders default slot content', () => {
    const wrapper = mount(Card, {
      slots: {
        default: '<p>Body content</p>'
      }
    })
    expect(wrapper.find('.card-body').html()).toContain('Body content')
  })

  it('renders named slots', () => {
    const wrapper = mount(Card, {
      slots: {
        header: '<h2>My Title</h2>',
        default: '<p>Body</p>',
        footer: '<button>Action</button>'
      }
    })

    expect(wrapper.find('.card-header').text()).toBe('My Title')
    expect(wrapper.find('.card-footer button').exists()).toBe(true)
  })

  it('shows default title when no header slot', () => {
    const wrapper = mount(Card)
    expect(wrapper.find('.card-header').text()).toBe('Título por defecto')
  })

  it('hides footer when no footer slot', () => {
    const wrapper = mount(Card)
    expect(wrapper.find('.card-footer').exists()).toBe(false)
  })
})

Testing Composables

Composables can be tested directly without mounting a component:

import { describe, it, expect } from 'vitest'
import { useCounter } from '../useCounter'

describe('useCounter', () => {
  it('starts with default value 0', () => {
    const { count } = useCounter()
    expect(count.value).toBe(0)
  })

  it('starts with custom value', () => {
    const { count } = useCounter(10)
    expect(count.value).toBe(10)
  })

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

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

For composables using lifecycle hooks, you need a wrapper component:

import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'

function withSetup(composable) {
  let result
  const comp = defineComponent({
    setup() {
      result = composable()
      return () => null
    }
  })
  const wrapper = mount(comp)
  return { result, wrapper }
}

it('returns window size', () => {
  const { result } = withSetup(() => useWindowSize())
  expect(result.width.value).toBeGreaterThan(0)
})

Mocking

Module Mocking

import { vi } from 'vitest'

vi.mock('@/services/api', () => ({
  fetchUsers: vi.fn(() => Promise.resolve([
    { id: 1, name: 'Ana' },
    { id: 2, name: 'Luis' }
  ]))
}))

Fetch API Mocking

import { vi } from 'vitest'

beforeEach(() => {
  global.fetch = vi.fn(() =>
    Promise.resolve({
      ok: true,
      json: () => Promise.resolve({ data: 'test' })
    })
  )
})

afterEach(() => {
  vi.restoreAllMocks()
})

Vue Router Mocking

import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'

const router = createRouter({
  history: createMemoryHistory(),
  routes: [{ path: '/', component: { template: '<div>Home</div>' } }]
})

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

Testing with Coverage

Install the coverage provider:

npm install -D @vitest/coverage-v8

Run:

npx vitest run --coverage

Configure minimum thresholds in vite.config.ts:

test: {
  coverage: {
    provider: 'v8',
    reporter: ['text', 'html'],
    exclude: ['node_modules/', 'src/**/*.test.ts'],
    thresholds: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  }
}

Best Practices

1. Test behavior, not implementation: Verify what the user sees, not internal details.

2. Use data-testid: Avoid fragile CSS selectors that break with styling changes.

3. One main assertion per test: Each test should verify a single thing.

4. Use async/await: Always wait for DOM changes with await nextTick() or await wrapper.trigger().

5. Clean up between tests: Use beforeEach/afterEach for clean state.

Conclusion

Vitest and Vue Test Utils form a powerful team for testing Vue 3 applications. Vitest's speed, combined with its Jest API compatibility, makes the transition straightforward. Start by testing your critical composables and components, and gradually expand coverage.

Comments (0)

Leave a comment

Be the first to comment