Testing Interaction
Trigger an event, await the update, set props from the parent, and assert on what the component emitted.
await wrapper.get('button').trigger('click') // same as: trigger(...); await nextTick()A rendering test proves the component draws the right thing once. An interaction test proves it draws the right thing after something happens: a click, a keypress, a new prop, a form submit.
Triggering and waiting
trigger(event) dispatches a DOM event on an element and returns a promise. The promise matters. Vue does not touch the DOM the moment state changes; it queues the update and applies it on the next tick, so that ten changes cause one render rather than ten. Assert immediately after triggering and you assert against the old DOM.
So await wrapper.get('button').trigger('click'). The long form is the same thing spelled out: dispatch the event, then await nextTick() yourself. setValue(value) for inputs and setProps(props) both return promises for the same reason.
Props come from outside
setProps is how a test plays the parent. It is the only honest way to check that a component reacts to a prop changing, because the component itself must never assign to a prop.
Emits
emitted() returns an object keyed by event name, where each value is an array of argument arrays, in order. emitted('save') returns just that array, or undefined if the event never fired, which is what you assert when a guard should have stopped it.
Example
// src/components/__tests__/CartLine.interaction.spec.ts
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import CartLine from '../CartLine.vue'
function mountLine(quantity: number) {
return mount(CartLine, {
props: { name: 'Keyboard', unitPrice: 49.5, quantity },
})
}
describe('CartLine interaction', () => {
it('emits the next quantity when plus is pressed', async () => {
const wrapper = mountLine(2)
await wrapper.get('[aria-label="Increase quantity"]').trigger('click')
expect(wrapper.emitted('update:quantity')).toEqual([[3]])
})
it('will not emit below one', async () => {
const wrapper = mountLine(1)
await wrapper.get('[aria-label="Decrease quantity"]').trigger('click')
expect(wrapper.emitted('update:quantity')).toBeUndefined()
})
it('redraws the total when the parent sends a new quantity', async () => {
const wrapper = mountLine(1)
await wrapper.setProps({ quantity: 4 })
expect(wrapper.get('[data-testid="total"]').text()).toBe('198.00')
})
})When to use it
- Check a submit button stays disabled until the form is valid.
- Prove a dialog closes on Escape as well as on the close button.
- Confirm a quantity control emits an update instead of writing to its prop.
- Catch the day someone renames an emitted event.
More examples
Why the await is not optional
The first test documents the trap by asserting the stale state on purpose, then awaiting nextTick to get the real one. Nobody should write it that way, but seeing the false assertion pass explains the failure you will otherwise spend twenty minutes on. The second test is the form to use: awaiting the promise trigger already returns does the nextTick for you.
// src/components/__tests__/DetailsToggle.spec.ts
import { expect, it } from 'vitest'
import { nextTick } from 'vue'
import { mount } from '@vue/test-utils'
import DetailsToggle from '../DetailsToggle.vue'
it('shows the body only after the update is flushed', async () => {
const wrapper = mount(DetailsToggle, { props: { summary: 'Shipping' } })
const button = wrapper.get('button')
button.trigger('click') // no await: the event fired, the DOM has not caught up
expect(wrapper.find('[data-testid="body"]').exists()).toBe(false)
await nextTick()
expect(wrapper.find('[data-testid="body"]').exists()).toBe(true)
})
it('reads better with the promise awaited', async () => {
const wrapper = mount(DetailsToggle, { props: { summary: 'Shipping' } })
await wrapper.get('button').trigger('click')
expect(wrapper.find('[data-testid="body"]').exists()).toBe(true)
})Typing into a form and reading the emit
setValue writes the value and dispatches the input event, which is what v-model listens for, so this is how you simulate typing. Triggering submit on the form rather than clicking the button also covers the user who presses Enter in the field. The second test shows the shape of emitted(): an array of argument arrays in the order they fired, which is how you assert that nothing double-submitted.
// src/components/__tests__/SearchBox.spec.ts
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import SearchBox from '../SearchBox.vue'
describe('SearchBox', () => {
it('emits the trimmed term on submit', async () => {
const wrapper = mount(SearchBox)
await wrapper.get('input[type="search"]').setValue(' keyboards ')
await wrapper.get('form').trigger('submit')
expect(wrapper.emitted('search')).toEqual([['keyboards']])
})
it('emits once per submit, in order', async () => {
const wrapper = mount(SearchBox)
const input = wrapper.get('input[type="search"]')
const form = wrapper.get('form')
await input.setValue('mice')
await form.trigger('submit')
await input.setValue('monitors')
await form.trigger('submit')
expect(wrapper.emitted('search')).toEqual([['mice'], ['monitors']])
})
})Asserting a v-model update
ToggleSwitch uses defineModel, stable since Vue 3.4, so its contract with the parent is a modelValue prop plus an update:modelValue event. Passing an onUpdate:modelValue handler that calls setProps makes the test behave like a real parent with v-model: the value actually comes back down, so you can assert both the emit and the resulting DOM state. Without that handler the checkbox would snap back and the second assertion would fail.
// src/components/__tests__/ToggleSwitch.spec.ts
import { expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import ToggleSwitch from '../ToggleSwitch.vue'
it('reports the flipped value to its parent', async () => {
const wrapper = mount(ToggleSwitch, {
props: {
modelValue: false,
label: 'Email receipts',
'onUpdate:modelValue': (value: boolean) => wrapper.setProps({ modelValue: value }),
},
})
await wrapper.get('input[type="checkbox"]').setValue(true)
expect(wrapper.emitted('update:modelValue')).toEqual([[true]])
expect(wrapper.get<HTMLInputElement>('input').element.checked).toBe(true)
})
Discussion