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.
# 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
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.
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.
// 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')
})
})
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.
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.
// 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')
})
})
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.
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.
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.
Try this next
0 of 2 completed
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.
Explore 500+ free tutorials across 20+ languages and frameworks.