Vue Test Utils 2 Guide: Mounting Components, Props, Emits, Slots, and Async

Vue Test Utils 2 Guide: Mounting Components, Props, Emits, Slots, and Async

Vue Test Utils 2 is the official testing library for Vue 3 components. It mounts components in a virtual DOM (or real browser with Cypress), lets you interact with them, and provides a rich API for asserting on rendered output, emitted events, and DOM structure. This guide covers the full VTU 2 API with practical examples for every testing scenario.

Key Takeaways

mount() renders the full component tree. shallowMount() stubs children. Default to mount() — shallow renders hide integration bugs. Use shallowMount() only for components with heavy child dependencies you can't easily provide.

wrapper.get() throws on missing elements; wrapper.find() returns an empty wrapper. Use get() when the element must exist (failures are clearer). Use find() when the element's presence is what you're testing.

await wrapper.trigger('click') is not enough for async operations. After triggering events that cause async state changes, also await flushPromises() to settle all pending microtasks.

Use data-testid attributes for selectors, not classes or text. CSS classes change with design updates; text changes with copy changes. data-testid is stable and communicates intent.

wrapper.emitted() returns an array of arrays. Each emission is wrapper.emitted('eventName')[callIndex][argIndex]. Check both that the event fired and that the payload is correct.

Installation

npm install --save-dev @vue/test-utils vitest jsdom

For TypeScript:

npm install --save-dev @vue/test-utils vitest jsdom @types/node

mount() vs shallowMount()

mount() renders the component and all its children:

import { mount, shallowMount } from '@vue/test-utils';
import ParentComponent from './ParentComponent.vue';

// Full render — children are real
const wrapper = mount(ParentComponent);

// Shallow render — children are stubs
const shallowWrapper = shallowMount(ParentComponent);

When to use shallowMount():

  • Child components make network requests you can't easily mock
  • Child components require complex global configuration
  • You're explicitly testing the parent component's logic, not integration

For most tests, mount() is the right choice.

Finding Elements

const wrapper = mount(UserCard, {
  props: { user: { id: '1', name: 'Alice', email: 'alice@example.com' } }
});

// By CSS selector
wrapper.find('.user-name')         // returns DOMWrapper or empty wrapper
wrapper.get('.user-name')          // returns DOMWrapper or throws

// By attribute
wrapper.find('[data-testid="delete-btn"]')

// By component
wrapper.findComponent(ChildComponent)
wrapper.getComponent(ChildComponent)

// Multiple elements
wrapper.findAll('.user-card')      // returns DOMWrapper[]

Best practice: use data-testid attributes in your components:

<button data-testid="delete-btn" @click="$emit('delete', user.id)">
  Delete
</button>

This decouples selectors from visual styling.

Testing Props

<!-- Badge.vue -->
<template>
  <span :class="['badge', `badge--${variant}`]">{{ label }}</span>
</template>

<script setup lang="ts">
defineProps<{
  label: string;
  variant: 'success' | 'warning' | 'error';
}>();
</script>
describe('Badge', () => {
  it('renders the label text', () => {
    const wrapper = mount(Badge, {
      props: { label: 'Active', variant: 'success' }
    });
    expect(wrapper.text()).toBe('Active');
  });

  it('applies the correct variant class', () => {
    const wrapper = mount(Badge, {
      props: { label: 'Error', variant: 'error' }
    });
    expect(wrapper.classes()).toContain('badge--error');
  });

  it.each([
    ['success', 'badge--success'],
    ['warning', 'badge--warning'],
    ['error', 'badge--error'],
  ])('applies class for variant %s', (variant, expectedClass) => {
    const wrapper = mount(Badge, {
      props: { label: 'Test', variant: variant as any }
    });
    expect(wrapper.classes()).toContain(expectedClass);
  });
});

Testing Emitted Events

<!-- ConfirmDialog.vue -->
<template>
  <div class="dialog">
    <p>{{ message }}</p>
    <button data-testid="confirm" @click="$emit('confirm')">Confirm</button>
    <button data-testid="cancel" @click="$emit('cancel')">Cancel</button>
  </div>
</template>

<script setup lang="ts">
defineProps<{ message: string }>();
defineEmits<{ confirm: []; cancel: [] }>();
</script>
describe('ConfirmDialog', () => {
  it('emits confirm when confirm button is clicked', async () => {
    const wrapper = mount(ConfirmDialog, {
      props: { message: 'Are you sure?' }
    });

    await wrapper.get('[data-testid="confirm"]').trigger('click');

    expect(wrapper.emitted('confirm')).toBeTruthy();
    expect(wrapper.emitted('confirm')).toHaveLength(1);
  });

  it('emits cancel when cancel button is clicked', async () => {
    const wrapper = mount(ConfirmDialog, {
      props: { message: 'Are you sure?' }
    });

    await wrapper.get('[data-testid="cancel"]').trigger('click');

    expect(wrapper.emitted('cancel')).toBeTruthy();
    expect(wrapper.emitted('confirm')).toBeFalsy();
  });
});

Events with Payloads

// Component emitting data
// @click="$emit('select', { id: item.id, name: item.name })"

it('emits selected item on click', async () => {
  const wrapper = mount(ItemList, { props: { items } });
  
  await wrapper.find('[data-testid="item-0"]').trigger('click');
  
  const emitted = wrapper.emitted('select');
  expect(emitted).toBeTruthy();
  expect(emitted![0][0]).toEqual({ id: '1', name: 'Widget' });
});

Testing Async Behavior

<!-- UserProfile.vue -->
<template>
  <div>
    <div v-if="loading">Loading...</div>
    <div v-else-if="error">Error: {{ error }}</div>
    <div v-else data-testid="user-name">{{ user?.name }}</div>
  </div>
</template>

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

const props = defineProps<{ userId: string }>();
const user = ref<User | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);

onMounted(async () => {
  try {
    user.value = await fetchUser(props.userId);
  } catch (e) {
    error.value = 'Failed to load user';
  } finally {
    loading.value = false;
  }
});
</script>
import { mount, flushPromises } from '@vue/test-utils';
import { vi } from 'vitest';
import UserProfile from './UserProfile.vue';
import * as usersApi from '../api/users';

vi.mock('../api/users');
const mockFetchUser = vi.mocked(usersApi.fetchUser);

describe('UserProfile', () => {
  it('shows loading state initially', () => {
    mockFetchUser.mockReturnValue(new Promise(() => {}));  // never resolves
    
    const wrapper = mount(UserProfile, { props: { userId: '1' } });
    
    expect(wrapper.text()).toContain('Loading...');
  });

  it('displays user name after loading', async () => {
    mockFetchUser.mockResolvedValue({ id: '1', name: 'Alice' });
    
    const wrapper = mount(UserProfile, { props: { userId: '1' } });
    
    await flushPromises();
    
    expect(wrapper.get('[data-testid="user-name"]').text()).toBe('Alice');
  });

  it('displays error on fetch failure', async () => {
    mockFetchUser.mockRejectedValue(new Error('Network error'));
    
    const wrapper = mount(UserProfile, { props: { userId: '1' } });
    
    await flushPromises();
    
    expect(wrapper.text()).toContain('Error:');
    expect(wrapper.text()).toContain('Failed to load user');
  });
});

Testing Slots

import { defineComponent, h } from 'vue';

it('renders default slot content', () => {
  const wrapper = mount(Card, {
    slots: {
      default: '<p data-testid="content">Card content</p>',
    },
  });

  expect(wrapper.get('[data-testid="content"]').text()).toBe('Card content');
});

it('renders named slots', () => {
  const wrapper = mount(Card, {
    slots: {
      header: '<h1>Card Title</h1>',
      default: 'Body content',
      footer: defineComponent({
        render: () => h('button', { 'data-testid': 'footer-btn' }, 'Footer Action'),
      }),
    },
  });

  expect(wrapper.find('.card-header').text()).toBe('Card Title');
  expect(wrapper.get('[data-testid="footer-btn"]').text()).toBe('Footer Action');
});

Testing provide/inject

import { defineComponent } from 'vue';

// Component that injects a value
// inject('theme', 'light')

it('uses injected theme', () => {
  const wrapper = mount(ThemedButton, {
    global: {
      provide: {
        theme: 'dark',
      },
    },
  });

  expect(wrapper.classes()).toContain('theme-dark');
});

Testing v-model

// Input component with v-model
it('updates model value on input', async () => {
  const wrapper = mount(SearchInput, {
    props: {
      modelValue: '',
      'onUpdate:modelValue': (value: string) => {
        wrapper.setProps({ modelValue: value });
      },
    },
  });

  await wrapper.find('input').setValue('hello world');

  expect(wrapper.props('modelValue')).toBe('hello world');
});

Common Mistakes

Forgetting await before trigger():

// Wrong — DOM may not have updated yet
wrapper.find('button').trigger('click');
expect(wrapper.text()).toContain('Clicked');

// Correct
await wrapper.find('button').trigger('click');
expect(wrapper.text()).toContain('Clicked');

Checking text that includes whitespace:

// Fragile — breaks if whitespace changes
expect(wrapper.text()).toBe('Hello    World');

// Better
expect(wrapper.text().trim()).toBe('Hello    World');
// Or
expect(wrapper.text()).toContain('Hello');

Asserting on implementation details:

// Wrong — tests internal state directly
expect((wrapper.vm as any).isLoading).toBe(false);

// Correct — tests what the user sees
expect(wrapper.find('[data-testid="loading-spinner"]').exists()).toBe(false);

Running Component Tests

npx vitest run                          # all tests, single run
npx vitest --reporter=verbose           <span class="hljs-comment"># detailed per-test output
npx vitest --coverage                   <span class="hljs-comment"># with coverage report

Production Monitoring

Component tests verify individual Vue components in isolation. They don't test the deployed application, routing, API integrations, or what real users experience.

HelpMeTest continuously tests your live Vue application — from loading the page to completing user flows. Free tier includes 10 tests with 5-minute monitoring.

Read more