Vue Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Testing Vue Components

Vitest and Vue Test Utils: mounting, interacting, mocking fetch and testing composables.

Tests let you change code without fear. In a Vue project the standard toolkit is Vitest (a test runner built on Vite, so it understands .vue files, aliases and TypeScript with no extra setup) and Vue Test Utils (VTU), which mounts components and lets you click, type and inspect them like a user would.

This lesson covers the tests you'll write most often: rendering, user interaction, emitted events, composables and mocked API calls.

The .spec.js code in this lesson runs in a Vite project with Vitest installed (npm install -D vitest @vue/test-utils jsdom). This site loads Vue from a CDN with no build step, so the live demo below uses a tiny look-alike runner with the same mount, find, trigger, emitted and expect calls, run against real mounted components in your browser.

Setting up Vitest

Add a test script and tell Vitest to use a browser-like DOM:

// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
    plugins: [vue()],
    test: {
        environment: 'jsdom',   // a fake DOM, so components can mount in Node
        globals: true,          // describe / it / expect without importing them
    },
});
// package.json
{
    "scripts": {
        "test": "vitest",
        "test:run": "vitest run"
    }
}

npm test watches your files and re-runs only the affected tests on every save. npm run test:run runs everything once, which is the right command for CI.

Tests usually sit next to the component (TaskItem.vue + TaskItem.spec.js) or in a tests/ folder. Vitest finds any *.spec.js or *.test.js file.

Testing what a component renders

mount() renders a component with the props you give it. find() takes a CSS selector and returns a wrapper you can inspect.

<!-- TaskItem.vue -->
<script setup>
defineProps({ task: { type: Object, required: true } });
defineEmits(['toggle', 'remove']);
</script>

<template>
    <li :class="{ done: task.done }">
        <input type="checkbox" :checked="task.done" @change="$emit('toggle', task.id)">
        <span data-test="title">{{ task.title }}</span>
        <button data-test="remove" @click="$emit('remove', task.id)">Remove</button>
    </li>
</template>
// TaskItem.spec.js
import { mount } from '@vue/test-utils';
import TaskItem from './TaskItem.vue';

describe('TaskItem', () => {
    it('renders the task title', () => {
        const wrapper = mount(TaskItem, {
            props: { task: { id: 7, title: 'Write tests', done: false } },
        });

        expect(wrapper.find('[data-test="title"]').text()).toBe('Write tests');
    });

    it('adds the "done" class for completed tasks', () => {
        const wrapper = mount(TaskItem, {
            props: { task: { id: 7, title: 'Ship it', done: true } },
        });

        expect(wrapper.classes()).toContain('done');
    });
});

Select elements with data-test attributes rather than classes or tag names. Classes change when someone restyles the component, but a data-test attribute exists only for tests, so nobody renames it by accident.

Testing user interaction and emitted events

trigger() fires a DOM event and returns a promise. Always await it: Vue updates the DOM asynchronously, and the promise resolves after the re-render. emitted() records every event the component emitted, with its arguments.

it('emits remove with the task id', async () => {
    const wrapper = mount(TaskItem, {
        props: { task: { id: 7, title: 'Write tests', done: false } },
    });

    await wrapper.find('[data-test="remove"]').trigger('click');

    expect(wrapper.emitted('remove')).toHaveLength(1);
    expect(wrapper.emitted('remove')[0]).toEqual([7]);   // the event's arguments
});

it('emits toggle when the checkbox changes', async () => {
    const wrapper = mount(TaskItem, {
        props: { task: { id: 3, title: 'Review PR', done: false } },
    });

    await wrapper.find('input').setValue(true);   // setValue works for inputs, checkboxes and selects

    expect(wrapper.emitted('toggle')[0]).toEqual([3]);
});

Components with internal state are tested the same way: interact, then check the DOM.

// Counter.vue: a +1 button that stops at `max` and shows "Limit reached"
it('stops at max and disables the button', async () => {
    const wrapper = mount(Counter, { props: { max: 2 } });
    const button = wrapper.find('[data-test="inc"]');

    await button.trigger('click');
    await button.trigger('click');
    await button.trigger('click');   // ignored: already at max

    expect(wrapper.find('[data-test="count"]').text()).toBe('Count: 2');
    expect(button.attributes('disabled')).toBeDefined();
    expect(wrapper.find('[data-test="max"]').exists()).toBe(true);
});

Run the tests

These are the tests from this lesson, running against the real components in your browser. Tick the box to introduce a bug into Counter.vue and watch a test catch it:

Testing composables

A composable that doesn't use lifecycle hooks or inject is just a function, so call it and check its return values:

// useCounter.spec.js
import { useCounter } from './useCounter';

it('increments and resets', () => {
    const { count, increment, reset } = useCounter(5);

    increment();
    increment();
    expect(count.value).toBe(7);

    reset();
    expect(count.value).toBe(5);
});

If the composable uses onMounted or inject, it must run inside a component. Mount a throwaway component that calls it:

function withSetup(composable) {
    let result;
    mount({ setup() { result = composable(); return () => null; } });
    return result;
}

const { width } = withSetup(() => useWindowSize());

Mocking API calls

Tests should never hit a real server: they'd be slow and flaky, and would fail whenever the API is down. Replace fetch with a mock that returns exactly the response you want to test:

import { vi } from 'vitest';
import { flushPromises, mount } from '@vue/test-utils';
import TaskList from './TaskList.vue';

it('shows tasks loaded from the API', async () => {
    global.fetch = vi.fn().mockResolvedValue({
        ok: true,
        json: () => Promise.resolve({ data: [{ id: 1, title: 'Design new homepage hero' }] }),
    });

    const wrapper = mount(TaskList);
    await flushPromises();   // wait for the fetch and the re-render

    expect(fetch).toHaveBeenCalledWith('/api/demo/tasks?priority=high');
    expect(wrapper.text()).toContain('Design new homepage hero');
});

it('shows an error message when the API fails', async () => {
    global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });

    const wrapper = mount(TaskList);
    await flushPromises();

    expect(wrapper.find('[role="alert"]').text()).toContain('Could not load tasks');
});

flushPromises() waits for every pending promise to settle. Use it after anything asynchronous that isn't a trigger().

Test the error path as well as the happy path. Mocks make it easy to simulate a 500, a 422 or a network failure, and those are the states real users hit and nobody checks by hand.

Testing with Pinia and the router

Components that use a store or the router need those installed. VTU's global.plugins option takes the same plugins as app.use():

import { createTestingPinia } from '@pinia/testing';

const wrapper = mount(TaskBoard, {
    global: {
        plugins: [createTestingPinia({ initialState: { tasks: { items: [] } } })],
        stubs: { RouterLink: true },   // render <RouterLink> as a harmless stub
    },
});

createTestingPinia() gives each test a fresh store and replaces every action with a spy, so you can assert that the component called store.save() without the action doing any work.

What to test, and what not to

Test Skip
What the user sees for given props Internal variable names
What happens when they click or type That ref() is reactive (Vue's job)
Events emitted, with their arguments Exact CSS or markup that doesn't affect behaviour
Loading, empty and error states Third-party components' internals
Composables' inputs and outputs Implementation details you might refactor

Test behaviour, not implementation. If you can refactor a component's internals without breaking its tests, the tests are checking the right things.

Key takeaways

  • Vitest + Vue Test Utils is the standard stack. Vitest reuses your Vite config, so .vue files just work.
  • mount() with props, find() with data-test selectors, then assert on text(), classes() and attributes().
  • Always await trigger() and setValue(). Use flushPromises() after async work like fetch.
  • emitted('name') returns every emission with its arguments, which is how you test a component's outputs.
  • Mock fetch (or your API module) with vi.fn() and test both success and failure responses.
  • Test behaviour the user can observe, not implementation details.