You can find the content of this article updated to the most recent version of vue-test-utils and Jest on my book.
Slots are the way to make content distribution happen in the web components world. Vue.js slots are made following the Web Component specs, meaning that if you learn how to use them in Vue.js, that will be useful in the future 😉.
They make components structure to be much more flexible, moving the responsibility of managing the state to the parent component. For example, we can have a List
component, and different kind of item components, such ListItem
and ListItemImage
. They'll be used like:
<template>
<List>
<ListItem :someProp="someValue" />
<ListItem :someProp="someValue" />
<ListItemImage :image="imageUrl" :someProp="someValue" />
</List>
</template>
The inner content of List
is the slot itself, and its accessible via <slot>
tag. So the List
implementation looks like:
<template>
<ul>
<!-- slot here will equal to what's inside <List> -->
<slot></slot>
</ul>
</template>
And, say that the ListItem
component looks like:
<template>
<li> {{ someProp }} </li>
</template>
Then, the final result rendered by Vue.js would be:
<ul>
<li> someValue </li>
<li> someValue </li>
<li> someValue </li> <!-- assume the same implementation for ListItemImage -->
</ul>
Make MessageList slot based
Let's take a look at the MessageList.vue
component:
<template>
<ul>
<Message
@message-clicked="handleMessageClick"
:message="message"
v-for="message in messages"
:key="message"/>
</ul>
</template>
MessageList has "hardcoded" the Message component inside. In a way that's more automated, but in the other is not flexible at all. What if you wanna have different types of Message components? What about changing its structure or styling? That's where slots come in handy.
Let's change Message.vue
to use slots. First, move that <Message...
part to the App.vue
component, as well as the handleMessageClick
method, so it's used externally:
<template>
<div id="app">
<MessageList>
<Message
@message-clicked="handleMessageClick"
:message="message"
v-for="message in messages"
:key="message"/>
</MessageList>
</div>
</template>
<script>
import MessageList from './components/MessageList'
import Message from './components/Message'
export default {
name: 'app',
data: () => ({ messages: ['Hey John', 'Howdy Paco'] }),
methods: {
handleMessageClick(message) {
console.log(message)
}
},
components: {
MessageList,
Message
}
}
</script>
Don't forget to import the Message component and add it to the components
option in App.vue
.
Then, in MessageList.vue
, we can remove the references to Message
, looking like:
<template>
<ul class="list-messages">
<slot></slot>
</ul>
</template>
<script>
export default {
name: 'MessageList'
}
</script>
$children
and $slots
Vue components have two instance variables useful for accessing slots:
$children
: an array of Vue component instances of the default slot.$slots
: an object of VNodes mapping all the slots defined in the component instance.
The $slots
object has more data available. In fact, $children
is just a portion of the $slots
variable, that could be accessed the same way by mapping over the $slots.default
array, filtered by Vue component instances:
const children = this.$slots.default
.map(vnode => vnode.componentInstance)
.filter(cmp => !!cmp);
Testing Slots
Probably what we want to test the most out of slots is where they end up in the component, and for that we can reuse the skills got in the article Test Styles and Structure of Vue.js Components in Jest.
Right now, most of the tests in MessageList.test.js
will fail, so let's remove them all (or comment them out), and focus on slot testing.
One thing we can test, is to make sure that the Message components end up within a ul
element with class list-messages
. In order to pass slots to the MessageList
component, we can use the slots
property of the options object of mount
or shallowMount
methods. So let's create a beforeEach
method with the following code:
beforeEach(() => {
cmp = shallowMount(MessageList, {
slots: {
default: '<div class="fake-msg"></div>'
}
});
});
Since we just want to test if the messages are rendered, we can search for <div class="fake-msg"></div>
as follows:
it("Messages are inserted in a ul.list-messages element", () => {
const list = cmp.find("ul.list-messages");
expect(list.findAll(".fake-msg").length).toBe(1);
});
And that should be ok to go. The slots option also accepts a component declaration, and even an array, so we could write:
import AnyComponent from 'anycomponent'
...
shallowMount(MessageList, {
slots: {
default: AnyComponent // or [AnyComponent, AnyComponent]
}
})
The problem with that is that is very limited, you cannot override props for example, and we need that for the Message
component since it has a required property. This should affect the cases that you really need to test slots with the expected components. For example, if you wanna make sure that MessageList
expects only Message
components as slots. That's on track and at some point it will land in vue-test-utils.
As a workaround, we can accomplish that by using a render function. So we can rewrite the test to be more specific:
beforeEach(() => {
const messageWrapper = {
render(h) {
return h(Message, { props: { message: "hey" } });
}
};
cmp = shallowMount(MessageList, {
slots: {
default: messageWrapper
}
});
});
it("Messages are inserted in a MessageList component", () => {
const list = cmp.find(MessageList);
expect(list.find(Message).isVueInstance()).toBe(true);
});
Testing Named Slots
The unnamed slot we used above is called the default slot, but we can have multiple slots by using named slots. Let's add a header to the MessageList.vue
component:
<template>
<div>
<header class="list-header">
<slot name="header">
This is a default header
</slot>
</header>
<ul class="list-messages">
<slot></slot>
</ul>
</div>
</template>
By using <slot name="header">
we're defining another slot for the header. You can see a This is a default header
text inside the slot, that's displayed as the default content when a slot is not passed to the component, and that's applicable to the default slot.
Then, from App.vue
we can use add a header to the MessageList
component by using the slot="header"
attribute:
<template>
<div id="app">
<MessageList>
<header slot="header">
Awesome header
</header>
<Message
@message-clicked="handleMessageClick"
:message="message"
v-for="message in messages"
:key="message"/>
</MessageList>
</div>
</template>
It's time to write a unit test for it. Testing named slots is just as testing a default slot, the same dynamics apply. So, we can start by testing that the header slot is rendered within the <header class="list-header">
element, and it renders a default text when no header slot is passed by. In MessageList.test.js
:
it("Header slot renders a default header text", () => {
const header = cmp.find(".list-header");
expect(header.text().trim()).toBe("This is a default header");
});
Then, the same but checking the default content gets replaced when we mock the header slot:
it("Header slot is rendered withing .list-header", () => {
const component = shallowMount(MessageList, {
slots: {
header: "<div>What an awesome header</div>"
}
});
const header = component.find(".list-header");
expect(header.text().trim()).toBe("What an awesome header");
});
See that the header slot used in this last test is wrapped in a <div>
. It's important the slots are wrapped in an html tag, otherwise vue-test-utils will complain.
Testing Contextual Slot Specs
We've test how and where the slots render, and probably that's what we mostly need. However, it doesn't end there. If you pass component instances as slots, just as we're doing in the default slot with Message, you can test functionality related to it.
Be careful on what you test here, this is probably something you don't need to do in most cases, since the functional tests of a component should belong to that component test. When talking about testing slots functionality, we test how a slot must behave in the context of the component where that slot is used, and that's something is not very common. Normally we just pass the slot and forget about it. So don't get too stick to the following example, It's only purpose is to demonstrate how the tool works.
Let's say that, for whatever reason, in the context of the MessageList
component, all the Message
components must have a length higher than 5. We can test that like:
it("Message length is higher than 5", () => {
const messages = cmp.findAll(Message);
messages.wrappers.forEach(c => {
expect(c.vm.message.length).toBeGreaterThan(5);
});
});
findAll
returns an object containing an array of wrappers
where we can access its vm
component instance property. This test will fail because the message has a length of 3, so go to the beforeEach
function and make it longer:
beforeEach(() => {
const messageWrapper = {
render(h) {
return h(Message, { props: { message: 'hey yo' } })
}
}
...
Then it should pass.
Conclusion
Testing slots is very simple, normally we'd like to test that they're placed and rendered as we want, so is just like testing style and structure knowing how slots behave or can be mocked. You won't need to test slot functionality very ofter probably. Keep in mind to test things only related to slots when you want to test slots, and think twice if what you're testing belongs to the slot test or the component test itself.
You can find the code of this article in this repo.