Testing Vue Components with Slots
Slots are one of the most powerful language features in Vue. With the ability to define fallback content, named slots, and scoped slots, they allow parent components to inject their own markup, styles, and behavior.
Like props and events, slots are part of the component's public API.
Common components like Buttons and Inputs often use slots like "prefix" and "suffix" to allow you to define icon placement and use SVGs or entire Icon components.
Page-level layout components like a Sidebar or Footer also commonly make use of slots.
Lastly, renderless components, like a Loading component or ApolloQuery component make heavy use of slots in order to define what to render in various states like: error, loading, and success.
The Simplest Slot
We'll be showing off how to test a Modal that uses a default <slot/>
. Like in
previous sections, we'll start simple.
it('renders the modal content', () => {
cy.mount(Modal, { slots: { default: () => 'Content' } })
.get(modalSelector)
.should('have.text', 'Content')
})
it('can be closed', () => {
cy.mount(Modal, { slots: { default: () => 'Content' } })
.get(modalSelector)
.should('have.text', 'Content')
.get(closeButtonSelector)
.should('have.text', 'Close')
.click()
// Repeat the assertion to make sure the text
// is no longer visible
.get(modalSelector)
.should('not.have.text', 'Content')
})
it('renders the modal content', () => {
cy.mount(() => <Modal>Content</Modal>)
.get(modalSelector)
.should('have.text', 'Content')
})
it('can be closed', () => {
cy.mount(() => <Modal>Content</Modal>)
.get(modalSelector)
.should('have.text', 'Content')
.get(closeButtonSelector)
.should('have.text', 'Close')
.click()
// Repeat the assertion to make sure the text
// is no longer visible
.get(modalSelector)
.should('not.have.text', 'Content')
})
<template>
<div class="overlay" v-if="show">
<div class="modal">
<button @click="show = !show">Close</button>
<slot />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>
<style scoped>
.overlay {
position: fixed;
display: flex;
padding-top: 120px;
justify-content: center;
background: rgba(100, 100, 100, 30%);
left: 0;
right: 0;
top: 0;
bottom: 0;
}
.modal {
position: absolute;
min-height: 350px;
min-width: 400px;
background: white;
}
</style>
<template>
<div class="overlay" v-if="show">
<div class="modal">
<button @click="show = !show">Close</button>
<slot />
</div>
</div>
</template>
<script setup>
export default {
data() {
return {
show: true,
}
},
}
</script>
<style scoped>
.overlay {
position: fixed;
display: flex;
padding-top: 120px;
justify-content: center;
background: rgba(100, 100, 100, 30%);
left: 0;
right: 0;
top: 0;
bottom: 0;
}
.modal {
position: absolute;
min-height: 350px;
min-width: 400px;
background: white;
}
</style>
Named Slots
Named slots in Vue give the parent component the ability to inject different markup and logic from the parent into container components.
In the case of our modal, the modal might define a header, footer, and body named slot.
This is all part of the component's API and exercising it thoroughly is the responsibility of the test.
const footerText = 'My Custom Footer'
const headerText = 'My Custom Header'
const slots = {
default: () => 'Content',
footer: () => footerText,
header: () => headerText
}
it('renders the default modal content', () => {
cy.mount(Modal, { slots })
.get(modalSelector).should('have.text', 'Content')
})
it('renders a custom footer', () => {
const footerText = 'My Custom Footer'
cy.mount(Modal, { slots })
.get(modalSelector).should('have.text', 'Content')
.and('have.text' footerText)
})
it('renders a custom header', () => {
const headerText = 'My Custom Header'
cy.mount(Modal, { slots })
.get(modalSelector).should('have.text', 'Content')
.and('have.text' headerText)
})
it('renders the fallback "Close" button when no footer is provided', () => {
cy.mount(Modal, { slots })
.get(modalSelector).should('have.text', 'Content')
.get(closeButtonSelector).should('have.text', 'Close').click()
// Repeat the assertion to make sure the text
// is no longer visible
.get(modalSelector).should('not.have.text', 'Content')
})
const footerText = 'My Custom Footer'
const headerText = 'My Custom Header'
const slots = {
footer: () => footerText,
header: () => headerText
}
it('renders the default modal content', () => {
cy.mount(() => <Modal {...slots}>Content</Modal>)
.get(modalSelector).should('have.text', 'Content')
})
it('renders a custom footer', () => {
const footerText = 'My Custom Footer'
cy.mount(() => <Modal {...slots}>Content</Modal>)
.get(modalSelector).should('have.text', 'Content')
.and('have.text' footerText)
})
it('renders a custom header', () => {
const headerText = 'My Custom Header'
cy.mount(() => <Modal {...slots}>Content</Modal>)
.get(modalSelector).should('have.text', 'Content')
.and('have.text' headerText)
})
it('renders the fallback "Close" button when no footer is provided', () => {
cy.mount(() => <Modal>Content</Modal>)
.get(modalSelector).should('have.text', 'Content')
.get(closeButtonSelector).should('have.text', 'Close').click()
// Repeat the assertion to make sure the text
// is no longer visible
.get(modalSelector).should('not.have.text', 'Content')
})
<template>
<div class="overlay" v-if="show">
<div class="modal">
<div class="header"><slot name="header"></div>
<hr/>
<div class="content"><slot/></div>
<hr/>
<div class="footer"><slot name="footer"><button @click="show = !show">Close</button></slot></div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>
<style scoped>
.overlay { /* Styles */ }
.modal {
/* Styles */
display: flex;
flex-direction: column;
}
.header {
font-size: 1.25rem;
}
.content {
flex-grow: 1;
}
</style>
<template>
<div class="overlay" v-if="show">
<div class="modal">
<div class="header"><slot name="header"></div>
<hr/>
<div class="content"><slot/></div>
<hr/>
<div class="footer"><slot name="footer">
<button @click="show = !show">Close</button>
</slot></div>
</div>
</div>
</template>
<script setup>
export default {
data() {
return {
show: true
}
}
}
</script>
<style scoped>
.overlay { /* Styles */ }
.modal {
/* Styles */
display: flex;
flex-direction: column;
}
.header {
font-size: 1.25rem;
}
.content {
flex-grow: 1;
}
</style>
Scoped Slots
Now, what if we want to allow the parent to control when to close the modal? We can provide a slot prop, a function, called close to any of the slots we want.
The implementation of our modal will change slightly and we only have to show off the template to demonstrate the change.
<template>
<div class="overlay" v-if="show">
<div class="modal">
<div class="header"><slot name="header" :close="onClose"></div>
<hr/>
<div class="content"><slot :close="onClose"/></div>
<hr/>
<div class="footer"><slot name="footer" :close="onClose">
<button @click="show = !show">Close</button>
</slot></div>
</div>
</div>
</template>
Now here, we can write a few new tests! Each of our parent components should be
able to utilize the method and make sure it's wired up correctly. We're going to
import h
from Vue to create real virtual nodes so that we can interact with
them from the outside of the test.
This is one of the points at which you may find JSX/TSX tests more elegant to work with.
import { h } from 'vue'
const footerSelector = '[data-testid=footer-close]'
const headerSelector = '[data-testid=header-close]'
const contentSelector = '[data-testid=content-close]'
const text = 'Close me!'
const slots = {
footer: ({ close }) => h('div', { onClick: close, 'data-testid': 'footer-close' }, text }),
header: ({ close }) => h('div', { onClick: close, 'data-testid': 'header-close' }, text }),
default: ({ close }) => h('div', { onClick: close, 'data-testid': 'content-close' }, text }),
}
it('The footer slot binds the close method', () => {
cy.mount(Modal, { slots })
.get(footerSelector).should('have.text', text)
.click()
.get(modalSelector).should('not.exist')
})
it('The header slot binds the close method', () => {
cy.mount(Modal, { slots })
.get(headerSelector).should('have.text', text)
.click()
.get(modalSelector).should('not.exist')
})
it('The default slot binds the close method', () => {
cy.mount(Modal, { slots })
.get(contentSelector).should('have.text', text)
.click()
.get(modalSelector).should('not.exist')
})
const footerSelector = '[data-testid=footer-close]'
const headerSelector = '[data-testid=header-close]'
const contentSelector = '[data-testid=content-close]'
const text = 'Close me!'
const slots = {
footer: ({ close }) => <div onClick={close} data-testid="footer-close">{text}</div>),
header: ({ close }) => <div onClick={close} data-testid="header-close">{text}</div>),
default: ({ close }) => <div onClick={close} data-testid="content-close">{text}</div>),
}
it('The footer slot binds the close method', () => {
cy.mount(() => <Modal {...slots}/>)
.get(footerSelector).should('have.text', text)
.click()
.get(modalSelector).should('not.exist')
})
it('The header slot binds the close method', () => {
cy.mount(() => <Modal {...slots}/>)
.get(headerSelector).should('have.text', text)
.click()
.get(modalSelector).should('not.exist')
})
it('The default slot binds the close method', () => {
cy.mount(() => <Modal {...slots}/>)
.get(contentSelector).should('have.text', text)
.click()
.get(modalSelector).should('not.exist')
})
What's Next?
Now that you're comfortable mounting components and asserting on their slots, scoped slots, and fallbacks, you should be ready to test most components!
Let's work on configuring a custom mount command to handle applications like Vuetify and plugins like Vue Router.