Tutorials Logic, IN info@tutorialslogic.com

Testing Vue Apps Vitest Vue Test Utils

Configure Vitest for Vue Components

A component test should exercise public inputs and user-visible behavior rather than private implementation details.

Vitest runs the test modules; Vue Test Utils mounts components and interacts with their rendered wrappers.

Use end-to-end tests for a small number of critical browser journeys that component tests cannot prove.

Use Vitest with the same Vite configuration and aliases as the application. Vue Test Utils mounts Vue components and exposes wrapper queries, events, props, and emitted values. A jsdom or happy-dom environment supplies DOM APIs for component tests; code that depends on full browser layout or navigation belongs in a real-browser test.

Put global cleanup, custom matchers, and stable browser API shims in a setup file. Restore mocks after each test and isolate stateful plugins. Keep watch mode for local feedback and run the deterministic single-run command in continuous integration.

  • Match the Vue plugin and path aliases used by the production Vite build.
  • Fail the CI command on unhandled rejections and test failures.
  • Collect coverage as a gap-finding signal, not as proof that assertions are meaningful.
  • Keep test-only configuration out of component production code.

Vitest + Vue Test Utils - Component Testing

Vitest + Vue Test Utils - Component Testing
# Install testing dependencies
npm install -D vitest @vue/test-utils jsdom @vitest/coverage-v8

# vite.config.js - add test config
# export default defineConfig({
#   plugins: [vue()],
#   test: {
#     environment: 'jsdom',
#     globals: true,
#   }
# })

# package.json scripts
# "test": "vitest",
# "test:run": "vitest run",
# "test:coverage": "vitest run --coverage"

# Run tests
npm run test        # watch mode
npm run test:run    # single run
npm run test:coverage  # with coverage report
Output
vitest run exits successfully when all suites pass; the coverage command also writes the configured coverage report.

Vitest uses the Vite project graph and jsdom supplies browser-like DOM APIs. Keep the test environment and setup files in configuration so every component test starts consistently.

Test Public Component Behavior

Mount the smallest useful component boundary, provide props, find an accessible control or stable test selector, trigger the interaction, and assert what a user or parent component can observe. Prefer text, attributes, enabled state, navigation intent, and emitted events over internal refs or direct method calls.

shallowMount replaces child components with stubs, which can make a focused parent test cheaper but can also hide broken slots, event names, and provide/inject contracts. Use mount by default for a small component tree and stub a child only when the child is expensive or independently tested.

  • Use get when an element must exist and find when absence is an expected assertion.
  • Await trigger, setValue, setProps, and other operations that schedule a Vue render.
  • Assert emitted payloads when the child-to-parent event is the component output.
  • Avoid snapshots that approve large unrelated markup changes without explaining behavior.

Test Counter Behavior Through the DOM

Test Counter Behavior Through the DOM
// Counter.test.js - testing a Counter component
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import Counter from '@/components/Counter.vue'

describe('Counter', () => {
    let wrapper

    beforeEach(() => {
        wrapper = mount(Counter, {
            props: { initialCount: 0 }
        })
    })

    it('renders initial count', () => {
        expect(wrapper.text()).toContain('0')
    })

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

    it('decrements count when - button clicked', async () => {
        // First increment to 1
        await wrapper.find('[data-testid="increment"]').trigger('click')
        // Then decrement
        await wrapper.find('[data-testid="decrement"]').trigger('click')
        expect(wrapper.text()).toContain('0')
    })

    it('resets count when reset button clicked', async () => {
        await wrapper.find('[data-testid="increment"]').trigger('click')
        await wrapper.find('[data-testid="increment"]').trigger('click')
        await wrapper.find('[data-testid="reset"]').trigger('click')
        expect(wrapper.text()).toContain('0')
    })

    it('emits update event when count changes', async () => {
        await wrapper.find('[data-testid="increment"]').trigger('click')
        expect(wrapper.emitted('update')).toBeTruthy()
        expect(wrapper.emitted('update')[0]).toEqual([1])
    })

    it('does not go below 0', async () => {
        await wrapper.find('[data-testid="decrement"]').trigger('click')
        expect(wrapper.text()).toContain('0')
    })

    it('accepts initialCount prop', () => {
        const w = mount(Counter, { props: { initialCount: 10 } })
        expect(w.text()).toContain('10')
    })
})
Output
Seven Counter tests pass when rendering, controls, lower bound, props, and the update event all satisfy the component contract.

The suite mounts the component, triggers the controls a user can reach, awaits Vue updates, and checks rendered text or the public emitted-event contract.

Control Asynchronous Work

Vue batches reactive DOM updates, so await wrapper interactions or nextTick before reading the changed DOM. Promise-based work such as a mocked HTTP response is outside Vue's render queue; flushPromises waits for already-resolved promise handlers before the assertion.

Test loading, success, empty, and failure states separately. Mock at a stable network boundary and return realistic status and payload shapes. A never-resolving promise can hold the loading state, while a rejected promise verifies the error path. Avoid arbitrary sleep calls because they make tests slower without proving the awaited condition occurred.

  • Create a fresh wrapper and mock state for each test unless shared setup is demonstrably immutable.
  • Use fake timers only for timer-owned behavior and advance them explicitly.
  • Assert that stale or aborted requests cannot overwrite newer state when the component supports cancellation.
  • Keep one assertion narrative per state transition so a failure points to the broken contract.

Test Loading, Success, Failure, and Filtering

Test Loading, Success, Failure, and Filtering
// UserList.test.js - testing async component with API
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import UserList from '@/components/UserList.vue'

// Mock fetch
const mockUsers = [
    { id: 1, name: 'Alice', email: 'alice@example.com' },
    { id: 2, name: 'Bob',   email: 'bob@example.com' },
]

describe('UserList', () => {
    beforeEach(() => {
        // Reset mocks before each test
        vi.restoreAllMocks()
    })

    it('shows loading state initially', () => {
        global.fetch = vi.fn(() => new Promise(() => {}))  // never resolves
        const wrapper = mount(UserList)
        expect(wrapper.find('[data-testid="loading"]').exists()).toBe(true)
    })

    it('renders users after successful fetch', async () => {
        global.fetch = vi.fn(() =>
            Promise.resolve({
                ok: true,
                json: () => Promise.resolve(mockUsers)
            })
        )

        const wrapper = mount(UserList)
        await flushPromises()  // wait for all promises to resolve

        expect(wrapper.find('[data-testid="loading"]').exists()).toBe(false)
        expect(wrapper.findAll('[data-testid="user-item"]')).toHaveLength(2)
        expect(wrapper.text()).toContain('Alice')
        expect(wrapper.text()).toContain('Bob')
    })

    it('shows error message on fetch failure', async () => {
        global.fetch = vi.fn(() => Promise.reject(new Error('Network error')))

        const wrapper = mount(UserList)
        await flushPromises()

        expect(wrapper.find('[data-testid="error"]').exists()).toBe(true)
        expect(wrapper.text()).toContain('Network error')
    })

    it('filters users by search query', async () => {
        global.fetch = vi.fn(() =>
            Promise.resolve({ ok: true, json: () => Promise.resolve(mockUsers) })
        )

        const wrapper = mount(UserList)
        await flushPromises()

        const searchInput = wrapper.find('[data-testid="search"]')
        await searchInput.setValue('Alice')

        expect(wrapper.findAll('[data-testid="user-item"]')).toHaveLength(1)
        expect(wrapper.text()).toContain('Alice')
        expect(wrapper.text()).not.toContain('Bob')
    })

    it('deletes user when delete button clicked', async () => {
        global.fetch = vi.fn(() =>
            Promise.resolve({ ok: true, json: () => Promise.resolve(mockUsers) })
        )

        const wrapper = mount(UserList)
        await flushPromises()

        const deleteButtons = wrapper.findAll('[data-testid="delete-user"]')
        await deleteButtons[0].trigger('click')

        expect(wrapper.findAll('[data-testid="user-item"]')).toHaveLength(1)
        expect(wrapper.text()).not.toContain('Alice')
    })
})
Output
The suite verifies loading, two rendered users, a visible network error, filtered results, and deletion behavior.

Each test controls fetch, flushes pending promises when required, and asserts the user-visible state. Resetting mocks prevents one response from leaking into another test.

Install Router, Pinia, and Injected Dependencies

Components that use a real plugin contract should receive that plugin through global.plugins. For Pinia, create a fresh testing store per test and choose whether actions are stubbed or executed. For Vue Router, prefer a memory-history router, push the starting location, and wait for router.isReady before mounting a component that reads the current route.

Use global.provide for injected interfaces and global.mocks only for intentionally mocked instance properties. Keep the fake narrow and behaviorally accurate. If a test requires many unrelated providers, the component may own too many responsibilities or the test boundary may be too broad.

  • Create plugin instances inside beforeEach so state and routes cannot leak between tests.
  • Assert the resulting UI or navigation rather than inspecting a router or store implementation detail.
  • Test one integration with real actions when component behavior depends on action side effects.
  • Use a typed adapter around external services so failure and cancellation behavior can be faked explicitly.

Define the End-to-End Boundary

Component tests are fast and precise, but they do not prove that the production build, browser, server, routing, authentication, and network contracts work together. Cover a small set of high-value journeys with Playwright or Cypress: sign-in, a primary create or purchase flow, authorization boundaries, and recovery from an important failure.

Keep most edge cases at the component or service level and reserve end-to-end tests for cross-system confidence. Seed deterministic data, isolate test accounts, wait on observable UI or network conditions, and retain traces or screenshots on failure. Do not compensate for an unstable environment with long fixed delays.

  • Use unit tests for pure transformations and composables with no meaningful rendered contract.
  • Use component tests for props, slots, events, validation, and state-driven UI.
  • Use end-to-end tests for critical journeys across real application boundaries.
  • Review skipped and quarantined tests as defects with owners and expiry dates.
Before you move on

Testing Vue Apps Vitest Vue Test Utils Mastery Check

5 checks
  • I can configure Vitest and a DOM environment without adding test behavior to production components.
  • I can test a component through props, controls, rendered output, and emitted events.
  • I know when to await a Vue update and when to flush pending promises.
  • I can provide fresh router, Pinia, and injected dependencies without cross-test state leakage.
  • I can place a scenario at the unit, component, or end-to-end level and explain that boundary.

Try this next

Vue JS Testing Skill Drills

0 of 2 completed

  1. Mount a form, submit invalid values, and assert accessible error text. Then enter valid values and assert the exact submit payload emitted to the parent. Interact through inputs and the submit control; do not call the component validation method directly.
  2. Control a service promise and write separate tests for loading, empty, populated, and rejected responses, including the retry action. Use observable state and flushPromises instead of a fixed timeout.

Vue JS Questions Learners Ask

Calling a method proves the method exists; triggering the click proves the template, event binding, emitted update, and rendered result work together. Vue Test Utils is most useful when the test behaves like a user: mount the component, find the control, trigger the event, then assert visible output or emitted events.

Vue batches DOM updates. trigger and setValue schedule reactive updates, but the rendered DOM may not change until the next tick. Awaiting the helper gives Vue time to flush the update before the assertion runs.

Assert emitted events when the component’s job is to notify its parent rather than own the final UI. A child dialog may emit close, a form field may emit update:modelValue, and a tab may emit select.

Browse Free Tutorials

Explore 500+ free tutorials across 20+ languages and frameworks.