Unit Testing Vue 3 Components with Vitest and Vue Test Utils

by Didin J. on Jan 06, 2026 Unit Testing Vue 3 Components with Vitest and Vue Test Utils

Unit test Vue 3 components with Vitest and Vue Test Utils. Learn setup, props, events, slots, mocks, async testing, and best practices.

Unit testing is a critical part of building reliable, maintainable frontend applications. With Vue.js 3, the ecosystem has evolved toward faster tooling, better TypeScript support, and a more modular architecture—making modern testing workflows both simpler and more powerful.

In this tutorial, you’ll learn how to unit test Vue 3 components using Vitest and Vue Test Utils, the officially recommended testing stack for Vue applications built with Vite.

By the end of this guide, you will be able to:

  • Understand the role of unit testing in Vue 3 applications

  • Set up Vitest and Vue Test Utils from scratch

  • Write and run tests for Vue 3 components (props, events, slots, and lifecycle)

  • Mock dependencies and composables effectively

  • Apply best practices for fast, maintainable test suites

Why Vitest for Vue 3?

Vitest is designed to work seamlessly with Vite-based projects, which makes it an excellent choice for Vue 3:

  • Blazing fast test runs using native ES modules

  • 🧪 Jest-compatible API, so it feels familiar

  • 🔌 First-class Vue 3 + TypeScript support

  • 📦 Easy mocking, spies, and snapshot testing

Combined with Vue Test Utils, Vitest provides a clean and expressive way to test Vue components in isolation.

What We’ll Build and Test

Throughout this tutorial, we’ll test real Vue 3 components, including:

  • A simple counter component

  • Components with props and emitted events

  • Components using slots and conditional rendering

  • Components that depend on composables or external services

All examples will follow Vue 3 Composition API best practices and modern project structure.


Project Setup – Installing Vue 3, Vitest, and Vue Test Utils

In this section, we’ll set up a Vue 3 project with Vitest and Vue Test Utils using Vite, which is the recommended tooling for modern Vue apps.

1. Create a New Vue 3 Project with Vite

Start by scaffolding a new Vue 3 project:

 
npm create vite@latest vue-vitest-testing

 

Choose the following options:

  • Framework: Vue

  • Variant: TypeScript (recommended, but optional)

Then install dependencies:

 
cd vue-vitest-testing
npm install

 

You can also use pnpm or yarn if you prefer—Vitest works with all of them.

2. Verify the Vue 3 Setup

Start the dev server to ensure everything works:

 
npm run dev

 

You should see the default Vue welcome page powered by Vue.js 3.

3. Install Vitest and Vue Test Utils

Now install the testing dependencies:

 
npm install -D vitest @vitest/ui @vue/test-utils jsdom

 

What these packages do:

  • Vitest – test runner and assertion library

  • Vue Test Utils – utilities for mounting and interacting with Vue components

  • jsdom – browser-like environment for component testing

  • @vitest/ui – optional web UI for running tests

4. Configure Vitest

Update vite.config.ts:

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

export default defineConfig({
  plugins: [vue()],
  test: {
    environment: 'jsdom',
    globals: true,
  },
})

 

This tells Vitest to:

  • Use a browser-like environment

  • Allow global test functions like describe, it, and expect

5. Add a Test Script

Update package.json:

 
{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "test": "vitest",
    "test:ui": "vitest --ui"
  }
}

 

Run tests with:

npm run test

Or launch the visual test runner:

 
npm run test:ui

 

6. Project Structure for Testing

A clean structure looks like this:

 
src/
 ├─ components/
 │   └─ Counter.vue
 └─ tests/
     └─ Counter.spec.ts

 

Vitest automatically detects files ending with .spec.ts or .test.ts.

7. Sanity Check (Optional)

Create a simple test to confirm everything works:

 
import { describe, it, expect } from 'vitest'

describe('sanity check', () => {
  it('works correctly', () => {
    expect(true).toBe(true)
  })
})

 

Run:

 
npm run test

 

✅ If this passes, your environment is ready.


Writing Your First Vue 3 Component Test

Now that the project is set up, let’s write your first real unit test for a Vue 3 component using Vue Test Utils and Vitest.

We’ll start simple:
✅ mounting a component
✅ checking rendered output
✅ interacting with the DOM

1. Create a Simple Vue 3 Component

Create src/components/Counter.vue:

 
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)

const increment = () => {
  count.value++
}
</script>

<template>
  <div>
    <p data-testid="count">Count: {{ count }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>

 

This component uses the Composition API from Vue.js 3 and exposes a simple interaction we can test.

2. Create the Test File

Create src/tests/Counter.spec.ts:

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

 

3. Mounting the Component

Use mount() to render the component in a simulated DOM:

describe('Counter.vue', () => {
  it('renders initial count', () => {
    const wrapper = mount(Counter)

    expect(wrapper.text()).toContain('Count: 0')
  })
})

🔍 What’s happening here?

  • mount() fully renders the component

  • wrapper.text() returns all rendered text

  • expect() asserts the expected output

Run the test:

 
npm run test

 

✅ You should see the passing test.

4. Querying DOM Elements

Instead of relying on raw text, it’s better to query specific elements.

Update the test:

it('shows the initial count as 0', () => {
  const wrapper = mount(Counter)

  const count = wrapper.get('[data-testid="count"]')
  expect(count.text()).toBe('Count: 0')
})

✔️ get() throws an error if the element doesn’t exist (good for strict tests).

5. Testing User Interaction (Click Event)

Now let’s test the button click:

 
it('increments the count when button is clicked', async () => {
  const wrapper = mount(Counter)

  await wrapper.get('button').trigger('click')
  expect(wrapper.get('[data-testid="count"]').text()).toBe('Count: 1')
})

 

Important details:

  • trigger() simulates DOM events

  • await is required because Vue updates the DOM asynchronously

6. Best Practices So Far

✔ Use data-testid attributes for stable selectors
✔ Test behavior, not implementation details
✔ Keep tests small and focused
✔ Prefer get() over find() when the element must exist

7. Test Output Example

When everything passes, Vitest will show:

 
 ✓ src/tests/Counter.spec.ts (3 tests) 15ms
   ✓ Counter.vue (3)
     ✓ renders initial count 7ms
     ✓ shows the initial count as 0 5ms
     ✓ increments the count when button is clicked 3ms

 Test Files  1 passed (1)
      Tests  3 passed (3)
   Start at  15:45:33
   Duration  646ms (transform 46ms, setup 0ms, import 108ms, tests 15ms, environment 431ms)

 


Testing Props and Conditional Rendering in Vue 3 Components

In real-world Vue apps, components rarely stand alone. They receive props, render content conditionally, and adapt their UI based on state. In this section, we’ll test all of that using Vue Test Utils with Vitest.

1. Create a Component with Props

Create src/components/StatusMessage.vue:

<script setup lang="ts">
defineProps<{
  status: 'success' | 'error'
}>()
</script>

<template>
  <p v-if="status === 'success'" class="success">
    Operation completed successfully
  </p>

  <p v-else class="error">
    Something went wrong
  </p>
</template>

This component:

  • Accepts a status prop

  • Uses v-if / v-else for conditional rendering

2. Create the Test File

Create src/tests/StatusMessage.spec.ts:

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

3. Testing Props

Pass props when mounting the component:

it('renders success message when status is success', () => {
  const wrapper = mount(StatusMessage, {
    props: {
      status: 'success',
    },
  })

  expect(wrapper.text()).toContain('Operation completed successfully')
  expect(wrapper.classes()).toContain('success')
})

props option allows full control over component inputs.

4. Testing Conditional Rendering

Test the error state:

it('renders error message when status is error', () => {
  const wrapper = mount(StatusMessage, {
    props: {
      status: 'error',
    },
  })

  expect(wrapper.text()).toContain('Something went wrong')
  expect(wrapper.classes()).toContain('error')
})

5. Updating Props Dynamically

Vue Test Utils allows you to update props after mounting:

it('updates the message when status changes', async () => {
  const wrapper = mount(StatusMessage, {
    props: {
      status: 'success',
    },
  })

  await wrapper.setProps({ status: 'error' })

  expect(wrapper.text()).toContain('Something went wrong')
})

🔁 This is useful for testing reactive UI changes.

6. Testing Element Existence

Instead of checking text, you can test if elements exist:

it('shows only one message at a time', () => {
  const wrapper = mount(StatusMessage, {
    props: {
      status: 'success',
    },
  })

  expect(wrapper.find('.success').exists()).toBe(true)
  expect(wrapper.find('.error').exists()).toBe(false)
})

7. Best Practices for Props Testing

✔ Test each prop variation
✔ Keep prop types strict (TypeScript helps here)
✔ Avoid testing internal logic—focus on rendered output
✔ Prefer exists() for conditional UI


Testing Events and Emitted Values in Vue 3 Components

Vue components often communicate upward by emitting events. In this section, you’ll learn how to test emitted events and their payloads using Vue Test Utils and Vitest.

1. Create a Component That Emits Events

Create src/components/UserForm.vue:

<script setup lang="ts">
import { ref } from 'vue'

const emit = defineEmits<{
  (e: 'submit', payload: { name: string }): void
}>()

const name = ref('')

const submitForm = () => {
  emit('submit', { name: name.value })
}
</script>

<template>
  <form @submit.prevent="submitForm">
    <input v-model="name" placeholder="Enter name" />
    <button type="submit">Submit</button>
  </form>
</template>

This component emits a submit event with a payload.

2. Create the Test File

Create src/tests/UserForm.spec.ts:

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

3. Triggering Form Submission

Simulate user interaction:

it('emits submit event with name payload', async () => {
  const wrapper = mount(UserForm)

  await wrapper.get('input').setValue('Djamware')
  await wrapper.get('form').trigger('submit')

  const events = wrapper.emitted('submit')
  expect(events).toBeTruthy()
})

4. Asserting Emitted Event Payload

Verify the payload:

it('emits correct payload when form is submitted', async () => {
  const wrapper = mount(UserForm)

  await wrapper.get('input').setValue('Djamware')
  await wrapper.get('form').trigger('submit')

  const submitEvents = wrapper.emitted('submit')

  expect(submitEvents).toHaveLength(1)
  expect(submitEvents?.[0]).toEqual([{ name: 'Djamware' }])
})

🔍 wrapper.emitted() returns an object where:

  • Keys are event names

  • Values are arrays of emitted payloads

5. Testing That an Event Was NOT Emitted

You can also assert negative cases:

it('does not emit submit when form is not submitted', () => {
  const wrapper = mount(UserForm)

  expect(wrapper.emitted('submit')).toBeUndefined()
})

6. Best Practices for Event Testing

✔ Test event name and payload
✔ Use real DOM events (click, submit, input)
✔ Avoid calling component methods directly
✔ Assert emitted events instead of internal state

7. Common Mistakes to Avoid

❌ Testing emit() directly
❌ Relying on implementation details
❌ Forgetting await on DOM updates


Testing Slots and Scoped Slots in Vue 3 Components

Slots are a core feature of Vue.js 3, enabling flexible and reusable components. In this section, you’ll learn how to test default slots, named slots, and scoped slots using Vue Test Utils and Vitest.

1. Testing Default Slots

Component: BaseCard.vue

 
<template>
  <div class="card">
    <slot />
  </div>
</template>

 

Test: BaseCard.spec.ts

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

describe('BaseCard.vue', () => {
  it('renders content passed to default slot', () => {
    const wrapper = mount(BaseCard, {
      slots: {
        default: '<p>Slot Content</p>',
      },
    })

    expect(wrapper.html()).toContain('Slot Content')
  })
})

 

✔ The slots option allows you to inject slot content directly.

2. Testing Named Slots

Component: Modal.vue

 
<template>
  <div class="modal">
    <header>
      <slot name="header" />
    </header>

    <main>
      <slot />
    </main>
  </div>
</template>

 

Test: Modal.spec.ts

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

describe('Modal.vue', () => {
  it('renders header and default slots', () => {
    const wrapper = mount(Modal, {
      slots: {
        header: '<h1>Modal Title</h1>',
        default: '<p>Modal Content</p>',
      },
    })

    expect(wrapper.text()).toContain('Modal Title')
    expect(wrapper.text()).toContain('Modal Content')
  })
})

 

3. Testing Scoped Slots

Scoped slots expose data from the child component to the parent.

Component: UserList.vue

<script setup lang="ts">
defineProps<{
  users: { id: number; name: string }[]
}>()
</script>

<template>
  <ul>
    <li v-for="user in users" :key="user.id">
      <slot name="user" :user="user">
        {{ user.name }}
      </slot>
    </li>
  </ul>
</template>

Test: UserList.spec.ts

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

describe('UserList.vue', () => {
  it('renders scoped slot with user data', () => {
    const wrapper = mount(UserList, {
      props: {
        users: [{ id: 1, name: 'Alice' }],
      },
      slots: {
        user: ({ user }: any) => `<strong>${user.name}</strong>`,
      },
    })

    expect(wrapper.html()).toContain('<strong>Alice</strong>')
  })
})

🔍 Scoped slots can be passed as functions to access slot props.

4. Slot Fallback Content

Test fallback content when no slot is provided:

it('renders fallback content when no slot is passed', () => {
  const wrapper = mount(UserList, {
    props: {
      users: [{ id: 1, name: 'Bob' }],
    },
  })

  expect(wrapper.text()).toContain('Bob')
})

5. Best Practices for Slot Testing

✔ Test slot rendering, not layout
✔ Use minimal slot content
✔ Prefer text-based assertions
✔ Test fallback behavior


Mocking Dependencies and Composables in Vue 3 Tests

Modern Vue.js 3 applications rely heavily on composables, API services, and external dependencies. To keep unit tests fast, deterministic, and isolated, we need to mock those dependencies effectively. In this section, you’ll learn how to mock composables, modules, and global dependencies using Vitest and Vue Test Utils.

1. Mocking a Simple Composable

Composable: useCounter.ts

import { ref } from 'vue'

export function useCounter() {
  const count = ref(0)
  const increment = () => count.value++
  return { count, increment }
}

Component: CounterWithComposable.vue

<script setup lang="ts">
import { useCounter } from '../composables/useCounter'

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

<template>
  <button @click="increment">
    Count: {{ count }}
  </button>
</template>

2. Mocking the Composable with Vitest

In CounterWithComposable.spec.ts:

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

vi.mock('../composables/useCounter', () => {
  return {
    useCounter: () => ({
      count: { value: 10 },
      increment: vi.fn(),
    }),
  }
})

describe('CounterWithComposable.vue', () => {
  it('renders mocked count value', () => {
    const wrapper = mount(CounterWithComposable)
    expect(wrapper.text()).toContain('Count: 10')
  })
})

✔ The real composable is replaced with a mock implementation.

3. Mocking Methods and Verifying Calls

Test that a mocked method is called:

it('calls increment when button is clicked', async () => {
  const wrapper = mount(CounterWithComposable)
  await wrapper.trigger('click')

  const { increment } = require('../composables/useCounter').useCounter()
  expect(increment).toHaveBeenCalled()
})

4. Mocking API Services (Axios Example)

Service: api.ts

export async function fetchUser() {
  return { id: 1, name: 'Djamware' }
}

Mock the service:

vi.mock('../services/api', () => ({
  fetchUser: vi.fn(() =>
    Promise.resolve({ id: 1, name: 'Mock User' })
  ),
}))

Then assert rendered output based on mocked data.

5. Mocking Global Properties

Mock global dependencies like $router or $t:

mount(MyComponent, {
  global: {
    mocks: {
      $t: (msg: string) => msg,
    },
  },
})

This is useful for i18n and router mocks.

6. Mocking Provide / Inject

mount(MyComponent, {
  global: {
    provide: {
      theme: 'dark',
    },
  },
})

7. Best Practices for Mocking

✔ Mock only what you don’t own
✔ Keep mocks simple and predictable
✔ Reset mocks between tests if needed (vi.resetAllMocks())
✔ Avoid over-mocking


Testing Async Logic, API Calls, and Loading States in Vue 3

Many Vue 3 components perform asynchronous operations such as fetching data from APIs, loading resources, or waiting for side effects. In this section, you’ll learn how to test async behavior, loading states, and error handling using Vitest and Vue Test Utils.

1. Example Component with Async Logic

Component: UserProfile.vue

<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchUser } from '../services/api'

const loading = ref(true)
const user = ref<{ name: string } | null>(null)
const error = ref<string | null>(null)

onMounted(async () => {
  try {
    user.value = await fetchUser()
  } catch (e) {
    error.value = 'Failed to load user'
  } finally {
    loading.value = false
  }
})
</script>

<template>
  <p v-if="loading">Loading...</p>
  <p v-else-if="error">{{ error }}</p>
  <p v-else>{{ user?.name }}</p>
</template>

2. Mock the API Call

Mock the fetchUser function:

import { vi } from 'vitest'

vi.mock('../services/api', () => ({
  fetchUser: vi.fn(),
}))

3. Testing the Loading State

import { mount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import UserProfile from '../components/UserProfile.vue'
import { fetchUser } from '../services/api'

it('shows loading state initially', () => {
  ;(fetchUser as any).mockResolvedValue({ name: 'Djamware' })

  const wrapper = mount(UserProfile)
  expect(wrapper.text()).toContain('Loading...')
})

4. Testing Successful API Response

it('renders user name after successful fetch', async () => {
  ;(fetchUser as any).mockResolvedValue({ name: 'Djamware' })

  const wrapper = mount(UserProfile)

  await Promise.resolve() // wait for onMounted
  await wrapper.vm.$nextTick()

  expect(wrapper.text()).toContain('Djamware')
})

nextTick() ensures DOM updates are flushed.

5. Testing Error State

it('shows error message when API fails', async () => {
  ;(fetchUser as any).mockRejectedValue(new Error('API error'))

  const wrapper = mount(UserProfile)

  await Promise.resolve()
  await wrapper.vm.$nextTick()

  expect(wrapper.text()).toContain('Failed to load user')
})

6. Using flushPromises (Optional)

Install helper:

 
npm install -D flush-promises

 

Usage:

 
import flushPromises from 'flush-promises'

await flushPromises()

 

This is helpful when multiple async calls are involved.

7. Best Practices for Async Testing

✔ Always mock async dependencies
✔ Assert loading → success/error transitions
✔ Use await and nextTick() consistently
✔ Keep async tests deterministic


Testing with Stubs, shallowMount, and Component Isolation

As your Vue application grows, components often depend on child components, third-party UI libraries, or complex layouts. To keep unit tests focused and fast, you can isolate components using stubs and shallowMount. In this section, you’ll learn when and how to use them with Vue Test Utils and Vitest.

1. mount vs shallowMount

  • mount – renders the component and all child components

  • shallowMount – renders the component but stubs all child components

Use shallowMount when:

  • Child components are complex

  • You only care about parent behavior

  • You want faster tests

2. Example Parent & Child Components

Child: BaseButton.vue

 
<template>
  <button class="base-button">
    <slot />
  </button>
</template>

 

Parent: ConfirmDialog.vue

 
<script setup lang="ts">
const emit = defineEmits<{
  (e: 'confirm'): void
}>()
</script>

<template>
  <div>
    <p>Are you sure?</p>
    <BaseButton @click="emit('confirm')">Confirm</BaseButton>
  </div>
</template>

 

3. Testing with shallowMount

import { shallowMount } from '@vue/test-utils'
import { describe, it, expect } from 'vitest'
import ConfirmDialog from '../components/ConfirmDialog.vue'

describe('ConfirmDialog.vue', () => {
    it('renders confirmation message', () => {
        const wrapper = shallowMount(ConfirmDialog)

        expect(wrapper.text()).toContain('Are you sure?')
    })
})

✔ Child components are automatically stubbed.

4. Stubbing Child Components Explicitly

shallowMount(ConfirmDialog, {
  global: {
    stubs: {
      BaseButton: {
        template: '<button><slot /></button>',
      },
    },
  },
})

This gives you control over the stubbed output.

5. Triggering Events on Stubbed Components

it('emits confirm event when button is clicked', async () => {
  const wrapper = shallowMount(ConfirmDialog)

  await wrapper.find('button').trigger('click')

  expect(wrapper.emitted('confirm')).toBeTruthy()
})

Even with stubs, events still work.

6. Stubbing Third-Party Components

For UI libraries (e.g. modals, icons):

mount(MyComponent, {
  global: {
    stubs: ['FontAwesomeIcon', 'RouterLink'],
  },
})

7. Best Practices for Component Isolation

✔ Use mount for integration-style tests
✔ Use shallowMount for pure unit tests
✔ Stub third-party components aggressively
✔ Avoid snapshot tests on deeply nested trees


Test Organization, Coverage, and Best Practices for Vue 3

As your test suite grows, organization and consistency become just as important as writing the tests themselves. In this final section, we’ll cover project structure, test coverage, naming conventions, and best practices to keep your Vue 3 unit tests clean, fast, and maintainable—using Vitest and Vue Test Utils.

1. Recommended Test Folder Structure

Two common and valid approaches:

Option A: Centralized tests/ folder

 
src/
 ├─ components/
 │   └─ UserForm.vue
 └─ tests/
     └─ UserForm.spec.ts

 

Option B: Co-located tests (recommended for large apps)

 
src/
 ├─ components/
 │   ├─ UserForm.vue
 │   └─ UserForm.spec.ts

 

✔ Choose one style and stay consistent.

2. Naming Conventions

  • Test files: ComponentName.spec.ts

  • Test descriptions:

     
    it('emits submit event when form is submitted', () => {})

     

✔ Describe behavior, not implementation.

3. Running Tests in Watch Mode

 
npm run test -- --watch

 

This keeps tests running as you code.

4. Enabling Test Coverage

Install coverage support:

 
npm install -D @vitest/coverage-v8

 

Update vite.config.ts:

 
test: {
  coverage: {
    reporter: ['text', 'html'],
    exclude: ['node_modules/', 'src/main.ts'],
  },
}

 

Run coverage:

 
npm run test -- --coverage

 

📊 Coverage reports help identify untested logic, but don’t chase 100%.

5. What (and What Not) to Test

✅ Test:

  • Component behavior

  • User interactions

  • Props, events, slots

  • Conditional rendering

❌ Avoid:

  • Internal refs and methods

  • Vue internals

  • Third-party libraries

6. Common Anti-Patterns

❌ Snapshot testing everything
❌ Over-mocking simple logic
❌ Testing CSS details
❌ Duplicating e2e tests

7. CI Integration (Quick Example)

 
npm run test -- --run

 

Use this in GitHub Actions or CI pipelines to run tests once.

8. Final Testing Checklist

✔ Tests are fast and deterministic
✔ Clear test names and structure
✔ Minimal mocking
✔ Good coverage of business logic
✔ Easy to maintain


Conclusion

Unit testing Vue 3 components with Vitest and Vue Test Utils gives you a modern, fast, and developer-friendly testing experience. By focusing on behavior-driven tests, isolating components properly, and keeping your test suite organized, you can confidently ship features without fear of regressions.

This testing stack aligns perfectly with the modern Vue 3 + Vite ecosystem and scales well from small projects to large applications.

You can find the full source code on our GitHub.

We know that building beautifully designed Mobile and Web Apps from scratch can be frustrating and very time-consuming. Check Envato unlimited downloads and save development and design time.

That's just the basics. If you need more deep learning about Vue/Vue.js, you can take the following cheap course:

Thanks!