Mount a component and assert the DOM the user actually sees
You changed the discount logic in PriceButton, and the pull request needs evidence. The evidence is not "the helper returns 12.34" — it is "the rendered button says €12.34, carries the on-sale class, and exposes an aria-label a screen reader can announce." A component test produces that kind of evidence by rendering the component for real and querying the DOM it produces.
@vue/test-utils gives you mount(Component, options?). It creates a Vue application, runs setup(), renders the template into a detached <div>, and hands back a wrapper — an object whose query methods all start from the component's root element:
import { mount } from '@vue/test-utils'
import PriceButton from '@/components/PriceButton.vue'
test('renders a discounted price with its a11y label', () => {
const wrapper = mount(PriceButton, {
props: { price: 12.34, onSale: true },
})
expect(wrapper.text()).toContain('€12.34')
expect(wrapper.classes()).toContain('on-sale')
expect(wrapper.attributes('aria-label')).toBe('Price: 12.34 euros')
wrapper.unmount()
})
Notice what the test never mentions: internal refs, computed names, or anything exposed through defineExpose. It asserts the contract that ships to the browser. That discipline is what keeps component tests alive through refactors — rename the internal price ref to amountCents and this test still passes, because the rendered output did not change. Tests that reach through wrapper.vm couple to implementation details, and they break the moment you restructure a component without changing its behavior. This is the single most important habit in this topic.
The query API is small, and each method returns something specific:
| Method | Returns | Reads | Typical use |
|---|---|---|---|
text() |
string | root's textContent |
assert user-visible copy |
html() |
string | outer markup | inspect structure, snapshot a subtree |
find(selector) |
wrapper | first matching descendant | drill into a child element |
findAll(selector) |
wrapper array | every match | count rendered rows |
exists() |
boolean | presence | prove a node disappeared |
classes() / attributes(name) |
array / string | root element | CSS state, ARIA labels |
A wrapper also exposes the raw node as wrapper.element, which is the escape hatch for measurements — getBoundingClientRect() for a tooltip's position, for example — without leaving the test's subject.
Two details bite people early. First, find() returns a wrapper object even when nothing matched, so find(...).text() fails later with an unhelpful Cannot read properties of undefined. Use get() instead: it returns the wrapper or throws an error that names the selector, which turns a missing element into a readable test failure. Second, text() reads textContent, so it concatenates text from hidden children as well as visible ones — when visibility matters, assert on the specific node the user reads rather than the whole root.
mount renders the full tree: children are real components, slots are filled, and a broken prop type inside a child surfaces as a console warning during the test. That is usually what you want, because your component's behavior depends on what its children render. When the tree is heavy or a child drags in a network client or a browser API, you isolate instead — shallowMount stubs every child automatically, and mount(Comp, { global: { stubs: {...} } }) stubs selected ones; Mocking Modules and Stubbing Children covers that trade-off in detail.
With Vitest configured in Configuring Vitest, every test file now starts the same way: import mount, render, query, assert on what the user would see. Once the event handlers get involved, the next question is what to wait for — that is exactly what Testing Async Updates answers.
Drive the component: set values, fire events, and check what it emitted
A passive render test proves the markup exists. Most components exist to react, so the second style of test acts like a user: type into the field, submit the form, click the button — then assert the new DOM or the payload the component emitted to its parent.
import { mount } from '@vue/test-utils'
import TodoForm from '@/components/TodoForm.vue'
test('emits add with the typed title on submit', async () => {
const wrapper = mount(TodoForm)
await wrapper.get('input[name="title"]').setValue('write tests')
await wrapper.get('form').trigger('submit')
expect(wrapper.emitted('add')).toHaveLength(1)
expect(wrapper.emitted('add')[0]).toEqual(['write tests'])
expect(wrapper.find('ul li').text()).toBe('write tests')
})
setValue() assigns element.value and dispatches the events v-model listens for, so reactive state updates exactly as it would when a user types. trigger('submit') dispatches a real DOM event that runs your @submit.prevent handler. Both return promises tied to Vue's update cycle — await them and the assertion afterwards sees fresh markup. When the handler kicks off async work, waiting for the correct queue becomes a separate problem, and Testing Async Updates is where that belongs.
emitted() deserves special attention. Vue Test Utils records every event the root component emits as an array of payload arrays, so wrapper.emitted('add')[0][0] is the first argument of the first call. Asserting the emitted payload is the cleanest way to test a presentational component: the emit signature is its public API toward the parent, and asserting it does not care how the component computed the value internally. Note that emitted() only captures events from the root of what you mounted — to observe a child's events, mount the child or use findComponent().
Class and attribute assertions cover the other half of the user-visible contract. A disabled button, aria-expanded="true" on an open menu, an is-invalid class after failed validation — these drive CSS, screen readers, and user feedback:
import { mount } from '@vue/test-utils'
import QuantityPicker from '@/components/QuantityPicker.vue'
test('locks at the minimum and announces why', async () => {
const wrapper = mount(QuantityPicker, { props: { min: 1, quantity: 1 } })
const dec = wrapper.get('[data-test="decrement"]')
expect(dec.attributes('disabled')).toBeDefined()
await dec.trigger('click')
expect(wrapper.classes()).toContain('at-min')
expect(wrapper.text()).toContain('Minimum reached')
})
Watch the boolean-attribute trap: a disabled button reports '' from attributes('disabled'), not 'true'. The assertion that survives is toBeDefined() — or toBe('') — instead of a truthy string comparison. For CSS state, prefer classes() over string-matching html(): it keeps passing when unrelated classes are reordered or added.
Choosing what to assert decides how brittle the suite becomes:
| Assert on | Use it when | Fragility |
|---|---|---|
text() of a specific node |
copy the user reads | low |
classes() / attributes() |
CSS state or accessibility | low |
emitted('name') |
component's parent contract | low |
html() snapshots |
structure of a complex subtree | medium — any markup edit fails it |
wrapper.vm.someRef |
nothing, in a component test | high — coupled to internals |
The rule that ties it together: assert the output contract — markup, attributes, emitted events — and leave internal state alone. If you find yourself writing expect(wrapper.vm.counter).toBe(2) to prove a behavior, keep driving the UI until the rendered result proves the counter moved; that test then also covers the wiring between state and template, which is where real regressions hide.
None of these assertions is meaningful if the handler's work lands after you assert. The mounting and query discipline from the previous chapters, combined with the wait discipline from Testing Async Updates, is what makes event-driven tests deterministic instead of flaky.
Give one test its own plugins, mocks, and stubs with the global option
mount() accepts a global option: per-test registration of plugins, stubs, mocked instance properties, provide values, and local components. It is how you give one test exactly the environment it needs — and how the most common cross-test leakage bug sneaks in when you reach for the module-level alternative instead.
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import Header from '@/components/Header.vue'
test('shows the cart badge for the active route', () => {
const wrapper = mount(Header, {
global: {
plugins: [createTestingPinia()],
stubs: { 'router-link': true },
mocks: { $route: { path: '/cart' } },
provide: { currency: 'EUR' },
},
})
expect(wrapper.get('.badge').text()).toBe('3')
})
Each key does a different job, and mixing them up is the classic mistake:
global key |
Registers | Use for |
|---|---|---|
plugins |
Vue plugins (Pinia, router, i18n) | real behavior the component depends on |
mocks |
instance properties ($route, $toast) |
globals the app's plugin layer would provide |
stubs |
replacement components | children that are expensive or irrelevant here |
provide |
provide()/inject() values |
composables that inject tokens |
components |
extra component registrations | wrappers used only in this test's template |
Everything under global lives for that one mount() call and disappears with it, which is exactly why the option exists: component tests should declare their dependencies instead of inheriting whatever the app wires up globally.
The contrast is config.global, exported by @vue/test-utils — module-level state that mutates the defaults for every subsequent mount in the process. Setting config.global.mocks = { $route: {...} } in one test file leaks into every mount that runs afterwards, including tests in the same file that expected no route mock. If you do use it (say, to stub one obnoxious child in every file), reset it:
import { config } from '@vue/test-utils'
const originals = { ...config.global }
afterEach(() => {
config.global = originals
})
Put that reset in a shared setup file rather than trusting each author to remember it. Prefer per-mount global as the default; reach for config.global only for genuinely universal decisions.
Choosing between mocks and plugins for the router shows the deeper trade-off. Mocking $route proves the component renders the right state for a given path, but it proves nothing about navigation — a typo in your path: in the real router config would still pass. Mounting the real router proves more:
import { createRouter, createMemoryHistory } from 'vue-router'
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/cart', component: CartView }],
})
await router.push('/cart')
await router.isReady()
const wrapper = mount(Header, { global: { plugins: [router] } })
Pick the lighter option when the test's claim is about rendering, and the real plugin when the claim involves navigation, guards, or plugin-provided behavior. The claim decides the machinery.
One more environmental detail: by default the component mounts into a detached element, invisible to the document. Tests that touch focus, blur, scroll positions, or document-level listeners need the node in the DOM:
const wrapper = mount(MenuTrigger, { attachTo: document.body })
// ... run assertions, then detach so nodes do not accumulate
wrapper.unmount()
attachTo leaks nodes within a file if you skip cleanup, and stale nodes make later document.querySelector calls ambiguous.
You now have the full mounting vocabulary: render with Configuring Vitest-driven tooling, query and assert what the user sees, and scope environment per test. Handlers that fetch, debounce, or time out change one thing — what you await before asserting — which is precisely the subject of Testing Async Updates.
Quiz
1. What does mount(PriceButton, { props }) return, and where do its query methods start?
2. A test calls wrapper.find('#missing').text() after the element stopped rendering and dies with 'Cannot read properties of undefined'. What change makes the failure readable?
3. After clicking submit once, what does wrapper.emitted('add')[0] hold?
4. config.global.mocks = { $route } set in one test file leaks into mounts in later files. Why does that happen?
Flashcards
Question
What does mount() return, and from where do its queries resolve?
Click to reveal answer
Answer
A wrapper around the component's root DOM element; text(), find(), classes(), attributes() and friends all start from that root, which is why assertions stay on rendered output instead of internal state.
Question
Why prefer wrapper.get() over find(...).text() when the element must exist?
Click to reveal answer
Answer
find() returns a wrapper even with no match, so the failure surfaces later as an undefined-property error; get() throws immediately with an error naming the missing selector.
Question
Difference between text() and html() on a wrapper?
Click to reveal answer
Answer
text() returns the root's textContent — concatenated text, including hidden children; html() returns the full serialized markup, useful for structure checks but brittle for copy assertions.
Question
How do mount's global option and config.global differ in scope?
Click to reveal answer
Answer
global (plugins, stubs, mocks, provide, components) applies only to that one mount call; config.global mutates module-level defaults that affect every later mount in the process unless restored.
Question
How do you assert what a component told its parent?
Click to reveal answer
Answer
wrapper.emitted('add') returns an array of payload arrays — one entry per occurrence; emitted('add')[0][0] is the first call's first argument. Child emissions require findComponent(...).vm.$emit or mounting the child itself.
Revision Notes
Key Takeaways
- 1. mount renders for real and returns a wrapper anchored at the root — assert the DOM contract, not wrapper.vm internals, so refactors don't break green tests
- 2. find() silently returns an empty wrapper; get() fails loudly with the selector, which turns missing elements into readable failures
- 3. emitted() records the root's emits as arrays of payload arrays — the emit signature is the component's public API toward its parent
- 4. mount's global option (plugins, stubs, mocks, provide) is scoped to one call; config.global is module-level and leaks across files unless reset
- 5. mount runs the whole tree, shallowMount stubs every child — the choice is decided by which children the test's claim depends on
Interview Tips
- • "How do you test a component without coupling to its internals?" — assert rendered text, classes, attributes, and emitted events; say plainly that wrapper.vm assertions break on refactors that don't change behavior
- • "What's the difference between find and get?" — find returns a possibly-empty wrapper and defers failure; get throws immediately naming the selector, so use get when absence is a test failure
- • "How do you give one test a router or store?" — mount's global option: real plugins when the claim involves navigation, mocks when the claim is only about rendering; mention restoring config.global if you mutate it
- • "How would you test a form submission?" — setValue for v-model correctness, await trigger('submit') for the handler, then assert both the emitted payload and the visible result
Cheat Sheet
Mounting Components in Tests
Render and query:
const wrapper = mount(Comp, { props: { price: 12.34 } })
expect(wrapper.get('[data-test="buy"]').text()).toContain('12.34')
expect(wrapper.classes()).toContain('on-sale')
expect(wrapper.attributes('aria-label')).toBeDefined()
| Method | Returns | Use for |
|---|---|---|
text() |
string (textContent) |
user-visible copy |
html() |
string | structure / snapshots |
find(sel) |
wrapper (may be empty) | drill into a child |
get(sel) |
wrapper or throw | required element |
findAll(sel) |
wrapper array | counting rows |
classes() / attributes(n) |
array / string | CSS state, ARIA |
emitted('e') |
payload arrays | parent contract |
Events: await input.setValue(x) and await wrapper.get('form').trigger('submit') — both wait for Vue's update before your next assertion.
Per-test environment:
mount(Header, { global: { plugins: [createTestingPinia()],
stubs: { 'router-link': true }, mocks: { $route }, provide: { currency: 'EUR' } } })
Prefer per-mount global; if you mutate config.global, restore it in a shared afterEach. Use attachTo: document.body only for focus/scroll/document tests, and always unmount().