Test Vue 3 components using Vue Test Utils and Vitest: mount components, simulate user events, test Composition API logic, mock Pinia stores, and test async component behaviour.
Vue 3's Composition API and first-class TypeScript support make it an excellent framework for testable code. Vue Test Utils v2 pairs with Vitest (the Vite-native test runner) to provide a fast, ergonomic testing experience where component tests run in milliseconds via jsdom.
This lesson covers the Vue-specific testing patterns you will use on every real project: mounting components with controlled dependencies, simulating user interactions, testing reactive composables, and mocking Pinia stores. The patterns here complement the universal principles covered in earlier lessons — the Vue-specific tools change, but the underlying philosophy of testing behaviour over implementation remains constant.
Vue Test Utils provides mount (full mount with child components) and shallowMount (stubs child components). For unit tests, shallowMount isolates the component under test.
1// src/components/CourseCard.vue
2<script setup lang="ts">
3interface Props {
4 title: string;
5 instructor: string;
6 price: number;
7 enrolled: boolean;
8}
9const props = defineProps<Props>();
10const emit = defineEmits<{ enrol: [courseId: string] }>();
11</script>
12
13<template>
14 <article class="course-card">
15 <h2>{{ title }}</h2>
16 <p>By {{ instructor }}</p>
17 <span class="price">${{ price.toFixed(2) }}</span>
18 <button
19 v-if="!enrolled"
20 data-testid="enrol-btn"
21 @click="emit('enrol', 'course-1')"
22 >
23 Enrol Now
24 </button>
25 <span v-else data-testid="enrolled-badge">Enrolled</span>
26 </article>
27</template>
28
29// src/components/CourseCard.test.ts
30import { mount } from '@vue/test-utils';
31import CourseCard from './CourseCard.vue';
32
33const defaultProps = {
34 title: 'TypeScript Fundamentals',
35 instructor: 'Alice Chen',
36 price: 49.99,
37 enrolled: false,
38};
39
40describe('CourseCard', () => {
41 it('renders the course title as a heading', () => {
42 const wrapper = mount(CourseCard, { props: defaultProps });
43 expect(wrapper.find('h2').text()).toBe('TypeScript Fundamentals');
44 });
45
46 it('renders the instructor name', () => {
47 const wrapper = mount(CourseCard, { props: defaultProps });
48 expect(wrapper.text()).toContain('By Alice Chen');
49 });
50
51 it('formats the price with 2 decimal places', () => {
52 const wrapper = mount(CourseCard, { props: { ...defaultProps, price: 9.9 } });
53 expect(wrapper.find('.price').text()).toBe('$9.90');
54 });
55
56 it('shows the Enrol Now button when not enrolled', () => {
57 const wrapper = mount(CourseCard, { props: { ...defaultProps, enrolled: false } });
58 expect(wrapper.find('[data-testid="enrol-btn"]').exists()).toBe(true);
59 expect(wrapper.find('[data-testid="enrolled-badge"]').exists()).toBe(false);
60 });
61
62 it('shows Enrolled badge instead of button when enrolled', () => {
63 const wrapper = mount(CourseCard, { props: { ...defaultProps, enrolled: true } });
64 expect(wrapper.find('[data-testid="enrolled-badge"]').exists()).toBe(true);
65 expect(wrapper.find('[data-testid="enrol-btn"]').exists()).toBe(false);
66 });
67});Vue Test Utils uses wrapper.trigger() for DOM events and wrapper.setValue() for form inputs.
1import { mount } from '@vue/test-utils';
2import CourseCard from './CourseCard.vue';
3
4describe('CourseCard events', () => {
5 it('emits the enrol event when Enrol Now is clicked', async () => {
6 const wrapper = mount(CourseCard, { props: defaultProps });
7
8 await wrapper.find('[data-testid="enrol-btn"]').trigger('click');
9
10 // Check emitted events
11 expect(wrapper.emitted('enrol')).toBeTruthy();
12 expect(wrapper.emitted('enrol')![0]).toEqual(['course-1']);
13 });
14});
15
16// Testing a search form component
17// src/components/SearchForm.vue — simplified
18describe('SearchForm', () => {
19 it('emits search event with input value on submit', async () => {
20 const wrapper = mount(SearchForm);
21
22 // Set the input value
23 await wrapper.find('input[type="search"]').setValue('Vue testing');
24
25 // Submit the form
26 await wrapper.find('form').trigger('submit');
27
28 expect(wrapper.emitted('search')).toBeTruthy();
29 expect(wrapper.emitted('search')![0]).toEqual(['Vue testing']);
30 });
31
32 it('does not emit search when input is empty', async () => {
33 const wrapper = mount(SearchForm);
34 await wrapper.find('form').trigger('submit');
35 expect(wrapper.emitted('search')).toBeFalsy();
36 });
37
38 it('trims whitespace before emitting', async () => {
39 const wrapper = mount(SearchForm);
40 await wrapper.find('input[type="search"]').setValue(' Vue ');
41 await wrapper.find('form').trigger('submit');
42 expect(wrapper.emitted('search')![0]).toEqual(['Vue']);
43 });
44});
45
46// Keyboard events:
47await wrapper.find('input').trigger('keydown', { key: 'Enter' });
48await wrapper.find('button').trigger('mouseenter');
49await wrapper.trigger('focus');Composables (useXxx functions) can be tested directly using a lightweight wrapper component or with @vueuse/core's renderComposable helper pattern.
1// src/composables/useSearch.ts
2import { ref, computed, watch } from 'vue';
3
4export function useSearch(searchFn: (query: string) => Promise<string[]>) {
5 const query = ref('');
6 const results = ref<string[]>([]);
7 const loading = ref(false);
8 const error = ref('');
9
10 const hasResults = computed(() => results.value.length > 0);
11
12 watch(query, async (newQuery) => {
13 if (newQuery.length < 2) { results.value = []; return; }
14 loading.value = true;
15 error.value = '';
16 try {
17 results.value = await searchFn(newQuery);
18 } catch {
19 error.value = 'Search failed';
20 } finally {
21 loading.value = false;
22 }
23 });
24
25 return { query, results, loading, error, hasResults };
26}
27
28// src/composables/useSearch.test.ts
29import { mount } from '@vue/test-utils';
30import { defineComponent, ref } from 'vue';
31import { useSearch } from './useSearch';
32import { flushPromises } from '@vue/test-utils';
33
34function mountComposable(searchFn: (q: string) => Promise<string[]>) {
35 let exposed: ReturnType<typeof useSearch>;
36 mount(defineComponent({
37 setup() {
38 exposed = useSearch(searchFn);
39 return exposed;
40 },
41 template: '<div />',
42 }));
43 return exposed!;
44}
45
46describe('useSearch', () => {
47 it('initialises with empty results and loading false', () => {
48 const { results, loading, error } = mountComposable(vi.fn());
49 expect(results.value).toEqual([]);
50 expect(loading.value).toBe(false);
51 expect(error.value).toBe('');
52 });
53
54 it('sets loading to true while search is in progress', async () => {
55 let resolve!: (v: string[]) => void;
56 const searchFn = vi.fn(() => new Promise<string[]>(r => { resolve = r; }));
57 const { query, loading } = mountComposable(searchFn);
58
59 query.value = 'Vue';
60 await nextTick();
61 expect(loading.value).toBe(true);
62
63 resolve(['Vue basics']);
64 await flushPromises();
65 expect(loading.value).toBe(false);
66 });
67
68 it('populates results after successful search', async () => {
69 const searchFn = vi.fn().mockResolvedValue(['Vue 3', 'Nuxt 3']);
70 const { query, results } = mountComposable(searchFn);
71
72 query.value = 'Vue';
73 await flushPromises();
74
75 expect(results.value).toEqual(['Vue 3', 'Nuxt 3']);
76 });
77
78 it('sets error message when search throws', async () => {
79 const searchFn = vi.fn().mockRejectedValue(new Error('Network error'));
80 const { query, error } = mountComposable(searchFn);
81
82 query.value = 'Vue';
83 await flushPromises();
84
85 expect(error.value).toBe('Search failed');
86 });
87});Pinia is Vue's official state management library. @pinia/testing provides createTestingPinia which stubs all store actions and lets you set initial state.
1import { mount } from '@vue/test-utils';
2import { createTestingPinia } from '@pinia/testing';
3import { useCartStore } from '../stores/cart';
4import CartSummary from './CartSummary.vue';
5
6// CartSummary.vue renders cart items from Pinia store
7describe('CartSummary', () => {
8 it('renders items from the cart store', () => {
9 const wrapper = mount(CartSummary, {
10 global: {
11 plugins: [
12 createTestingPinia({
13 initialState: {
14 cart: {
15 items: [
16 { id: '1', name: 'Widget', price: 10, quantity: 2 },
17 { id: '2', name: 'Gadget', price: 25, quantity: 1 },
18 ],
19 },
20 },
21 }),
22 ],
23 },
24 });
25
26 expect(wrapper.text()).toContain('Widget');
27 expect(wrapper.text()).toContain('Gadget');
28 });
29
30 it('calls removeItem action when remove button is clicked', async () => {
31 const wrapper = mount(CartSummary, {
32 global: {
33 plugins: [
34 createTestingPinia({
35 initialState: { cart: { items: [{ id: '1', name: 'Widget', price: 10, quantity: 1 }] } },
36 }),
37 ],
38 },
39 });
40
41 const cart = useCartStore(); // gets the test store instance
42 await wrapper.find('[data-testid="remove-1"]').trigger('click');
43
44 expect(cart.removeItem).toHaveBeenCalledWith('1');
45 });
46
47 it('shows empty cart message when store has no items', () => {
48 const wrapper = mount(CartSummary, {
49 global: {
50 plugins: [createTestingPinia({ initialState: { cart: { items: [] } } })],
51 },
52 });
53 expect(wrapper.text()).toContain('Your cart is empty');
54 });
55});Vue batches DOM updates asynchronously. After triggering events or changing reactive data, you must wait for Vue to process the update queue.
1import { mount, flushPromises } from '@vue/test-utils';
2import { nextTick } from 'vue';
3
4// await wrapper.trigger() automatically calls nextTick
5// For manual reactive updates, call nextTick explicitly
6
7describe('async component patterns', () => {
8 it('updates DOM after reactive data change', async () => {
9 const wrapper = mount({
10 template: '<p>{{ message }}</p>',
11 data() { return { message: 'Hello' }; }
12 });
13
14 wrapper.vm.message = 'World';
15 await nextTick(); // wait for Vue to re-render
16 expect(wrapper.find('p').text()).toBe('World');
17 });
18
19 it('shows loaded data after API call resolves', async () => {
20 const fetchUsersMock = vi.fn().mockResolvedValue([
21 { id: 1, name: 'Alice' }
22 ]);
23
24 const wrapper = mount(UserList, {
25 global: {
26 provide: { fetchUsers: fetchUsersMock }
27 }
28 });
29
30 // Component calls fetchUsers in onMounted
31 await flushPromises(); // settles all pending Promises
32
33 expect(wrapper.findAll('li')).toHaveLength(1);
34 expect(wrapper.find('li').text()).toBe('Alice');
35 });
36
37 it('shows error state when API fails', async () => {
38 const fetchUsersMock = vi.fn().mockRejectedValue(new Error('Server error'));
39
40 const wrapper = mount(UserList, {
41 global: { provide: { fetchUsers: fetchUsersMock } }
42 });
43
44 await flushPromises();
45
46 expect(wrapper.find('[role="alert"]').text()).toContain('Failed to load');
47 });
48});
49
50// Vitest config for Vue (vite.config.ts):
51// test: {
52// environment: 'jsdom',
53// globals: true,
54// setupFiles: ['./src/test-setup.ts'],
55// }
56
57// src/test-setup.ts:
58// import { config } from '@vue/test-utils';
59// config.global.plugins = [createPinia()]; // global Pinia if neededA Vue 3 comment form component tested with mount, trigger, setValue, emitted assertions, and flushPromises for async submission behaviour.
1<!-- src/components/CommentForm.vue -->
2<script setup lang="ts">
3import { ref } from 'vue';
4
5interface Props {
6 postId: string;
7 onSubmit: (postId: string, text: string) => Promise<void>;
8}
9
10const props = defineProps<Props>();
11const emit = defineEmits<{ submitted: [] }>();
12
13const text = ref('');
14const submitting = ref(false);
15const error = ref('');
16const charCount = computed(() => text.value.length);
17const isValid = computed(() => text.value.trim().length >= 5);
18
19async function handleSubmit() {
20 if (!isValid.value) return;
21 submitting.value = true;
22 error.value = '';
23 try {
24 await props.onSubmit(props.postId, text.value.trim());
25 text.value = '';
26 emit('submitted');
27 } catch {
28 error.value = 'Failed to post comment. Please try again.';
29 } finally {
30 submitting.value = false;
31 }
32}
33</script>
34
35<template>
36 <form @submit.prevent="handleSubmit">
37 <label for="comment">Add a comment</label>
38 <textarea
39 id="comment"
40 v-model="text"
41 placeholder="Write your comment..."
42 :disabled="submitting"
43 />
44 <span>{{ charCount }} characters</span>
45 <p v-if="error" role="alert">{{ error }}</p>
46 <button type="submit" :disabled="!isValid || submitting">
47 {{ submitting ? 'Posting...' : 'Post Comment' }}
48 </button>
49 </form>
50</template>
51
52// src/components/CommentForm.test.ts
53import { mount, flushPromises } from '@vue/test-utils';
54import CommentForm from './CommentForm.vue';
55
56describe('CommentForm', () => {
57 const postId = 'post-123';
58
59 it('renders the textarea and submit button', () => {
60 const wrapper = mount(CommentForm, {
61 props: { postId, onSubmit: vi.fn() }
62 });
63 expect(wrapper.find('textarea').exists()).toBe(true);
64 expect(wrapper.find('button[type="submit"]').text()).toBe('Post Comment');
65 });
66
67 it('submit button is disabled when text is fewer than 5 characters', async () => {
68 const wrapper = mount(CommentForm, { props: { postId, onSubmit: vi.fn() } });
69 await wrapper.find('textarea').setValue('Hi');
70 expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeDefined();
71 });
72
73 it('submit button is enabled when text has 5 or more characters', async () => {
74 const wrapper = mount(CommentForm, { props: { postId, onSubmit: vi.fn() } });
75 await wrapper.find('textarea').setValue('Great post!');
76 expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeUndefined();
77 });
78
79 it('shows character count', async () => {
80 const wrapper = mount(CommentForm, { props: { postId, onSubmit: vi.fn() } });
81 await wrapper.find('textarea').setValue('Hello');
82 expect(wrapper.text()).toContain('5 characters');
83 });
84
85 it('calls onSubmit with postId and trimmed text on valid submission', async () => {
86 const onSubmit = vi.fn().mockResolvedValue(undefined);
87 const wrapper = mount(CommentForm, { props: { postId, onSubmit } });
88
89 await wrapper.find('textarea').setValue(' Great article! ');
90 await wrapper.find('form').trigger('submit');
91 await flushPromises();
92
93 expect(onSubmit).toHaveBeenCalledWith('post-123', 'Great article!');
94 });
95
96 it('emits submitted event after successful submission', async () => {
97 const onSubmit = vi.fn().mockResolvedValue(undefined);
98 const wrapper = mount(CommentForm, { props: { postId, onSubmit } });
99
100 await wrapper.find('textarea').setValue('Great post!');
101 await wrapper.find('form').trigger('submit');
102 await flushPromises();
103
104 expect(wrapper.emitted('submitted')).toBeTruthy();
105 });
106
107 it('clears the textarea after successful submission', async () => {
108 const onSubmit = vi.fn().mockResolvedValue(undefined);
109 const wrapper = mount(CommentForm, { props: { postId, onSubmit } });
110
111 await wrapper.find('textarea').setValue('Great post!');
112 await wrapper.find('form').trigger('submit');
113 await flushPromises();
114
115 expect(wrapper.find('textarea').element.value).toBe('');
116 });
117
118 it('shows error message when submission fails', async () => {
119 const onSubmit = vi.fn().mockRejectedValue(new Error('Network error'));
120 const wrapper = mount(CommentForm, { props: { postId, onSubmit } });
121
122 await wrapper.find('textarea').setValue('Great post!');
123 await wrapper.find('form').trigger('submit');
124 await flushPromises();
125
126 expect(wrapper.find('[role="alert"]').text()).toContain('Failed to post comment');
127 });
128
129 it('shows Posting... label and disables textarea while submitting', async () => {
130 let resolveSubmit!: () => void;
131 const onSubmit = vi.fn(() => new Promise<void>(r => { resolveSubmit = r; }));
132 const wrapper = mount(CommentForm, { props: { postId, onSubmit } });
133
134 await wrapper.find('textarea').setValue('Great post!');
135 await wrapper.find('form').trigger('submit');
136 await nextTick();
137
138 expect(wrapper.find('button').text()).toBe('Posting...');
139 expect(wrapper.find('textarea').attributes('disabled')).toBeDefined();
140
141 resolveSubmit();
142 await flushPromises();
143 expect(wrapper.find('button').text()).toBe('Post Comment');
144 });
145});This suite tests 8 distinct behaviours of the CommentForm: initial render, disabled state for short text, enabled state for valid text, character count display, correct onSubmit arguments including trimming, submitted event emission, textarea clearing, error display on failure, and loading state during submission. flushPromises() settles all pending Promises before DOM assertions. Notice the pattern for testing an in-progress state: do not await flushPromises before asserting on the loading state — just use nextTick to let Vue process the synchronous state change.
The final lesson in this module covers unit testing best practices and common anti-patterns: what makes tests brittle, how to detect and fix test interdependence, and the professional discipline of maintaining a test suite as a first-class asset.
Your progress is saved in this browser, on this device only.