Skip to content
TutorialTesting

Testing props, emits and slots

A component's contract is its props, its events and its slots. Here is one small test for each, written with Vitest 3.1 and Vue Test Utils 2.4, plus the await people forget.

vuejs.school team29 May 20256 min readVitest 3.1 · Vue Test Utils 2.4

A component test should read like the component's documentation: given these props, this is on screen; after this click, this event with this payload; whatever you put in this slot shows up here. Vue Test Utils (VTU), the official testing library for Vue components, has a mounting option or a method for each of those sentences.

The examples assume Vitest already runs component tests in your project, with environment: 'happy-dom' in its config: happy-dom gives the tests a browser-like document without starting a browser.

The component under test

Most of the tests mount a quantity picker. It does not own its value: the number comes in as the value prop, and the next one goes out as a change event for the parent to store. Its scoped hint slot receives remaining, how many more the user can add.

src/components/QuantityPicker.vue
<script setup lang="ts">
const props = withDefaults(defineProps<{
  value: number
  max?: number
  label?: string
}>(), { max: 10, label: 'Quantity' })

const emit = defineEmits<{ change: [value: number] }>()

function step(delta: number) {
  const next = Math.min(props.max, Math.max(0, props.value + delta))
  if (next !== props.value) emit('change', next)
}
</script>

<template>
  <div class="picker">
    <span class="label">{{ label }}</span>
    <button type="button" aria-label="Decrease" :disabled="value === 0" @click="step(-1)">-</button>
    <output>{{ value }}</output>
    <button type="button" aria-label="Increase" :disabled="value === max" @click="step(1)">+</button>
    <slot name="hint" :remaining="max - value" />
  </div>
</template>

step() keeps the next value between 0 and max, and emits only when the value would change.

Props: mount with them, assert on the output

mount() takes the component and an options object; props sets the initial props. Then assert on what the prop does, not on the prop itself. A test that checks wrapper.props('value') on the mounted component only reads back what you passed in.

src/components/__tests__/QuantityPicker.spec.ts
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import QuantityPicker from '../QuantityPicker.vue'

describe('QuantityPicker', () => {
  it('shows the value and the default label', () => {
    const wrapper = mount(QuantityPicker, { props: { value: 3 } })
    expect(wrapper.get('output').text()).toBe('3')
    expect(wrapper.get('.label').text()).toBe('Quantity')
  })

  it('disables the increase button at the maximum', () => {
    const wrapper = mount(QuantityPicker, { props: { value: 5, max: 5 } })
    expect(wrapper.get('[aria-label="Increase"]').attributes('disabled')).toBeDefined()
  })
})

The later QuantityPicker tests go inside the same describe block.

get() throws a readable error when the selector matches nothing; find() returns an empty wrapper that you have to check with exists(). Use get() for elements that must be there, find() only when absence is the thing you are testing.

Change a prop with setProps, then await the update

A prop that is read once in setup() and copied into a local variable will render correctly on mount and then never update. setProps() catches that by changing a prop after mount.

It is also where a common mistake shows up. The test below is not wrong about the component. It is wrong about time:

it('shows the new value', () => {
  const wrapper = mount(QuantityPicker, { props: { value: 1 } })
  wrapper.setProps({ value: 2 })
  expect(wrapper.get('output').text()).toBe('2')
  // AssertionError: expected '1' to be '2'
})

setProps() has changed the prop, but the DOM is still the old one. Vue batches updates and applies them in a microtask, which runs only after the test's synchronous code, so the assertion runs first. setProps() returns a promise that resolves after that update. Make the test async and await the call:

it('shows the new value', async () => {
  const wrapper = mount(QuantityPicker, { props: { value: 1 } })
  await wrapper.setProps({ value: 2 })
  expect(wrapper.get('output').text()).toBe('2')
})

The same goes for trigger() and setValue(): both return a promise, and both need the await before any assertion on the DOM. Two helpers cover updates that VTU did not start:

  • await nextTick() from vue, when the change comes from code you ran by hand, say a child's event you emitted yourself (the cart tests below).
  • await flushPromises() from VTU, when the component waits on a promise, such as a mocked fetch. It runs the handlers of every promise that has already resolved, which one tick does not.

Emits: read the payload

wrapper.emitted() returns every event the component emitted since mount, keyed by name. Each entry is an array with one item per emission, and each item is the array of arguments. So one change event carrying 4 is [[4]], and the full shape is worth asserting, because it checks the count and the payload in one go.

it('emits change with the next value', async () => {
  const wrapper = mount(QuantityPicker, { props: { value: 3 } })
  await wrapper.get('[aria-label="Increase"]').trigger('click')
  expect(wrapper.emitted('change')).toEqual([[4]])
})

it('does not emit when already at zero', async () => {
  const wrapper = mount(QuantityPicker, { props: { value: 0 } })
  await wrapper.get('[aria-label="Decrease"]').trigger('click')
  expect(wrapper.emitted('change')).toBeUndefined()
})

trigger('click') dispatches a real DOM event, and it respects disabled: VTU skips the dispatch on a disabled element, so the second test passes for the same reason a user could not click the button.

emitted('change') is undefined when the event never fired, which is why the assertion is toBeUndefined() rather than toHaveLength(0). The same method covers update:modelValue and any other name; nothing about v-model events is special here.

Pass slot content as a string or a render function

The slots mounting option takes the slot name as the key. The simplest value is a string of template; VTU compiles it, so Vue syntax works inside. For a scoped slot, wrap the string in a <template> with the slot's name and destructure the props it passes:

it('renders the hint slot with the remaining count', () => {
  const wrapper = mount(QuantityPicker, {
    props: { value: 3, max: 5 },
    slots: {
      hint: '<template #hint="{ remaining }">{{ remaining }} left</template>',
    },
  })
  expect(wrapper.text()).toContain('2 left')
})

A render function is the other option, and it is better when the slot content needs to be checked precisely, because there is no string to parse. The function receives the slot props and returns a VNode from h(), whose import goes at the top of the spec file:

import { h } from 'vue'

it('passes remaining to a render function slot', () => {
  const wrapper = mount(QuantityPicker, {
    props: { value: 3, max: 5 },
    slots: {
      hint: ({ remaining }: { remaining: number }) => h('em', `${remaining} left`),
    },
  })
  expect(wrapper.get('em').text()).toBe('2 left')
})

A default slot uses the key default. Named slots that take no props can be a plain string, header: 'Hello', or an imported component.

Stub a child the test does not need

When the component under test renders a child that does something heavy, or something that needs a browser, replace the child with a stub. Here CartLine renders a product image, a QuantityPicker and a total:

src/components/CartLine.vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import ProductImage from './ProductImage.vue'
import QuantityPicker from './QuantityPicker.vue'

const props = defineProps<{ product: { id: number; name: string; price: number } }>()

const quantity = ref(1)
const total = computed(() => (props.product.price * quantity.value).toFixed(2))
</script>

<template>
  <li>
    <ProductImage :product-id="product.id" />
    <QuantityPicker :value="quantity" @change="quantity = $event" />
    <span class="total">£{{ total }}</span>
  </li>
</template>

ProductImage can be any component with a productId prop; with a stub in its place, its setup and template never run. The stub is declared under global.stubs, and findComponent() on the real picker lets the test fire the child's event without caring which button produces it:

src/components/__tests__/CartLine.spec.ts
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import { nextTick } from 'vue'
import CartLine from '../CartLine.vue'
import QuantityPicker from '../QuantityPicker.vue'

it('recalculates the total when the picker emits change', async () => {
  const wrapper = mount(CartLine, {
    props: { product: { id: 1, name: 'Tea', price: 4 } },
    global: { stubs: { ProductImage: true } },
  })

  wrapper.findComponent(QuantityPicker).vm.$emit('change', 3)
  await nextTick()

  expect(wrapper.get('.total').text()).toBe('£12.00')
})

ProductImage: true renders an empty <product-image-stub> element in its place, with the props as attributes. shallowMount() would stub every child, including the picker, which is more than this test wants; prefer naming the one child you need out of the way.

Check what the parent passes down

A stub also lets you test the other half of the parent's contract: what it hands to its children. findComponent() matches a stub as well as the real component, and props() on the result returns what the parent bound, because a stub keeps the original component's prop definitions.

These tests also go in CartLine.spec.ts, with ProductImage imported next to the other components:

import ProductImage from '../ProductImage.vue'

it('passes the product id to the image', () => {
  const wrapper = mount(CartLine, {
    props: { product: { id: 7, name: 'Tea', price: 4 } },
    global: { stubs: { ProductImage: true } },
  })
  expect(wrapper.findComponent(ProductImage).props('productId')).toBe(7)
})

it('feeds the new quantity back into the picker', async () => {
  const wrapper = mount(CartLine, {
    props: { product: { id: 1, name: 'Tea', price: 4 } },
    global: { stubs: { ProductImage: true } },
  })
  const picker = wrapper.findComponent(QuantityPicker)
  expect(picker.props('value')).toBe(1)

  picker.vm.$emit('change', 3)
  await nextTick()

  expect(picker.props('value')).toBe(3)
})

The second test checks the loop: the child reports a change, the parent stores it and passes it back down as a prop. Break either half, by forgetting the @change handler or by binding a stale value, and the last assertion fails with expected 1 to be 3. That is a clearer failure than a wrong total, because the failing line names the prop.

props() with no argument returns every prop as an object, for the rare test that wants to check several at once.

Use attachTo when the test needs the document

By default VTU renders into a detached element: the DOM exists, but it is not in document. That is enough for text, classes and events on the component's own elements. Three things only work once the component is in the document:

  • Focus and document.activeElement.
  • Clicking a checkbox or radio with trigger('click'). Detached, the change event never fires, so its v-model does not change. setValue() works either way.
  • A listener on document that should hear events from inside the component. Events from a detached element never bubble that far.

For those, pass attachTo: document.body and unmount at the end, otherwise the elements accumulate across tests. Here SearchBox calls focus() on its input in onMounted:

it('focuses the input on mount', () => {
  const wrapper = mount(SearchBox, { attachTo: document.body })
  expect(document.activeElement).toBe(wrapper.get('input').element)
  wrapper.unmount()
})

Without attachTo, document.activeElement stays on body and the test fails even though the component is correct.

Layout is a different case. happy-dom does not lay out the page, so getBoundingClientRect() returns zeros whether the component is attached or not. Check sizes and positions in a real browser instead.

Check it stuck3 questions from the Testing props, emits and slots quiz.
Practice now
Was this helpful?

Keep reading