Testing props, emits and slots
Test a component's contract from the outside: pass props and slots in, read the text and the emitted events out, and stub the children you are not testing.
What you'll learn
When you finish, you can do these.- 1Pass props in a test and change them with setProps()
- 2Check emitted events and test v-model on a component
- 3Fill slots in a test and stub a child you are not testing
- 4Fix a check on equal arrays and a setProps() without await
A price tag on a shop page gets two values from its parent, the component that puts it on the page: an amount and a sale flag. Values passed down like this are props. When someone clicks it, the tag emits an event named select, a message sent up to the parent. Rename that event to choose, and every parent listening for select stops reacting, with no error anywhere.
A component's contract with its parent has three parts: props, events and slots, the spots a parent fills with its own markup. A component test checks that contract from the outside, so a broken promise fails a test, not a page. The Code tab holds three pieces of a shop page with their tests: the price tag, a stepper for how many to buy, and a product card with slots. You need one idea from Vitest and Vue Test Utils: mount() puts a component on a page in memory and returns a wrapper, an object your test clicks with trigger() and reads.
The test plays the parent
In an app, a parent passes props down, listens for events coming up, and fills its children's slots. A test has no parent component, so the test takes that role: the options of mount(), its second argument, are what the parent would pass, and the wrapper records what the child sends back.
That gives every test the same shape: pass props or slots in, act like a person, then read what came out, such as the text, a class or an emitted event. Nothing reaches inside the component.
In the Code tab, the price tag is src/components/PriceTag.vue: a button that shows the amount, takes a sale class when the parent says so, and emits select with the amount when clicked. defineEmits() declares that event and returns emit, the function that sends it.
<script setup lang="ts">
const { amount, sale = false } = defineProps<{ amount: number; sale?: boolean }>()
const emit = defineEmits<{ select: [amount: number] }>()
</script>
<template>
<button type="button" :class="{ sale }" @click="emit('select', amount)">
{{ amount.toFixed(2) }}
</button>
</template>
Given an amount of 12.5, the button reads 12.50.
Props go in the props option of mount(). When a sale ends, the parent sets sale to false, and setProps() does the same mid-test. Vue updates the page a moment later, so await it, as you await trigger().
it('marks a sale, and follows the prop when it changes', async () => {
const wrapper = mount(PriceTag, { props: { amount: 12.5, sale: true } })
expect(wrapper.classes()).toContain('sale')
await wrapper.setProps({ sale: false })
expect(wrapper.classes()).not.toContain('sale')
})
classes() lists the button's classes, and toContain looks for sale: there while the sale is on, gone after the awaited setProps().
Events out with emitted()
Events go the other way, up from the child, and Vue Test Utils records every one. wrapper.emitted('select') is undefined until the first emit. After that it is an array with one entry per emit, and each entry is the array of arguments, the values sent with the event. One click with one argument is [[12.5]], and toEqual compares it value by value.
expect(wrapper.emitted('select')).toBeUndefined()
await wrapper.trigger('click')
expect(wrapper.emitted('select')).toEqual([[12.5]]) // one event, one argument
Both checks pass.
On a component, v-model, the two-way binding of form inputs, is a prop plus an event: modelValue goes down and update:modelValue comes up. The price tag has no v-model, so the shop page's stepper shows it: a number in an <output> element and an Add one button, for how many to buy.
In the Code tab, the stepper is src/components/Stepper.vue, built on defineModel(), which returns a ref, a box that holds a value: reading it gives modelValue, and changing it, as the button's count++ does, emits update:modelValue.
<script setup lang="ts">
const count = defineModel<number>({ default: 0 })
</script>
<template>
<output>{{ count }}</output>
<button type="button" @click="count++">Add one</button>
</template>
On its own it shows 0. To test it, play the whole parent: pass modelValue, and an onUpdate:modelValue handler, a function that listens for update:modelValue and writes the new value back with setProps().
const wrapper = mount(Stepper, {
props: {
modelValue: 4,
// Play the parent: write each update back into the prop
'onUpdate:modelValue': (value: number) => {
wrapper.setProps({ modelValue: value })
},
},
})
After a click on Add one, the event says [[5]] and the output shows 5. The handler matters: one that does not write back is a parent that ignores the update, and the output stays at 4.
Slots, and children you do not want to run
Slots are the last part of the contract. The price tag has none, so the shop page's product card shows them: a title, a line of text and a footer, all filled by its parent.
In the Code tab, the card is src/components/ProductCard.vue, with a named title slot, a default slot with no name, and a footer slot whose fallback text shows when the parent fills nothing. It also renders PriceChart, a child component that draws the price history on a canvas, an element for drawing.
<header><slot name="title" /></header>
<slot />
<PriceChart :product-id="productId" />
<footer>
<slot name="footer">Free delivery over 30 euros</slot>
</footer>
The chart sits between the text and the footer.
In a test, the slots option of mount() fills slots by name, with plain text or an HTML string. The chart gets in the way first. The simulated page a test runs in has no canvas, so getContext('2d'), the chart's first drawing call, returns null, and mounting the card fails with TypeError: Cannot read properties of null (reading 'fillRect').
The chart is not what this test is about anyway. The mount() option global.stubs replaces a child with a stub, an empty placeholder that keeps its props. The first test in ProductCard.spec.ts uses both options:
const stubs = { PriceChart: true } // the simulated page has no canvas
const wrapper = mount(ProductCard, {
props: { productId: 7 },
slots: {
title: '<h2>Sticker pack</h2>',
default: 'Ten stickers, waterproof.',
},
global: { stubs },
})
The h2 lands in the header, the text in the middle, and the footer, left out, shows its fallback.
Where the chart was, the page holds <price-chart-stub productid="7">, and the second test checks what the card passed it: findComponent(PriceChart).props('productId') is 7. The chart gets its own tests, or a browser test from the Testing Library and browser mode lesson later in this track.
What goes wrong
Both mistakes fail a test while the component works. The first compares the emitted array with toBe, which asks whether two values are the same object, and two arrays built separately never are, even when the values match.
expect(wrapper.emitted('select')).toBe([[12.5]])
Vitest prints AssertionError: expected [ [ 12.5 ] ] to be [ [ 12.5 ] ] // Object.is equality, then Compared values have no visual difference. and a hint to use deep equality. toEqual compares the contents instead.
expect(wrapper.emitted('select')).toEqual([[12.5]])
The same check now passes.
The second mistake is setProps() without await. The prop changes at once, but the class list is read before Vue updates the page.
wrapper.setProps({ sale: false })
expect(wrapper.classes()).not.toContain('sale')
The failure reads AssertionError: expected [ 'sale' ] to not include 'sale', which looks like a bug in the component until you notice the missing await.
await wrapper.setProps({ sale: false })
expect(wrapper.classes()).not.toContain('sale')
The classes are read after the update, and the test passes.
What to remember
- The test plays the parent: props and slots go into
mount(), and text, classes and emitted events come out. - Await
setProps()andtrigger()before reading the page. emitted('name')isundefinedbefore the first emit, then an array of calls where each call is an array of arguments. Compare it withtoEqual.- Test
v-modelby passingmodelValuewith anonUpdate:modelValuehandler that callssetProps(). - Stub children that need things the simulated page lacks, such as a canvas, and check the props they receive with
findComponent().
In your project
Do these in a real project. Each one says how to confirm it worked.- In
ProductCard.spec.ts, add a test that mounts the card with the stubs andslots: { footer: 'Ships in 2 days' }, and expects the footer text to beShips in 2 days. Runnpx vitest run: the new test passes, and the first test still sees the fallback text. - Remove the
awaitbeforesetProps()inPriceTag.spec.ts. The test fails withexpected [ 'sale' ] to not include 'sale'. Put theawaitback and it passes. - Delete
global: { stubs }from the firstProductCardtest. It fails withTypeError: Cannot read properties of null (reading 'fillRect')fromPriceChart.vue. Restore the stub, addconsole.log(wrapper.html()), and the output shows<price-chart-stub productid="7"></price-chart-stub>where the canvas was.