I'm new to Vue and currently writing unit tests for a search component that I am using in my project. Simply, when the user types in the input field and small X icon appears to the right of the input field. Clicking the X will reset the value of the field back to an empty string.
The component is using the composition API and working as intended, and I can watch the emits and payloads using Vue dev tools, however I have been unable to see these events using Vitest. The majority of the tests are failing, and I am wondering where the mistake is in my logic.
For this question I recreated the component with some scoped style to make it easy to mount if necessary. Here it is using the Vue3 Comp Api, TypeScript, Vite, Vitest and vue-test-utils.
Here is the component:
<template>
<div class="searchBar">
<input
:value="modelValue"
class="searchInput"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
autocomplete="off"
data-test="searchInput"
/>
<button
v-if="modelValue"
@click="clear($event)"
class="clearIcon"
ariaLabel="Clear Search"
data-test="clearIcon"
>
<i class="fa fa-times"></i>
</button>
</div>
</template>
<script lang="ts">
import {
defineComponent,
watch,
} from "vue";
export default defineComponent({
name: "SOComponent",
props: {
modelValue: {
type: [String, Number],
},
},
emits: [
"update:modelValue",
"search",
"clear",
],
setup(props, { emit }) {
function clear(event: Event) {
emit("clear", event);
emit("update:modelValue", "");
}
watch(
() => props.modelValue,
(newValue, oldValue) => {
if (newValue !== oldValue) {
emit("search", newValue);
}
}
);
return {
clear,
};
},
});
</script>
<style scoped>
.searchBar {
display: flex;
justify-content: space-between;
align-items: center;
background-color: white;
border: 2px solid black;
border-radius: 1rem;
}
.searchInput {
border: none;
width: 100%;
outline: none;
color: black;
font-size: 1rem;
padding: 1rem;
background-color: transparent;
}
.clearIcon {
display: flex;
align-items: center;
justify-content: center;
margin-right: 1rem;
background-color: red;
border: none;
color: white;
border-radius: 1rem;
padding: 6.5px 9px;
font-size: 1rem;
}
.clearIcon:hover {
background-color: darkred;
}
</style>
Here are the unit tests:
import { describe, it, expect, vi, afterEach } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import SOComponent from '../StackOverflowComponent.vue'
describe('SOComponent Component Tests', () => {
// Wrapper Factory
let wrapper: any
function createComponent() {
wrapper = shallowMount(SOComponent, {
attachTo: document.body
})
}
afterEach(() => {
wrapper.unmount()
})
// Helper Finder Functions
const searchInput = () => wrapper.find('[data-test="searchInput"]')
const clearIcon = () => wrapper.find('[data-test="clearIcon"]')
describe('component rendering', () => {
it('component renders as intended when created', () => {
createComponent()
expect(searchInput().exists()).toBe(true)
expect(clearIcon().exists()).toBe(false)
})
it('clear icon is displayed when input field has value', async () => {
createComponent()
await searchInput().setValue('render X')
expect(clearIcon().exists()).toBe(true)
})
it('clear icon is not displayed when input field has no value', async () => {
createComponent()
await searchInput().setValue('')
expect(clearIcon().exists()).toBe(false)
})
})
describe('component emits and methods', () => {
it('update:modelValue emits input value', async () => {
createComponent()
await searchInput().setValue('emit me')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['emit me'])
})
it('clear icon click calls clear method', async () => {
createComponent()
await searchInput().setValue('call it')
const clearSpy = vi.spyOn(wrapper.vm, 'clear')
await clearIcon().trigger('click')
expect(clearSpy).toHaveBeenCalled()
})
it('clear icon click resets input field value', async () => {
createComponent()
await searchInput().setValue('clear me')
await clearIcon().trigger('click')
expect((searchInput().element as HTMLInputElement).value).toBe('')
})
it('search is emitted when input gains value', async () => {
createComponent()
await searchInput().setValue('emit me')
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')![0]).toEqual(['emit me'])
})
it('clear is emitted when clear icon is clicked', async () => {
createComponent()
await searchInput().setValue('emit me')
await clearIcon().trigger('click')
expect(wrapper.emitted('clear')).toBeTruthy()
})
it('update:modelValue is emitted when clear icon is clicked', async () => {
createComponent()
await searchInput().setValue('clear me')
await clearIcon().trigger('click')
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')![1]).toEqual([''])
})
})
})
At this point I feel like I must be missing something fundamental about Vue3 reactivity since I am unable to test conditional renders attached to v-model. Honestly any help, solutions or advice would be very appreciated!
Thank you :)
From my understanding, it sounds like the 2 way binding for V-Model is not included in vue-test-utils. The fix I found was to set a watcher in props to track update:modelValue, and this will update the modelValue prop.
function createComponent() {
wrapper = shallowMount(Component, {
attachTo: document.body,
props: {
'onUpdate:modelValue': async (modelValue: any) => await wrapper.setProps({ modelValue })
}
})
}
Solution: https://github.com/vuejs/test-utils/discussions/279