Testing Components
Mount a component with Vue Test Utils, find elements by accessible name or test id, and assert on what rendered.
const wrapper = mount(Component, { props: { /* ... */ } })Mounting renders a component into the jsdom document and hands back a wrapper: an object with methods for finding elements and reading what they contain. mount comes from @vue/test-utils, the official testing library for Vue components.
mount or shallowMount
mount renders child components for real, so you test the component together with what it composes. shallowMount(Component) replaces every child with an empty placeholder element named after it, such as <message-row-stub>. It is shorthand for mount(Component, { shallow: true }) and the two are the same call. Prefer mount; reach for shallow rendering when a child is slow, hits the network, or drags half the app in with it.
Finding elements
find(selector) returns a wrapper that may be empty, so you can ask exists(). get(selector) throws when nothing matches, which gives a better failure message when the element is meant to be there. findAll(selector) returns an array.
Selectors are CSS. Pick something a user could perceive: [aria-label="Increase quantity"], button[type="submit"], a visible heading. Add data-testid only when there is nothing perceivable to aim at. Test Utils has no accessible-name query of its own, so if you want getByRole('button', { name: 'Save' }) you want @testing-library/vue.
Asserting
text() gives the rendered text content, html() the markup. Assert on the one value the test is about, not on a whole html() string: that version of the test breaks every time anyone touches the markup.
Example
// src/components/__tests__/CartLine.spec.ts
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import CartLine from '../CartLine.vue'
function mountLine(quantity = 2) {
return mount(CartLine, {
props: { name: 'Keyboard', unitPrice: 49.5, quantity },
})
}
describe('CartLine', () => {
it('shows the line total for the quantity', () => {
const wrapper = mountLine(2)
expect(wrapper.get('[data-testid="total"]').text()).toBe('99.00')
})
it('shows the name it was given', () => {
const wrapper = mountLine()
expect(wrapper.text()).toContain('Keyboard')
})
it('offers a labelled button in each direction', () => {
const wrapper = mountLine()
expect(wrapper.find('[aria-label="Increase quantity"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="Decrease quantity"]').exists()).toBe(true)
expect(wrapper.findAll('button')).toHaveLength(2)
})
})When to use it
- Check that a price or total is formatted the way the design says.
- Assert an empty-state message appears when a list prop is empty.
- Confirm a row renders once per item without rendering an expensive child.
More examples
The component under test
src/components/CartLine.vue. It draws a name, a minus button, the quantity, a plus button and the line total; clicking a button emits the next quantity rather than writing to the prop, because props are read-only. The emits declaration uses the tuple form, 'update:quantity': [value: number], which is the same thing as the older call-signature form (e: 'update:quantity', value: number): void. Both type-check identically; the tuple form is shorter and is what newer code tends to use.
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
name: string
unitPrice: number
quantity: number
}>()
const emit = defineEmits<{
'update:quantity': [value: number]
}>()
const total = computed(() => props.unitPrice * props.quantity)
function step(by: number) {
const next = props.quantity + by
if (next >= 1) emit('update:quantity', next)
}
</script>
<template>
<div class="line">
<p>{{ name }}</p>
<button type="button" aria-label="Decrease quantity" @click="step(-1)">-</button>
<span data-testid="quantity">{{ quantity }}</span>
<button type="button" aria-label="Increase quantity" @click="step(1)">+</button>
<p data-testid="total">{{ total.toFixed(2) }}</p>
</div>
</template>
<style scoped>
.line {
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>shallowMount, to keep a child out of the way
The first two tests are identical: shallowMount is an alias for mount with shallow: true. Shallow rendering is useful when MessageRow is expensive or fetches on its own, and the test is only about how many rows the list produces. The cost is visible in the third test: the subject text lives inside the child, so with the child stubbed there is nothing to assert against. Mount for behaviour, shallow for structure.
// src/components/__tests__/MessageList.spec.ts
import { expect, it } from 'vitest'
import { mount, shallowMount } from '@vue/test-utils'
import MessageList from '../MessageList.vue'
const messages = [
{ id: 1, subject: 'Invoice' },
{ id: 2, subject: 'Receipt' },
]
it('renders one row per message without rendering the rows', () => {
const wrapper = shallowMount(MessageList, { props: { messages } })
expect(wrapper.findAll('message-row-stub')).toHaveLength(2)
})
it('is the same as passing shallow: true', () => {
const wrapper = mount(MessageList, { props: { messages }, shallow: true })
expect(wrapper.findAll('message-row-stub')).toHaveLength(2)
})
it('renders the subjects for real when children are mounted', () => {
const wrapper = mount(MessageList, { props: { messages } })
expect(wrapper.text()).toContain('Invoice')
})Querying by accessible name
@testing-library/vue is an alternative front end over the same mounting machinery, and it re-exports the DOM Testing Library queries. getByRole finds the element the way assistive technology would: by role plus accessible name. That makes the test fail if someone removes the aria-label, which is exactly the regression you want caught. You can keep both libraries in one project; use this one where accessibility is part of the contract.
// src/components/__tests__/CartLine.a11y.spec.ts
import { expect, it } from 'vitest'
import { render, screen } from '@testing-library/vue'
import CartLine from '../CartLine.vue'
it('exposes the increase control as a named button', () => {
render(CartLine, {
props: { name: 'Keyboard', unitPrice: 49.5, quantity: 2 },
})
const increase = screen.getByRole('button', { name: 'Increase quantity' })
expect(increase).toBeTruthy()
expect(screen.getByTestId('total').textContent).toBe('99.00')
})
Discussion